feat: rss-subscriptions (#28)
* feat(subscriptions): add subscription atoms and types for feeds and rules * refactor(types): remove obsolete format/quality/codec fields and related logic to simplify download and avoid redundant storage * feat: add download_history schema, migration, and tag utils * refactor(dialog): convert to functional wrappers, simplify overlay and * feat(rss): add enclosure support, refine thumbnail lookup and UI labels feat(settings): add toggleable anonymous analytics collection and loader script * feat(settings): add toggleable anonymous analytics collection and loader script * feat(i18n): add subscriptions UI strings and import downloads types * refactor(subscriptions): remove legacy status fields and migrate items ordering * feat: add Radix HoverCard dependency and wrap tabs with HoverCard for * feat(db): move download history schema into migrate + drizzle config * feat(subscriptions): add item queueing UI and IPC handler * feat(downloads): normalize saved filenames, improve candidates and UI display
This commit is contained in:
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/main/lib/database/schema.ts',
|
||||||
|
out: './resources/drizzle',
|
||||||
|
dialect: 'sqlite'
|
||||||
|
})
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
||||||
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
|
||||||
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
|
||||||
"release": "git checkout main && git pull && pnpm run check && bumpp"
|
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||||
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.2",
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
@@ -27,8 +28,10 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
@@ -58,9 +61,11 @@
|
|||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
"react-i18next": "^16.0.0",
|
"react-i18next": "^16.0.0",
|
||||||
"react-router": "^7.9.4",
|
"react-router": "^7.9.4",
|
||||||
|
"rss-parser": "^3.13.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"yt-dlp-wrap-plus": "^2.3.20",
|
"yt-dlp-wrap-plus": "^2.3.20",
|
||||||
"zod": "^4.1.11"
|
"zod": "^4.1.11"
|
||||||
},
|
},
|
||||||
@@ -73,6 +78,7 @@
|
|||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"bumpp": "^10.3.1",
|
"bumpp": "^10.3.1",
|
||||||
|
"drizzle-kit": "^0.31.7",
|
||||||
"electron": "^38.1.2",
|
"electron": "^38.1.2",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^25.1.8",
|
||||||
"electron-vite": "^4.0.1",
|
"electron-vite": "^4.0.1",
|
||||||
|
|||||||
393
pnpm-lock.yaml
generated
393
pnpm-lock.yaml
generated
@@ -23,12 +23,18 @@ importers:
|
|||||||
'@radix-ui/react-checkbox':
|
'@radix-ui/react-checkbox':
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.3.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-context-menu':
|
||||||
|
specifier: ^2.2.16
|
||||||
|
version: 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.15
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.16
|
specifier: ^2.1.16
|
||||||
version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-hover-card':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.7
|
specifier: ^2.1.7
|
||||||
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -116,6 +122,9 @@ importers:
|
|||||||
react-router:
|
react-router:
|
||||||
specifier: ^7.9.4
|
specifier: ^7.9.4
|
||||||
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
rss-parser:
|
||||||
|
specifier: ^3.13.0
|
||||||
|
version: 3.13.0
|
||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
@@ -125,6 +134,9 @@ importers:
|
|||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.13
|
||||||
version: 4.1.15
|
version: 4.1.15
|
||||||
|
tw-animate-css:
|
||||||
|
specifier: ^1.4.0
|
||||||
|
version: 1.4.0
|
||||||
yt-dlp-wrap-plus:
|
yt-dlp-wrap-plus:
|
||||||
specifier: ^2.3.20
|
specifier: ^2.3.20
|
||||||
version: 2.3.20
|
version: 2.3.20
|
||||||
@@ -156,6 +168,9 @@ importers:
|
|||||||
bumpp:
|
bumpp:
|
||||||
specifier: ^10.3.1
|
specifier: ^10.3.1
|
||||||
version: 10.3.1
|
version: 10.3.1
|
||||||
|
drizzle-kit:
|
||||||
|
specifier: ^0.31.7
|
||||||
|
version: 0.31.7
|
||||||
electron:
|
electron:
|
||||||
specifier: ^38.1.2
|
specifier: ^38.1.2
|
||||||
version: 38.3.0
|
version: 38.3.0
|
||||||
@@ -342,6 +357,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
||||||
engines: {node: '>= 8.9.0'}
|
engines: {node: '>= 8.9.0'}
|
||||||
|
|
||||||
|
'@drizzle-team/brocli@0.10.2':
|
||||||
|
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||||
|
|
||||||
'@electron-toolkit/preload@3.0.2':
|
'@electron-toolkit/preload@3.0.2':
|
||||||
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
|
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -384,102 +402,206 @@ packages:
|
|||||||
resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==}
|
resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==}
|
||||||
engines: {node: '>=16.4'}
|
engines: {node: '>=16.4'}
|
||||||
|
|
||||||
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
|
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
|
||||||
|
deprecated: 'Merged into tsx: https://tsx.is'
|
||||||
|
|
||||||
|
'@esbuild-kit/esm-loader@2.6.5':
|
||||||
|
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
|
||||||
|
deprecated: 'Merged into tsx: https://tsx.is'
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.11':
|
'@esbuild/aix-ppc64@0.25.11':
|
||||||
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [aix]
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.25.11':
|
'@esbuild/android-arm64@0.25.11':
|
||||||
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.18.20':
|
||||||
|
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-arm@0.25.11':
|
'@esbuild/android-arm@0.25.11':
|
||||||
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
'@esbuild/android-x64@0.25.11':
|
'@esbuild/android-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [android]
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.25.11':
|
'@esbuild/darwin-arm64@0.25.11':
|
||||||
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.25.11':
|
'@esbuild/darwin-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.25.11':
|
'@esbuild/freebsd-arm64@0.25.11':
|
||||||
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.25.11':
|
'@esbuild/freebsd-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [freebsd]
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.25.11':
|
'@esbuild/linux-arm64@0.25.11':
|
||||||
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.18.20':
|
||||||
|
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.25.11':
|
'@esbuild/linux-arm@0.25.11':
|
||||||
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.18.20':
|
||||||
|
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.25.11':
|
'@esbuild/linux-ia32@0.25.11':
|
||||||
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.25.11':
|
'@esbuild/linux-loong64@0.25.11':
|
||||||
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.18.20':
|
||||||
|
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.25.11':
|
'@esbuild/linux-mips64el@0.25.11':
|
||||||
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [mips64el]
|
cpu: [mips64el]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.25.11':
|
'@esbuild/linux-ppc64@0.25.11':
|
||||||
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.25.11':
|
'@esbuild/linux-riscv64@0.25.11':
|
||||||
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.18.20':
|
||||||
|
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.25.11':
|
'@esbuild/linux-s390x@0.25.11':
|
||||||
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.25.11':
|
'@esbuild/linux-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -492,6 +614,12 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [netbsd]
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.25.11':
|
'@esbuild/netbsd-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -504,6 +632,12 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openbsd]
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.25.11':
|
'@esbuild/openbsd-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -516,24 +650,48 @@ packages:
|
|||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [openharmony]
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.25.11':
|
'@esbuild/sunos-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [sunos]
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.25.11':
|
'@esbuild/win32-arm64@0.25.11':
|
||||||
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.18.20':
|
||||||
|
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.25.11':
|
'@esbuild/win32-ia32@0.25.11':
|
||||||
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
cpu: [ia32]
|
cpu: [ia32]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.18.20':
|
||||||
|
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.25.11':
|
'@esbuild/win32-x64@0.25.11':
|
||||||
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -701,6 +859,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16':
|
||||||
|
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-context@1.1.2':
|
'@radix-ui/react-context@1.1.2':
|
||||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -780,6 +951,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-hover-card@1.1.15':
|
||||||
|
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-id@1.1.1':
|
'@radix-ui/react-id@1.1.1':
|
||||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1896,6 +2080,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
drizzle-kit@0.31.7:
|
||||||
|
resolution: {integrity: sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
drizzle-orm@0.44.7:
|
drizzle-orm@0.44.7:
|
||||||
resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==}
|
resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2063,6 +2251,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
entities@2.2.0:
|
||||||
|
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
|
||||||
|
|
||||||
entities@4.5.0:
|
entities@4.5.0:
|
||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
@@ -2100,6 +2291,16 @@ packages:
|
|||||||
es6-error@4.1.1:
|
es6-error@4.1.1:
|
||||||
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
|
||||||
|
|
||||||
|
esbuild-register@3.6.0:
|
||||||
|
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||||
|
peerDependencies:
|
||||||
|
esbuild: '>=0.12 <1'
|
||||||
|
|
||||||
|
esbuild@0.18.20:
|
||||||
|
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
esbuild@0.25.11:
|
esbuild@0.25.11:
|
||||||
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -2233,6 +2434,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
get-tsconfig@4.13.0:
|
||||||
|
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||||
|
|
||||||
giget@2.0.0:
|
giget@2.0.0:
|
||||||
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3063,6 +3267,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
responselike@2.0.1:
|
responselike@2.0.1:
|
||||||
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
|
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
|
||||||
|
|
||||||
@@ -3088,6 +3295,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
rss-parser@3.13.0:
|
||||||
|
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
|
||||||
|
|
||||||
safe-buffer@5.1.2:
|
safe-buffer@5.1.2:
|
||||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
@@ -3294,6 +3504,9 @@ packages:
|
|||||||
tunnel-agent@0.6.0:
|
tunnel-agent@0.6.0:
|
||||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||||
|
|
||||||
|
tw-animate-css@1.4.0:
|
||||||
|
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||||
|
|
||||||
type-fest@0.13.1:
|
type-fest@0.13.1:
|
||||||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3471,6 +3684,14 @@ packages:
|
|||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
xml2js@0.5.0:
|
||||||
|
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
xmlbuilder@11.0.1:
|
||||||
|
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
xmlbuilder@15.1.1:
|
xmlbuilder@15.1.1:
|
||||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||||
engines: {node: '>=8.0'}
|
engines: {node: '>=8.0'}
|
||||||
@@ -3685,6 +3906,8 @@ snapshots:
|
|||||||
ajv: 6.12.6
|
ajv: 6.12.6
|
||||||
ajv-keywords: 3.5.2(ajv@6.12.6)
|
ajv-keywords: 3.5.2(ajv@6.12.6)
|
||||||
|
|
||||||
|
'@drizzle-team/brocli@0.10.2': {}
|
||||||
|
|
||||||
'@electron-toolkit/preload@3.0.2(electron@38.3.0)':
|
'@electron-toolkit/preload@3.0.2(electron@38.3.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
electron: 38.3.0
|
electron: 38.3.0
|
||||||
@@ -3768,81 +3991,157 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@esbuild-kit/core-utils@3.3.2':
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.18.20
|
||||||
|
source-map-support: 0.5.21
|
||||||
|
|
||||||
|
'@esbuild-kit/esm-loader@2.6.5':
|
||||||
|
dependencies:
|
||||||
|
'@esbuild-kit/core-utils': 3.3.2
|
||||||
|
get-tsconfig: 4.13.0
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.11':
|
'@esbuild/aix-ppc64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm64@0.25.11':
|
'@esbuild/android-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-arm@0.25.11':
|
'@esbuild/android-arm@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/android-x64@0.25.11':
|
'@esbuild/android-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-arm64@0.25.11':
|
'@esbuild/darwin-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/darwin-x64@0.25.11':
|
'@esbuild/darwin-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-arm64@0.25.11':
|
'@esbuild/freebsd-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/freebsd-x64@0.25.11':
|
'@esbuild/freebsd-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm64@0.25.11':
|
'@esbuild/linux-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-arm@0.25.11':
|
'@esbuild/linux-arm@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ia32@0.25.11':
|
'@esbuild/linux-ia32@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-loong64@0.25.11':
|
'@esbuild/linux-loong64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-mips64el@0.25.11':
|
'@esbuild/linux-mips64el@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-ppc64@0.25.11':
|
'@esbuild/linux-ppc64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-riscv64@0.25.11':
|
'@esbuild/linux-riscv64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-s390x@0.25.11':
|
'@esbuild/linux-s390x@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/linux-x64@0.25.11':
|
'@esbuild/linux-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-arm64@0.25.11':
|
'@esbuild/netbsd-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/netbsd-x64@0.25.11':
|
'@esbuild/netbsd-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-arm64@0.25.11':
|
'@esbuild/openbsd-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openbsd-x64@0.25.11':
|
'@esbuild/openbsd-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@esbuild/openharmony-arm64@0.25.11':
|
'@esbuild/openharmony-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/sunos-x64@0.25.11':
|
'@esbuild/sunos-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-arm64@0.25.11':
|
'@esbuild/win32-arm64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-ia32@0.25.11':
|
'@esbuild/win32-ia32@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.18.20':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/win32-x64@0.25.11':
|
'@esbuild/win32-x64@0.25.11':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4030,6 +4329,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
|
|
||||||
|
'@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
@@ -4109,6 +4422,23 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
|
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.2
|
||||||
|
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||||
|
|
||||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
'@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.2.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||||
@@ -5287,6 +5617,15 @@ snapshots:
|
|||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
|
drizzle-kit@0.31.7:
|
||||||
|
dependencies:
|
||||||
|
'@drizzle-team/brocli': 0.10.2
|
||||||
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
|
esbuild: 0.25.11
|
||||||
|
esbuild-register: 3.6.0(esbuild@0.25.11)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.44.7(better-sqlite3@12.4.1):
|
drizzle-orm@0.44.7(better-sqlite3@12.4.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
better-sqlite3: 12.4.1
|
better-sqlite3: 12.4.1
|
||||||
@@ -5407,6 +5746,8 @@ snapshots:
|
|||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
tapable: 2.3.0
|
tapable: 2.3.0
|
||||||
|
|
||||||
|
entities@2.2.0: {}
|
||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
@@ -5437,6 +5778,38 @@ snapshots:
|
|||||||
es6-error@4.1.1:
|
es6-error@4.1.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
esbuild-register@3.6.0(esbuild@0.25.11):
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
esbuild: 0.25.11
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
esbuild@0.18.20:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/android-arm': 0.18.20
|
||||||
|
'@esbuild/android-arm64': 0.18.20
|
||||||
|
'@esbuild/android-x64': 0.18.20
|
||||||
|
'@esbuild/darwin-arm64': 0.18.20
|
||||||
|
'@esbuild/darwin-x64': 0.18.20
|
||||||
|
'@esbuild/freebsd-arm64': 0.18.20
|
||||||
|
'@esbuild/freebsd-x64': 0.18.20
|
||||||
|
'@esbuild/linux-arm': 0.18.20
|
||||||
|
'@esbuild/linux-arm64': 0.18.20
|
||||||
|
'@esbuild/linux-ia32': 0.18.20
|
||||||
|
'@esbuild/linux-loong64': 0.18.20
|
||||||
|
'@esbuild/linux-mips64el': 0.18.20
|
||||||
|
'@esbuild/linux-ppc64': 0.18.20
|
||||||
|
'@esbuild/linux-riscv64': 0.18.20
|
||||||
|
'@esbuild/linux-s390x': 0.18.20
|
||||||
|
'@esbuild/linux-x64': 0.18.20
|
||||||
|
'@esbuild/netbsd-x64': 0.18.20
|
||||||
|
'@esbuild/openbsd-x64': 0.18.20
|
||||||
|
'@esbuild/sunos-x64': 0.18.20
|
||||||
|
'@esbuild/win32-arm64': 0.18.20
|
||||||
|
'@esbuild/win32-ia32': 0.18.20
|
||||||
|
'@esbuild/win32-x64': 0.18.20
|
||||||
|
|
||||||
esbuild@0.25.11:
|
esbuild@0.25.11:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.11
|
'@esbuild/aix-ppc64': 0.25.11
|
||||||
@@ -5602,6 +5975,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
pump: 3.0.3
|
pump: 3.0.3
|
||||||
|
|
||||||
|
get-tsconfig@4.13.0:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
giget@2.0.0:
|
giget@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
citty: 0.1.6
|
citty: 0.1.6
|
||||||
@@ -6404,6 +6781,8 @@ snapshots:
|
|||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
responselike@2.0.1:
|
responselike@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
lowercase-keys: 2.0.0
|
lowercase-keys: 2.0.0
|
||||||
@@ -6457,6 +6836,11 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
rss-parser@3.13.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 2.2.0
|
||||||
|
xml2js: 0.5.0
|
||||||
|
|
||||||
safe-buffer@5.1.2: {}
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
@@ -6665,6 +7049,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
tw-animate-css@1.4.0: {}
|
||||||
|
|
||||||
type-fest@0.13.1:
|
type-fest@0.13.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -6794,6 +7180,13 @@ snapshots:
|
|||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xml2js@0.5.0:
|
||||||
|
dependencies:
|
||||||
|
sax: 1.4.1
|
||||||
|
xmlbuilder: 11.0.1
|
||||||
|
|
||||||
|
xmlbuilder@11.0.1: {}
|
||||||
|
|
||||||
xmlbuilder@15.1.1: {}
|
xmlbuilder@15.1.1: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
66
resources/drizzle/0000_swift_aaron_stack.sql
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
CREATE TABLE `download_history` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`thumbnail` text,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`download_path` text,
|
||||||
|
`saved_file_name` text,
|
||||||
|
`file_size` integer,
|
||||||
|
`duration` integer,
|
||||||
|
`downloaded_at` integer NOT NULL,
|
||||||
|
`completed_at` integer,
|
||||||
|
`sort_key` integer NOT NULL,
|
||||||
|
`error` text,
|
||||||
|
`description` text,
|
||||||
|
`channel` text,
|
||||||
|
`uploader` text,
|
||||||
|
`view_count` integer,
|
||||||
|
`tags` text,
|
||||||
|
`origin` text,
|
||||||
|
`subscription_id` text,
|
||||||
|
`selected_format` text,
|
||||||
|
`playlist_id` text,
|
||||||
|
`playlist_title` text,
|
||||||
|
`playlist_index` integer,
|
||||||
|
`playlist_size` integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `subscription_items` (
|
||||||
|
`subscription_id` text NOT NULL,
|
||||||
|
`item_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`published_at` integer NOT NULL,
|
||||||
|
`thumbnail` text,
|
||||||
|
`added` integer NOT NULL,
|
||||||
|
`download_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`subscription_id`, `item_id`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `subscription_items_subscription_idx` ON `subscription_items` (`subscription_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `subscriptions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`source_url` text NOT NULL,
|
||||||
|
`feed_url` text NOT NULL,
|
||||||
|
`platform` text NOT NULL,
|
||||||
|
`keywords` text NOT NULL,
|
||||||
|
`tags` text NOT NULL,
|
||||||
|
`only_latest` integer NOT NULL,
|
||||||
|
`enabled` integer NOT NULL,
|
||||||
|
`cover_url` text,
|
||||||
|
`latest_video_title` text,
|
||||||
|
`latest_video_published_at` integer,
|
||||||
|
`last_checked_at` integer,
|
||||||
|
`last_success_at` integer,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`last_error` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`download_directory` text,
|
||||||
|
`naming_template` text
|
||||||
|
);
|
||||||
451
resources/drizzle/meta/0000_snapshot.json
Normal file
451
resources/drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "91501763-7554-435d-91d8-382c52ee65e4",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"download_history": {
|
||||||
|
"name": "download_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"name": "thumbnail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"download_path": {
|
||||||
|
"name": "download_path",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"saved_file_name": {
|
||||||
|
"name": "saved_file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"file_size": {
|
||||||
|
"name": "file_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"name": "duration",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"downloaded_at": {
|
||||||
|
"name": "downloaded_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"name": "completed_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"sort_key": {
|
||||||
|
"name": "sort_key",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"name": "error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"name": "channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"uploader": {
|
||||||
|
"name": "uploader",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"view_count": {
|
||||||
|
"name": "view_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"name": "origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subscription_id": {
|
||||||
|
"name": "subscription_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_format": {
|
||||||
|
"name": "selected_format",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"playlist_id": {
|
||||||
|
"name": "playlist_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"playlist_title": {
|
||||||
|
"name": "playlist_title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"playlist_index": {
|
||||||
|
"name": "playlist_index",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"playlist_size": {
|
||||||
|
"name": "playlist_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"subscription_items": {
|
||||||
|
"name": "subscription_items",
|
||||||
|
"columns": {
|
||||||
|
"subscription_id": {
|
||||||
|
"name": "subscription_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"published_at": {
|
||||||
|
"name": "published_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"thumbnail": {
|
||||||
|
"name": "thumbnail",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"added": {
|
||||||
|
"name": "added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"download_id": {
|
||||||
|
"name": "download_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"subscription_items_subscription_idx": {
|
||||||
|
"name": "subscription_items_subscription_idx",
|
||||||
|
"columns": ["subscription_id"],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"subscription_items_pk": {
|
||||||
|
"columns": ["subscription_id", "item_id"],
|
||||||
|
"name": "subscription_items_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"subscriptions": {
|
||||||
|
"name": "subscriptions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"source_url": {
|
||||||
|
"name": "source_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"feed_url": {
|
||||||
|
"name": "feed_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"platform": {
|
||||||
|
"name": "platform",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"keywords": {
|
||||||
|
"name": "keywords",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"only_latest": {
|
||||||
|
"name": "only_latest",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"cover_url": {
|
||||||
|
"name": "cover_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"latest_video_title": {
|
||||||
|
"name": "latest_video_title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"latest_video_published_at": {
|
||||||
|
"name": "latest_video_published_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_checked_at": {
|
||||||
|
"name": "last_checked_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_success_at": {
|
||||||
|
"name": "last_success_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_error": {
|
||||||
|
"name": "last_error",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"download_directory": {
|
||||||
|
"name": "download_directory",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"naming_template": {
|
||||||
|
"name": "naming_template",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
resources/drizzle/meta/_journal.json
Normal file
13
resources/drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1763176841336,
|
||||||
|
"tag": "0000_swift_aaron_stack",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||||
|
|
||||||
|
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||||
|
const trimmed = template.trim()
|
||||||
|
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
||||||
|
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
||||||
|
}
|
||||||
|
|
||||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||||
const format = options.format
|
const format = options.format
|
||||||
const audioFormat = options.audioFormat
|
const audioFormat = options.audioFormat
|
||||||
@@ -80,7 +86,12 @@ export const buildDownloadArgs = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Output path with proper encoding handling
|
// Output path with proper encoding handling
|
||||||
const outputTemplate = path.join(downloadPath, '%(title)s via VidBee.%(ext)s')
|
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||||
|
const filenameTemplate = sanitizeFilenameTemplate(
|
||||||
|
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
|
||||||
|
)
|
||||||
|
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
|
||||||
|
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||||
args.push('-o', outputTemplate)
|
args.push('-o', outputTemplate)
|
||||||
|
|
||||||
// Add options for better filename handling
|
// Add options for better filename handling
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { configureLogger } from './config/logger-config'
|
|||||||
import { services } from './ipc'
|
import { services } from './ipc'
|
||||||
import { downloadEngine } from './lib/download-engine'
|
import { downloadEngine } from './lib/download-engine'
|
||||||
import { ffmpegManager } from './lib/ffmpeg-manager'
|
import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||||
|
import { subscriptionManager } from './lib/subscription-manager'
|
||||||
|
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||||
import { settingsManager } from './settings'
|
import { settingsManager } from './settings'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
@@ -22,6 +24,10 @@ configureLogger()
|
|||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let isQuitting = false
|
let isQuitting = false
|
||||||
|
|
||||||
|
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||||
|
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||||
|
})
|
||||||
|
|
||||||
export function createWindow(): void {
|
export function createWindow(): void {
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
const isWindows = process.platform === 'win32'
|
const isWindows = process.platform === 'win32'
|
||||||
@@ -80,6 +86,10 @@ export function createWindow(): void {
|
|||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||||
|
})
|
||||||
|
|
||||||
// Setup download engine event forwarding to renderer
|
// Setup download engine event forwarding to renderer
|
||||||
setupDownloadEvents()
|
setupDownloadEvents()
|
||||||
}
|
}
|
||||||
@@ -214,6 +224,8 @@ app.whenReady().then(async () => {
|
|||||||
// Create system tray
|
// Create system tray
|
||||||
createTray()
|
createTray()
|
||||||
|
|
||||||
|
subscriptionScheduler.start()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
// On macOS it's common to re-create a window in the app when the
|
// On macOS it's common to re-create a window in the app when the
|
||||||
// dock icon is clicked and there are no other windows open.
|
// dock icon is clicked and there are no other windows open.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { DownloadService } from './services/download-service'
|
|||||||
import { FileSystemService } from './services/file-system-service'
|
import { FileSystemService } from './services/file-system-service'
|
||||||
import { HistoryService } from './services/history-service'
|
import { HistoryService } from './services/history-service'
|
||||||
import { SettingsService } from './services/settings-service'
|
import { SettingsService } from './services/settings-service'
|
||||||
|
import { SubscriptionService } from './services/subscription-service'
|
||||||
import { ThumbnailService } from './services/thumbnail-service'
|
import { ThumbnailService } from './services/thumbnail-service'
|
||||||
import { UpdateService } from './services/update-service'
|
import { UpdateService } from './services/update-service'
|
||||||
import { WindowService } from './services/window-service'
|
import { WindowService } from './services/window-service'
|
||||||
@@ -15,6 +16,7 @@ export const services = createServices([
|
|||||||
FileSystemService,
|
FileSystemService,
|
||||||
HistoryService,
|
HistoryService,
|
||||||
SettingsService,
|
SettingsService,
|
||||||
|
SubscriptionService,
|
||||||
ThumbnailService,
|
ThumbnailService,
|
||||||
UpdateService,
|
UpdateService,
|
||||||
WindowService
|
WindowService
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
import type { AppSettings } from '../../../shared/types'
|
import type { AppSettings } from '../../../shared/types'
|
||||||
|
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||||
|
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||||
import { settingsManager } from '../../settings'
|
import { settingsManager } from '../../settings'
|
||||||
import { updateTrayMenu } from '../../tray'
|
import { updateTrayMenu } from '../../tray'
|
||||||
import { applyDockVisibility } from '../../utils/dock'
|
import { applyDockVisibility } from '../../utils/dock'
|
||||||
@@ -9,12 +11,20 @@ class SettingsService extends IpcService {
|
|||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||||
return settingsManager.get(key)
|
const value = settingsManager.get(key)
|
||||||
|
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||||
|
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
||||||
|
}
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||||
settingsManager.set(key, value)
|
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||||
|
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
||||||
|
} else {
|
||||||
|
settingsManager.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'language') {
|
if (key === 'language') {
|
||||||
updateTrayMenu()
|
updateTrayMenu()
|
||||||
@@ -23,15 +33,30 @@ class SettingsService extends IpcService {
|
|||||||
if (key === 'hideDockIcon') {
|
if (key === 'hideDockIcon') {
|
||||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'subscriptionCheckIntervalHours') {
|
||||||
|
subscriptionScheduler.refreshInterval()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
getAll(_context: IpcContext): AppSettings {
|
getAll(_context: IpcContext): AppSettings {
|
||||||
return settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
|
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||||
|
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||||
|
settings.subscriptionFilenameTemplate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||||
|
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||||
|
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||||
|
settings.subscriptionFilenameTemplate
|
||||||
|
)
|
||||||
|
}
|
||||||
settingsManager.setAll(settings)
|
settingsManager.setAll(settings)
|
||||||
|
|
||||||
if (settings.language) {
|
if (settings.language) {
|
||||||
@@ -41,12 +66,17 @@ class SettingsService extends IpcService {
|
|||||||
if (typeof settings.hideDockIcon === 'boolean') {
|
if (typeof settings.hideDockIcon === 'boolean') {
|
||||||
applyDockVisibility(settings.hideDockIcon)
|
applyDockVisibility(settings.hideDockIcon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||||
|
subscriptionScheduler.refreshInterval()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@IpcMethod()
|
@IpcMethod()
|
||||||
reset(_context: IpcContext): void {
|
reset(_context: IpcContext): void {
|
||||||
settingsManager.reset()
|
settingsManager.reset()
|
||||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||||
|
subscriptionScheduler.refreshInterval()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
164
src/main/ipc/services/subscription-service.ts
Normal file
164
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import type {
|
||||||
|
SubscriptionCreatePayload,
|
||||||
|
SubscriptionResolvedFeed,
|
||||||
|
SubscriptionRule,
|
||||||
|
SubscriptionUpdatePayload
|
||||||
|
} from '../../../shared/types'
|
||||||
|
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||||
|
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||||
|
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||||
|
import { settingsManager } from '../../settings'
|
||||||
|
|
||||||
|
interface CreateSubscriptionOptions {
|
||||||
|
url: string
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
onlyDownloadLatest?: boolean
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureUrlHasProtocol = (value: string): string => {
|
||||||
|
if (!value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(value)) {
|
||||||
|
return `https://${value}`
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
|
||||||
|
const normalized = ensureUrlHasProtocol(rawUrl.trim())
|
||||||
|
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
|
||||||
|
if (youTubeChannelMatch) {
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
|
||||||
|
platform: 'youtube'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: normalized,
|
||||||
|
platform: 'youtube'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
|
||||||
|
if (youTubeUserMatch) {
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
|
||||||
|
platform: 'youtube'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
|
||||||
|
if (youTubeHandleMatch) {
|
||||||
|
const handle = youTubeHandleMatch[1].replace('@', '')
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
|
||||||
|
platform: 'youtube'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
|
||||||
|
if (biliSpaceMatch) {
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
|
||||||
|
platform: 'bilibili'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/rsshub\.app\/bilibili/i.test(normalized)) {
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: normalized,
|
||||||
|
platform: 'bilibili'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceUrl: normalized,
|
||||||
|
feedUrl: normalized,
|
||||||
|
platform: 'custom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionService extends IpcService {
|
||||||
|
static readonly groupName = 'subscriptions'
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
list(_context: IpcContext): SubscriptionRule[] {
|
||||||
|
return subscriptionManager.getAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
|
||||||
|
return resolveFeedFromInput(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
async create(
|
||||||
|
_context: IpcContext,
|
||||||
|
options: CreateSubscriptionOptions
|
||||||
|
): Promise<SubscriptionRule> {
|
||||||
|
const resolved = resolveFeedFromInput(options.url)
|
||||||
|
const settings = settingsManager.getAll()
|
||||||
|
const payload: SubscriptionCreatePayload = {
|
||||||
|
sourceUrl: resolved.sourceUrl,
|
||||||
|
feedUrl: resolved.feedUrl,
|
||||||
|
platform: resolved.platform,
|
||||||
|
keywords: options.keywords,
|
||||||
|
tags: options.tags,
|
||||||
|
onlyDownloadLatest:
|
||||||
|
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||||
|
downloadDirectory: options.downloadDirectory || settings.downloadPath,
|
||||||
|
namingTemplate: sanitizeFilenameTemplate(
|
||||||
|
options.namingTemplate || settings.subscriptionFilenameTemplate
|
||||||
|
),
|
||||||
|
enabled: options.enabled ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = subscriptionManager.add(payload)
|
||||||
|
void subscriptionScheduler.runNow(created.id)
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
update(
|
||||||
|
_context: IpcContext,
|
||||||
|
id: string,
|
||||||
|
updates: SubscriptionUpdatePayload
|
||||||
|
): SubscriptionRule | undefined {
|
||||||
|
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||||
|
if (typeof normalized.namingTemplate === 'string') {
|
||||||
|
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||||
|
}
|
||||||
|
return subscriptionManager.update(id, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
remove(_context: IpcContext, id: string): boolean {
|
||||||
|
return subscriptionManager.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
async refresh(_context: IpcContext, id?: string): Promise<void> {
|
||||||
|
await subscriptionScheduler.runNow(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@IpcMethod()
|
||||||
|
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
|
||||||
|
return subscriptionScheduler.queueItem(id, itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SubscriptionService }
|
||||||
6
src/main/lib/database-path.ts
Normal file
6
src/main/lib/database-path.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { join } from 'node:path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export const getDatabaseFilePath = (): string => {
|
||||||
|
return join(app.getPath('userData'), 'vidbee.db')
|
||||||
|
}
|
||||||
97
src/main/lib/database/migrate.ts
Normal file
97
src/main/lib/database/migrate.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||||
|
import { type MigrationMeta, readMigrationFiles } from 'drizzle-orm/migrator'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import log from 'electron-log/main'
|
||||||
|
|
||||||
|
const MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'
|
||||||
|
const MIGRATIONS_TABLE = '__drizzle_migrations'
|
||||||
|
const KNOWN_TABLES = ['download_history', 'subscriptions', 'subscription_items']
|
||||||
|
|
||||||
|
const migrationsTableDefinition = sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS ${sql.identifier(MIGRATIONS_TABLE)} (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
hash text NOT NULL,
|
||||||
|
created_at numeric
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
export const runMigrations = (database: BetterSQLite3Database): void => {
|
||||||
|
const migrationsFolder = resolveMigrationsFolder()
|
||||||
|
if (!migrationsFolder) {
|
||||||
|
log.warn('database: drizzle migrations folder not found, skipping migrations')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureBaseline(database, migrationsFolder)
|
||||||
|
migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||||
|
} catch (error) {
|
||||||
|
log.error('database: failed to run drizzle migrations', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveMigrationsFolder = (): string | null => {
|
||||||
|
const candidates = new Set<string>()
|
||||||
|
candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))
|
||||||
|
candidates.add(resolve(__dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))
|
||||||
|
|
||||||
|
if (process.resourcesPath) {
|
||||||
|
candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))
|
||||||
|
candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))
|
||||||
|
} catch {
|
||||||
|
// app might not be ready yet, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureBaseline = (database: BetterSQLite3Database, migrationsFolder: string): void => {
|
||||||
|
if (hasTable(database, MIGRATIONS_TABLE)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExistingSchema = KNOWN_TABLES.some((table) => hasTable(database, table))
|
||||||
|
if (!hasExistingSchema) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('database: detected existing schema, seeding drizzle migrations baseline')
|
||||||
|
database.run(migrationsTableDefinition)
|
||||||
|
|
||||||
|
let migrations: MigrationMeta[]
|
||||||
|
try {
|
||||||
|
migrations = readMigrationFiles({ migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||||
|
} catch (error) {
|
||||||
|
log.error('database: failed to read drizzle migrations while seeding baseline', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database.transaction((tx) => {
|
||||||
|
for (const migration of migrations) {
|
||||||
|
tx.run(
|
||||||
|
sql`INSERT INTO ${sql.identifier(MIGRATIONS_TABLE)} ("hash", "created_at") VALUES(${migration.hash}, ${migration.folderMillis})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTable = (database: BetterSQLite3Database, tableName: string): boolean => {
|
||||||
|
const rows = database.all<{ name: string }>(
|
||||||
|
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}`
|
||||||
|
)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
83
src/main/lib/database/schema.ts
Normal file
83
src/main/lib/database/schema.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const downloadHistoryTable = sqliteTable('download_history', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
thumbnail: text('thumbnail'),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
downloadPath: text('download_path'),
|
||||||
|
savedFileName: text('saved_file_name'),
|
||||||
|
fileSize: integer('file_size', { mode: 'number' }),
|
||||||
|
duration: integer('duration', { mode: 'number' }),
|
||||||
|
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||||
|
completedAt: integer('completed_at', { mode: 'number' }),
|
||||||
|
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||||
|
error: text('error'),
|
||||||
|
description: text('description'),
|
||||||
|
channel: text('channel'),
|
||||||
|
uploader: text('uploader'),
|
||||||
|
viewCount: integer('view_count', { mode: 'number' }),
|
||||||
|
tags: text('tags'),
|
||||||
|
origin: text('origin'),
|
||||||
|
subscriptionId: text('subscription_id'),
|
||||||
|
selectedFormat: text('selected_format'),
|
||||||
|
playlistId: text('playlist_id'),
|
||||||
|
playlistTitle: text('playlist_title'),
|
||||||
|
playlistIndex: integer('playlist_index', { mode: 'number' }),
|
||||||
|
playlistSize: integer('playlist_size', { mode: 'number' })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const subscriptionsTable = sqliteTable('subscriptions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
sourceUrl: text('source_url').notNull(),
|
||||||
|
feedUrl: text('feed_url').notNull(),
|
||||||
|
platform: text('platform').notNull(),
|
||||||
|
keywords: text('keywords').notNull(),
|
||||||
|
tags: text('tags').notNull(),
|
||||||
|
onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),
|
||||||
|
enabled: integer('enabled', { mode: 'number' }).notNull(),
|
||||||
|
coverUrl: text('cover_url'),
|
||||||
|
latestVideoTitle: text('latest_video_title'),
|
||||||
|
latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),
|
||||||
|
lastCheckedAt: integer('last_checked_at', { mode: 'number' }),
|
||||||
|
lastSuccessAt: integer('last_success_at', { mode: 'number' }),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
lastError: text('last_error'),
|
||||||
|
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||||
|
downloadDirectory: text('download_directory'),
|
||||||
|
namingTemplate: text('naming_template')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const subscriptionItemsTable = sqliteTable(
|
||||||
|
'subscription_items',
|
||||||
|
{
|
||||||
|
subscriptionId: text('subscription_id').notNull(),
|
||||||
|
itemId: text('item_id').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
publishedAt: integer('published_at', { mode: 'number' }).notNull(),
|
||||||
|
thumbnail: text('thumbnail'),
|
||||||
|
added: integer('added', { mode: 'number' }).notNull(),
|
||||||
|
downloadId: text('download_id'),
|
||||||
|
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'number' }).notNull()
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
pk: primaryKey({
|
||||||
|
columns: [table.subscriptionId, table.itemId],
|
||||||
|
name: 'subscription_items_pk'
|
||||||
|
}),
|
||||||
|
subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
|
||||||
|
export type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
|
||||||
|
export type SubscriptionRow = typeof subscriptionsTable.$inferSelect
|
||||||
|
export type SubscriptionInsert = typeof subscriptionsTable.$inferInsert
|
||||||
|
export type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect
|
||||||
|
export type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert
|
||||||
@@ -395,6 +395,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
const createdAt = Date.now()
|
const createdAt = Date.now()
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
|
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||||
|
const origin = options.origin ?? 'manual'
|
||||||
|
|
||||||
const item: DownloadItem = {
|
const item: DownloadItem = {
|
||||||
id,
|
id,
|
||||||
@@ -402,7 +404,10 @@ class DownloadEngine extends EventEmitter {
|
|||||||
title: 'Downloading...',
|
title: 'Downloading...',
|
||||||
type: options.type,
|
type: options.type,
|
||||||
status: 'pending' as const,
|
status: 'pending' as const,
|
||||||
createdAt
|
createdAt,
|
||||||
|
tags: options.tags,
|
||||||
|
origin,
|
||||||
|
subscriptionId: options.subscriptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queue.add(id, options, item)
|
this.queue.add(id, options, item)
|
||||||
@@ -411,7 +416,10 @@ class DownloadEngine extends EventEmitter {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
downloadedAt: createdAt,
|
downloadedAt: createdAt,
|
||||||
downloadPath: settings.downloadPath
|
downloadPath: targetDownloadPath,
|
||||||
|
tags: options.tags,
|
||||||
|
origin,
|
||||||
|
subscriptionId: options.subscriptionId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +427,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||||
const ytdlp = ytdlpManager.getInstance()
|
const ytdlp = ytdlpManager.getInstance()
|
||||||
const settings = settingsManager.getAll()
|
const settings = settingsManager.getAll()
|
||||||
const downloadPath = settings.downloadPath
|
const defaultDownloadPath = settings.downloadPath
|
||||||
|
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||||
|
|
||||||
// Set environment variables for proper encoding on Windows
|
// Set environment variables for proper encoding on Windows
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
@@ -430,9 +439,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
let availableFormats: VideoFormat[] = []
|
let availableFormats: VideoFormat[] = []
|
||||||
let selectedFormat: VideoFormat | undefined
|
let selectedFormat: VideoFormat | undefined
|
||||||
let actualFormat: string | null = null
|
let actualFormat: string | null = null
|
||||||
let actualQuality: string | null = null
|
|
||||||
let actualCodec: string | null = null
|
|
||||||
let videoInfo: VideoInfo | undefined
|
let videoInfo: VideoInfo | undefined
|
||||||
|
let lastKnownOutputPath: string | undefined
|
||||||
|
|
||||||
// First, get detailed video info to capture basic metadata and formats
|
// First, get detailed video info to capture basic metadata and formats
|
||||||
try {
|
try {
|
||||||
@@ -444,20 +452,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
if (selectedFormat) {
|
if (selectedFormat) {
|
||||||
actualFormat = selectedFormat.ext || actualFormat
|
actualFormat = selectedFormat.ext || actualFormat
|
||||||
|
|
||||||
if (selectedFormat.height) {
|
|
||||||
actualQuality = `${selectedFormat.height}p${
|
|
||||||
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
|
|
||||||
}`
|
|
||||||
} else if (selectedFormat.format_note) {
|
|
||||||
actualQuality = selectedFormat.format_note
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.type === 'audio' || options.type === 'extract') {
|
|
||||||
actualCodec = selectedFormat.acodec || actualCodec
|
|
||||||
} else {
|
|
||||||
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateDownloadInfo(id, {
|
this.updateDownloadInfo(id, {
|
||||||
@@ -502,18 +496,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
selectedFormat = candidate
|
selectedFormat = candidate
|
||||||
actualFormat = candidate.ext || actualFormat
|
actualFormat = candidate.ext || actualFormat
|
||||||
|
|
||||||
if (candidate.height) {
|
|
||||||
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
|
|
||||||
} else if (candidate.format_note) {
|
|
||||||
actualQuality = candidate.format_note
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.type === 'audio' || options.type === 'extract') {
|
|
||||||
actualCodec = candidate.acodec || actualCodec
|
|
||||||
} else {
|
|
||||||
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateDownloadInfo(id, {
|
this.updateDownloadInfo(id, {
|
||||||
selectedFormat: candidate
|
selectedFormat: candidate
|
||||||
})
|
})
|
||||||
@@ -521,7 +503,39 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = buildDownloadArgs(options, downloadPath, settings)
|
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
||||||
|
|
||||||
|
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||||
|
if (!rawPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trimmed = rawPath.trim().replace(/^"|"$/g, '')
|
||||||
|
if (!trimmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastKnownOutputPath = path.isAbsolute(trimmed)
|
||||||
|
? trimmed
|
||||||
|
: path.join(resolvedDownloadPath, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractOutputPathFromLog = (message: string): void => {
|
||||||
|
const destinationMatch = message.match(/Destination:\s*(.+)$/)
|
||||||
|
if (destinationMatch) {
|
||||||
|
captureOutputPath(destinationMatch[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/)
|
||||||
|
if (mergingMatch) {
|
||||||
|
captureOutputPath(mergingMatch[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const movingMatch = message.match(/Moving file to\s+"(.+?)"/)
|
||||||
|
if (movingMatch) {
|
||||||
|
captureOutputPath(movingMatch[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if format selector contains '+' which means video and audio will be merged
|
// Check if format selector contains '+' which means video and audio will be merged
|
||||||
const formatSelector =
|
const formatSelector =
|
||||||
@@ -629,16 +643,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (extMatch && !actualFormat) {
|
if (extMatch && !actualFormat) {
|
||||||
actualFormat = extMatch[1]
|
actualFormat = extMatch[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
const heightMatch = formatInfo.match(/(\d+)p/)
|
|
||||||
if (heightMatch && !actualQuality) {
|
|
||||||
actualQuality = `${heightMatch[1]}p`
|
|
||||||
}
|
|
||||||
|
|
||||||
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
|
|
||||||
if (codecMatch && !actualCodec) {
|
|
||||||
actualCodec = codecMatch[1]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,6 +653,10 @@ class DownloadEngine extends EventEmitter {
|
|||||||
applySelectedFormat(formatMatch[1])
|
applySelectedFormat(formatMatch[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (eventType === 'download' || eventType === 'info') {
|
||||||
|
extractOutputPathFromLog(eventData)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle completion
|
// Handle completion
|
||||||
@@ -675,32 +683,44 @@ class DownloadEngine extends EventEmitter {
|
|||||||
extension = actualFormat || 'mp4'
|
extension = actualFormat || 'mp4'
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${sanitizedTitle}.${extension}`
|
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||||
const finalOutputPath = path.join(downloadPath, fileName)
|
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||||
|
|
||||||
scopedLoggers.download.info(
|
scopedLoggers.download.info(
|
||||||
'Generated file path for ID:',
|
'Resolved output paths for ID:',
|
||||||
id,
|
id,
|
||||||
'Path:',
|
'Primary:',
|
||||||
finalOutputPath,
|
lastKnownOutputPath ?? fallbackOutputPath,
|
||||||
|
'Fallback:',
|
||||||
|
fallbackOutputPath,
|
||||||
'Will merge:',
|
'Will merge:',
|
||||||
willMerge
|
willMerge
|
||||||
)
|
)
|
||||||
|
|
||||||
let fileSize: number | undefined
|
let fileSize: number | undefined
|
||||||
let actualFilePath = finalOutputPath
|
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||||
|
const candidatePaths = lastKnownOutputPath
|
||||||
|
? [lastKnownOutputPath, fallbackOutputPath]
|
||||||
|
: [fallbackOutputPath]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fs = await import('node:fs/promises')
|
const fs = await import('node:fs/promises')
|
||||||
// Try to find the actual file - yt-dlp may generate files with slightly different names
|
let located = false
|
||||||
const stats = await fs.stat(finalOutputPath)
|
for (const candidate of candidatePaths) {
|
||||||
fileSize = stats.size
|
if (!candidate) {
|
||||||
actualFilePath = finalOutputPath
|
continue
|
||||||
} catch (error) {
|
}
|
||||||
// If the expected file doesn't exist, try to find it by scanning the directory
|
try {
|
||||||
try {
|
const stats = await fs.stat(candidate)
|
||||||
const fs = await import('node:fs/promises')
|
fileSize = stats.size
|
||||||
const files = await fs.readdir(downloadPath)
|
actualFilePath = candidate
|
||||||
// Look for files matching the title pattern with the correct extension
|
located = true
|
||||||
|
break
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!located) {
|
||||||
|
const files = await fs.readdir(resolvedDownloadPath)
|
||||||
const matchingFiles = files.filter((file) => {
|
const matchingFiles = files.filter((file) => {
|
||||||
const baseName = file.replace(/\.[^.]+$/, '')
|
const baseName = file.replace(/\.[^.]+$/, '')
|
||||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||||
@@ -711,30 +731,33 @@ class DownloadEngine extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (matchingFiles.length > 0) {
|
if (matchingFiles.length > 0) {
|
||||||
// Use the most recently modified file if multiple matches
|
|
||||||
const fileStats = await Promise.all(
|
const fileStats = await Promise.all(
|
||||||
matchingFiles.map(async (file) => {
|
matchingFiles.map(async (file) => {
|
||||||
const filePath = path.join(downloadPath, file)
|
const filePath = path.join(resolvedDownloadPath, file)
|
||||||
const stats = await fs.stat(filePath)
|
const stats = await fs.stat(filePath)
|
||||||
return { file, path: filePath, mtime: stats.mtime, size: stats.size }
|
return { path: filePath, mtime: stats.mtime, size: stats.size }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
||||||
actualFilePath = mostRecent.path
|
actualFilePath = mostRecent.path
|
||||||
fileSize = mostRecent.size
|
fileSize = mostRecent.size
|
||||||
|
located = true
|
||||||
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
||||||
} else if (latestKnownSizeBytes !== undefined) {
|
}
|
||||||
fileSize = latestKnownSizeBytes
|
}
|
||||||
|
|
||||||
|
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||||
|
fileSize = latestKnownSizeBytes
|
||||||
|
if (!located) {
|
||||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||||
} else {
|
|
||||||
scopedLoggers.download.warn('Failed to find file for ID:', id, error)
|
|
||||||
}
|
|
||||||
} catch (scanError) {
|
|
||||||
if (latestKnownSizeBytes !== undefined) {
|
|
||||||
fileSize = latestKnownSizeBytes
|
|
||||||
} else {
|
|
||||||
scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError)
|
|
||||||
}
|
}
|
||||||
|
} else if (!fileSize) {
|
||||||
|
scopedLoggers.download.warn('Failed to find file for ID:', id)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)
|
||||||
|
if (latestKnownSizeBytes !== undefined) {
|
||||||
|
fileSize = latestKnownSizeBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -748,9 +771,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
completedAt: Date.now(),
|
completedAt: Date.now(),
|
||||||
fileSize,
|
fileSize,
|
||||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
|
||||||
quality: actualQuality || undefined,
|
|
||||||
codec: actualCodec || undefined,
|
|
||||||
savedFileName
|
savedFileName
|
||||||
})
|
})
|
||||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||||
@@ -837,15 +857,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (updates.fileSize !== undefined) {
|
if (updates.fileSize !== undefined) {
|
||||||
historyUpdates.fileSize = updates.fileSize
|
historyUpdates.fileSize = updates.fileSize
|
||||||
}
|
}
|
||||||
if (updates.format !== undefined) {
|
|
||||||
historyUpdates.format = updates.format
|
|
||||||
}
|
|
||||||
if (updates.quality !== undefined) {
|
|
||||||
historyUpdates.quality = updates.quality
|
|
||||||
}
|
|
||||||
if (updates.codec !== undefined) {
|
|
||||||
historyUpdates.codec = updates.codec
|
|
||||||
}
|
|
||||||
if (updates.description !== undefined) {
|
if (updates.description !== undefined) {
|
||||||
historyUpdates.description = updates.description
|
historyUpdates.description = updates.description
|
||||||
}
|
}
|
||||||
@@ -873,6 +884,9 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (updates.playlistSize !== undefined) {
|
if (updates.playlistSize !== undefined) {
|
||||||
historyUpdates.playlistSize = updates.playlistSize
|
historyUpdates.playlistSize = updates.playlistSize
|
||||||
}
|
}
|
||||||
|
if (updates.selectedFormat !== undefined) {
|
||||||
|
historyUpdates.selectedFormat = updates.selectedFormat
|
||||||
|
}
|
||||||
if (updates.status !== undefined) {
|
if (updates.status !== undefined) {
|
||||||
historyUpdates.status = updates.status
|
historyUpdates.status = updates.status
|
||||||
}
|
}
|
||||||
@@ -882,9 +896,6 @@ class DownloadEngine extends EventEmitter {
|
|||||||
if (updates.error !== undefined) {
|
if (updates.error !== undefined) {
|
||||||
historyUpdates.error = updates.error
|
historyUpdates.error = updates.error
|
||||||
}
|
}
|
||||||
if (updates.selectedFormat !== undefined) {
|
|
||||||
historyUpdates.selectedFormat = updates.selectedFormat
|
|
||||||
}
|
|
||||||
if (updates.savedFileName !== undefined) {
|
if (updates.savedFileName !== undefined) {
|
||||||
historyUpdates.savedFileName = updates.savedFileName
|
historyUpdates.savedFileName = updates.savedFileName
|
||||||
}
|
}
|
||||||
@@ -913,14 +924,13 @@ class DownloadEngine extends EventEmitter {
|
|||||||
error,
|
error,
|
||||||
duration: completedDownload?.item.duration,
|
duration: completedDownload?.item.duration,
|
||||||
fileSize: completedDownload?.item.fileSize,
|
fileSize: completedDownload?.item.fileSize,
|
||||||
format: completedDownload?.item.format,
|
|
||||||
quality: completedDownload?.item.quality,
|
|
||||||
codec: completedDownload?.item.codec,
|
|
||||||
description: completedDownload?.item.description,
|
description: completedDownload?.item.description,
|
||||||
channel: completedDownload?.item.channel,
|
channel: completedDownload?.item.channel,
|
||||||
uploader: completedDownload?.item.uploader,
|
uploader: completedDownload?.item.uploader,
|
||||||
viewCount: completedDownload?.item.viewCount,
|
viewCount: completedDownload?.item.viewCount,
|
||||||
tags: completedDownload?.item.tags,
|
tags: completedDownload?.item.tags,
|
||||||
|
origin: completedDownload?.item.origin,
|
||||||
|
subscriptionId: completedDownload?.item.subscriptionId,
|
||||||
playlistId: completedDownload?.item.playlistId,
|
playlistId: completedDownload?.item.playlistId,
|
||||||
playlistTitle: completedDownload?.item.playlistTitle,
|
playlistTitle: completedDownload?.item.playlistTitle,
|
||||||
playlistIndex: completedDownload?.item.playlistIndex,
|
playlistIndex: completedDownload?.item.playlistIndex,
|
||||||
@@ -934,6 +944,8 @@ class DownloadEngine extends EventEmitter {
|
|||||||
updates: Partial<DownloadHistoryItem>
|
updates: Partial<DownloadHistoryItem>
|
||||||
): void {
|
): void {
|
||||||
const existing = historyManager.getHistoryById(id)
|
const existing = historyManager.getHistoryById(id)
|
||||||
|
const resolvedDownloadPath =
|
||||||
|
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||||
const base: DownloadHistoryItem = existing ?? {
|
const base: DownloadHistoryItem = existing ?? {
|
||||||
id,
|
id,
|
||||||
url: options.url,
|
url: options.url,
|
||||||
@@ -941,21 +953,20 @@ class DownloadEngine extends EventEmitter {
|
|||||||
thumbnail: updates.thumbnail,
|
thumbnail: updates.thumbnail,
|
||||||
type: options.type,
|
type: options.type,
|
||||||
status: updates.status || 'pending',
|
status: updates.status || 'pending',
|
||||||
downloadPath: updates.downloadPath,
|
downloadPath: resolvedDownloadPath,
|
||||||
savedFileName: updates.savedFileName,
|
savedFileName: updates.savedFileName,
|
||||||
fileSize: updates.fileSize,
|
fileSize: updates.fileSize,
|
||||||
duration: updates.duration,
|
duration: updates.duration,
|
||||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||||
completedAt: updates.completedAt,
|
completedAt: updates.completedAt,
|
||||||
error: updates.error,
|
error: updates.error,
|
||||||
format: updates.format,
|
|
||||||
quality: updates.quality,
|
|
||||||
codec: updates.codec,
|
|
||||||
description: updates.description,
|
description: updates.description,
|
||||||
channel: updates.channel,
|
channel: updates.channel,
|
||||||
uploader: updates.uploader,
|
uploader: updates.uploader,
|
||||||
viewCount: updates.viewCount,
|
viewCount: updates.viewCount,
|
||||||
tags: updates.tags,
|
tags: updates.tags ?? options.tags,
|
||||||
|
origin: updates.origin ?? options.origin,
|
||||||
|
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
|
||||||
// Download-specific format info
|
// Download-specific format info
|
||||||
selectedFormat: updates.selectedFormat,
|
selectedFormat: updates.selectedFormat,
|
||||||
playlistId: updates.playlistId,
|
playlistId: updates.playlistId,
|
||||||
@@ -972,7 +983,11 @@ class DownloadEngine extends EventEmitter {
|
|||||||
type: updates.type ?? base.type,
|
type: updates.type ?? base.type,
|
||||||
title: updates.title ?? base.title,
|
title: updates.title ?? base.title,
|
||||||
status: updates.status ?? base.status,
|
status: updates.status ?? base.status,
|
||||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt
|
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
|
||||||
|
downloadPath: resolvedDownloadPath ?? base.downloadPath,
|
||||||
|
tags: updates.tags ?? base.tags,
|
||||||
|
origin: updates.origin ?? base.origin,
|
||||||
|
subscriptionId: updates.subscriptionId ?? base.subscriptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
historyManager.addHistoryItem(merged)
|
historyManager.addHistoryItem(merged)
|
||||||
|
|||||||
@@ -1,18 +1,149 @@
|
|||||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
|
|
||||||
import DatabaseConstructor from 'better-sqlite3'
|
import DatabaseConstructor from 'better-sqlite3'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import log from 'electron-log/main'
|
import log from 'electron-log/main'
|
||||||
import type { DownloadHistoryItem } from '../../shared/types'
|
import type { DownloadHistoryItem } from '../../shared/types'
|
||||||
|
import { runMigrations } from './database/migrate'
|
||||||
|
import {
|
||||||
|
type DownloadHistoryInsert,
|
||||||
|
type DownloadHistoryRow,
|
||||||
|
downloadHistoryTable
|
||||||
|
} from './database/schema'
|
||||||
|
import { getDatabaseFilePath } from './database-path'
|
||||||
|
|
||||||
const logger = log.scope('history-manager')
|
const logger = log.scope('history-manager')
|
||||||
|
|
||||||
const downloadHistoryTable = sqliteTable('download_history', {
|
const TAG_SEPARATOR = '\n'
|
||||||
|
|
||||||
|
const createDownloadHistoryTableSql = sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS download_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
thumbnail TEXT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
download_path TEXT,
|
||||||
|
saved_file_name TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
downloaded_at INTEGER NOT NULL,
|
||||||
|
completed_at INTEGER,
|
||||||
|
sort_key INTEGER NOT NULL,
|
||||||
|
error TEXT,
|
||||||
|
description TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
uploader TEXT,
|
||||||
|
view_count INTEGER,
|
||||||
|
tags TEXT,
|
||||||
|
origin TEXT,
|
||||||
|
subscription_id TEXT,
|
||||||
|
selected_format TEXT,
|
||||||
|
playlist_id TEXT,
|
||||||
|
playlist_title TEXT,
|
||||||
|
playlist_index INTEGER,
|
||||||
|
playlist_size INTEGER
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
const renameDownloadHistoryTableSql = sql`
|
||||||
|
ALTER TABLE download_history RENAME TO download_history_legacy
|
||||||
|
`
|
||||||
|
|
||||||
|
const dropLegacyDownloadHistoryTableSql = sql`
|
||||||
|
DROP TABLE download_history_legacy
|
||||||
|
`
|
||||||
|
|
||||||
|
const copyDownloadHistoryFromLegacySql = sql`
|
||||||
|
INSERT INTO download_history (
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
thumbnail,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
download_path,
|
||||||
|
saved_file_name,
|
||||||
|
file_size,
|
||||||
|
duration,
|
||||||
|
downloaded_at,
|
||||||
|
completed_at,
|
||||||
|
sort_key,
|
||||||
|
error,
|
||||||
|
description,
|
||||||
|
channel,
|
||||||
|
uploader,
|
||||||
|
view_count,
|
||||||
|
tags,
|
||||||
|
origin,
|
||||||
|
subscription_id,
|
||||||
|
selected_format,
|
||||||
|
playlist_id,
|
||||||
|
playlist_title,
|
||||||
|
playlist_index,
|
||||||
|
playlist_size
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
thumbnail,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
download_path,
|
||||||
|
saved_file_name,
|
||||||
|
file_size,
|
||||||
|
duration,
|
||||||
|
downloaded_at,
|
||||||
|
completed_at,
|
||||||
|
sort_key,
|
||||||
|
error,
|
||||||
|
description,
|
||||||
|
channel,
|
||||||
|
uploader,
|
||||||
|
view_count,
|
||||||
|
tags,
|
||||||
|
origin,
|
||||||
|
subscription_id,
|
||||||
|
selected_format,
|
||||||
|
playlist_id,
|
||||||
|
playlist_title,
|
||||||
|
playlist_index,
|
||||||
|
playlist_size
|
||||||
|
FROM download_history_legacy
|
||||||
|
`
|
||||||
|
|
||||||
|
const sanitizeList = (values?: string[]): string[] => {
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const serializeTags = (values?: string[]): string | null => {
|
||||||
|
const sanitized = sanitizeList(values)
|
||||||
|
return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTags = (value: string | null): string[] | undefined => {
|
||||||
|
if (!value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const parsed = value
|
||||||
|
.split(TAG_SEPARATOR)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index)
|
||||||
|
return parsed.length > 0 ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
status: text('status').notNull(),
|
status: text('status').notNull(),
|
||||||
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||||
@@ -20,14 +151,12 @@ const downloadHistoryTable = sqliteTable('download_history', {
|
|||||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||||
payload: text('payload').notNull()
|
payload: text('payload').notNull()
|
||||||
})
|
})
|
||||||
|
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
|
||||||
type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
|
|
||||||
type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
|
|
||||||
|
|
||||||
class HistoryManager {
|
class HistoryManager {
|
||||||
private sqlite: BetterSqlite3Instance | null = null
|
|
||||||
private db: BetterSQLite3Database | null = null
|
private db: BetterSQLite3Database | null = null
|
||||||
private history: Map<string, DownloadHistoryItem> = new Map()
|
private history: Map<string, DownloadHistoryItem> = new Map()
|
||||||
|
private schemaChecked = false
|
||||||
private migrationChecked = false
|
private migrationChecked = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -37,6 +166,7 @@ class HistoryManager {
|
|||||||
private initialize(): void {
|
private initialize(): void {
|
||||||
try {
|
try {
|
||||||
this.getDatabase()
|
this.getDatabase()
|
||||||
|
this.ensureStructuredSchema()
|
||||||
this.ensureLegacyMigration()
|
this.ensureLegacyMigration()
|
||||||
this.loadHistoryFromDatabase()
|
this.loadHistoryFromDatabase()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -51,35 +181,125 @@ class HistoryManager {
|
|||||||
|
|
||||||
const databasePath = this.getDatabasePath()
|
const databasePath = this.getDatabasePath()
|
||||||
|
|
||||||
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||||
this.sqlite.pragma('journal_mode = WAL')
|
sqlite.pragma('journal_mode = WAL')
|
||||||
this.sqlite.pragma('foreign_keys = ON')
|
sqlite.pragma('foreign_keys = ON')
|
||||||
this.sqlite
|
|
||||||
.prepare(
|
|
||||||
`CREATE TABLE IF NOT EXISTS download_history (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
downloaded_at INTEGER NOT NULL,
|
|
||||||
completed_at INTEGER,
|
|
||||||
sort_key INTEGER NOT NULL,
|
|
||||||
payload TEXT NOT NULL
|
|
||||||
)`
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
|
|
||||||
this.db = drizzle(this.sqlite)
|
const database = drizzle(sqlite)
|
||||||
|
runMigrations(database)
|
||||||
|
|
||||||
|
this.db = database
|
||||||
logger.info(`history-db initialized at ${databasePath}`)
|
logger.info(`history-db initialized at ${databasePath}`)
|
||||||
return this.db
|
return this.db
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDatabasePath(): string {
|
private getDatabasePath(): string {
|
||||||
return join(app.getPath('userData'), 'download-history.sqlite')
|
return getDatabaseFilePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLegacyStorePath(): string {
|
private getLegacyStorePath(): string {
|
||||||
return join(app.getPath('userData'), 'download-history.json')
|
return join(app.getPath('userData'), 'download-history.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ensureStructuredSchema(): void {
|
||||||
|
if (this.schemaChecked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.schemaChecked = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`)
|
||||||
|
const hasPayloadColumn = columns.some((column) => column.name === 'payload')
|
||||||
|
const hasUrlColumn = columns.some((column) => column.name === 'url')
|
||||||
|
if (hasPayloadColumn || !hasUrlColumn) {
|
||||||
|
this.migrateLegacyPayloadTable()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||||
|
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||||
|
if (needsRebuild) {
|
||||||
|
this.rebuildDownloadHistoryTable()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to inspect schema', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateLegacyPayloadTable(): void {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
logger.info('history-db migrating legacy payload schema to structured columns')
|
||||||
|
|
||||||
|
try {
|
||||||
|
let migratedCount = 0
|
||||||
|
database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
tx.run(renameDownloadHistoryTableSql)
|
||||||
|
tx.run(createDownloadHistoryTableSql)
|
||||||
|
|
||||||
|
const legacyRows = tx.select().from(legacyDownloadHistoryTable).all()
|
||||||
|
migratedCount = legacyRows.length
|
||||||
|
for (const legacyRow of legacyRows) {
|
||||||
|
const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow))
|
||||||
|
tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||||
|
},
|
||||||
|
{ behavior: 'immediate' }
|
||||||
|
)
|
||||||
|
logger.info(`history-db migrated ${migratedCount} rows to new schema`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to migrate legacy payload rows', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rebuildDownloadHistoryTable(): void {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
logger.info('history-db rebuilding download_history table to latest schema')
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.transaction(
|
||||||
|
(tx) => {
|
||||||
|
tx.run(renameDownloadHistoryTableSql)
|
||||||
|
tx.run(createDownloadHistoryTableSql)
|
||||||
|
tx.run(copyDownloadHistoryFromLegacySql)
|
||||||
|
tx.run(dropLegacyDownloadHistoryTableSql)
|
||||||
|
},
|
||||||
|
{ behavior: 'immediate' }
|
||||||
|
)
|
||||||
|
logger.info('history-db rebuilt download_history table')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('history-db failed to rebuild download_history schema', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
status: (parsed.status ?? row.status) as DownloadHistoryItem['status'],
|
||||||
|
downloadedAt: parsed.downloadedAt ?? row.downloadedAt,
|
||||||
|
completedAt: parsed.completedAt ?? row.completedAt ?? undefined
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('history-db falling back while migrating payload row', { id: row.id, error })
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
url: row.id,
|
||||||
|
title: `Download ${row.id}`,
|
||||||
|
type: 'video',
|
||||||
|
status: row.status as DownloadHistoryItem['status'],
|
||||||
|
downloadedAt: row.downloadedAt,
|
||||||
|
completedAt: row.completedAt ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ensureLegacyMigration(): void {
|
private ensureLegacyMigration(): void {
|
||||||
if (this.migrationChecked) {
|
if (this.migrationChecked) {
|
||||||
return
|
return
|
||||||
@@ -154,11 +374,31 @@ class HistoryManager {
|
|||||||
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
|
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
thumbnail: item.thumbnail ?? null,
|
||||||
|
type: item.type,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
|
downloadPath: item.downloadPath ?? null,
|
||||||
|
savedFileName: item.savedFileName ?? null,
|
||||||
|
fileSize: item.fileSize ?? null,
|
||||||
|
duration: item.duration ?? null,
|
||||||
downloadedAt: item.downloadedAt,
|
downloadedAt: item.downloadedAt,
|
||||||
completedAt: item.completedAt ?? null,
|
completedAt: item.completedAt ?? null,
|
||||||
sortKey: item.completedAt ?? item.downloadedAt,
|
sortKey: item.completedAt ?? item.downloadedAt,
|
||||||
payload: JSON.stringify(item)
|
error: item.error ?? null,
|
||||||
|
description: item.description ?? null,
|
||||||
|
channel: item.channel ?? null,
|
||||||
|
uploader: item.uploader ?? null,
|
||||||
|
viewCount: item.viewCount ?? null,
|
||||||
|
tags: serializeTags(item.tags) ?? null,
|
||||||
|
origin: item.origin ?? null,
|
||||||
|
subscriptionId: item.subscriptionId ?? null,
|
||||||
|
selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null,
|
||||||
|
playlistId: item.playlistId ?? null,
|
||||||
|
playlistTitle: item.playlistTitle ?? null,
|
||||||
|
playlistIndex: item.playlistIndex ?? null,
|
||||||
|
playlistSize: item.playlistSize ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,26 +408,44 @@ class HistoryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
|
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
|
||||||
try {
|
let selectedFormat: DownloadHistoryItem['selectedFormat']
|
||||||
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
|
if (row.selectedFormat) {
|
||||||
return {
|
try {
|
||||||
...parsed,
|
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
|
||||||
status: row.status as DownloadHistoryItem['status'],
|
} catch (error) {
|
||||||
downloadedAt: row.downloadedAt,
|
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
|
||||||
completedAt: row.completedAt ?? undefined
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('history-db failed to parse stored payload', { id: row.id, error })
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
url: '',
|
|
||||||
title: 'Unknown download',
|
|
||||||
type: 'video',
|
|
||||||
status: row.status as DownloadHistoryItem['status'],
|
|
||||||
downloadedAt: row.downloadedAt,
|
|
||||||
completedAt: row.completedAt ?? undefined
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = parseTags(row.tags ?? null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
url: row.url,
|
||||||
|
title: row.title,
|
||||||
|
thumbnail: row.thumbnail ?? undefined,
|
||||||
|
type: row.type as DownloadHistoryItem['type'],
|
||||||
|
status: row.status as DownloadHistoryItem['status'],
|
||||||
|
downloadPath: row.downloadPath ?? undefined,
|
||||||
|
savedFileName: row.savedFileName ?? undefined,
|
||||||
|
fileSize: row.fileSize ?? undefined,
|
||||||
|
duration: row.duration ?? undefined,
|
||||||
|
downloadedAt: row.downloadedAt,
|
||||||
|
completedAt: row.completedAt ?? undefined,
|
||||||
|
error: row.error ?? undefined,
|
||||||
|
description: row.description ?? undefined,
|
||||||
|
channel: row.channel ?? undefined,
|
||||||
|
uploader: row.uploader ?? undefined,
|
||||||
|
viewCount: row.viewCount ?? undefined,
|
||||||
|
tags,
|
||||||
|
origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined,
|
||||||
|
subscriptionId: row.subscriptionId ?? undefined,
|
||||||
|
selectedFormat,
|
||||||
|
playlistId: row.playlistId ?? undefined,
|
||||||
|
playlistTitle: row.playlistTitle ?? undefined,
|
||||||
|
playlistIndex: row.playlistIndex ?? undefined,
|
||||||
|
playlistSize: row.playlistSize ?? undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addHistoryItem(item: DownloadHistoryItem): void {
|
addHistoryItem(item: DownloadHistoryItem): void {
|
||||||
@@ -299,6 +557,15 @@ class HistoryManager {
|
|||||||
|
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasHistoryForUrl(url: string): boolean {
|
||||||
|
for (const item of this.history.values()) {
|
||||||
|
if (item.url === url) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const historyManager = new HistoryManager()
|
export const historyManager = new HistoryManager()
|
||||||
|
|||||||
641
src/main/lib/subscription-manager.ts
Normal file
641
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
|
||||||
|
import DatabaseConstructor from 'better-sqlite3'
|
||||||
|
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||||
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import log from 'electron-log/main'
|
||||||
|
import type {
|
||||||
|
SubscriptionCreatePayload,
|
||||||
|
SubscriptionFeedItem,
|
||||||
|
SubscriptionRule,
|
||||||
|
SubscriptionStatus,
|
||||||
|
SubscriptionUpdatePayload
|
||||||
|
} from '../../shared/types'
|
||||||
|
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
|
||||||
|
import { runMigrations } from './database/migrate'
|
||||||
|
import {
|
||||||
|
type SubscriptionInsert,
|
||||||
|
type SubscriptionItemRow,
|
||||||
|
type SubscriptionRow,
|
||||||
|
subscriptionItemsTable,
|
||||||
|
subscriptionsTable
|
||||||
|
} from './database/schema'
|
||||||
|
import { getDatabaseFilePath } from './database-path'
|
||||||
|
|
||||||
|
const sanitizeList = (values?: string[]): string[] => {
|
||||||
|
if (!values || values.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value, index, array) => value.length > 0 && array.indexOf(value) === index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDirectoryExists = (dir?: string): void => {
|
||||||
|
if (!dir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Failed to ensure subscription directory:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
|
||||||
|
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
|
||||||
|
|
||||||
|
const parseStringArray = (value: string | null | undefined): string[] => {
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as unknown
|
||||||
|
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
|
||||||
|
|
||||||
|
export class SubscriptionManager extends EventEmitter {
|
||||||
|
private sqlite: BetterSqlite3Instance | null = null
|
||||||
|
private db: BetterSQLite3Database | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
try {
|
||||||
|
this.getDatabase()
|
||||||
|
this.migrateLegacyStore()
|
||||||
|
} catch (error) {
|
||||||
|
log.error('subscriptions: failed to initialize database', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(): SubscriptionRule[] {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const rows = database
|
||||||
|
.select()
|
||||||
|
.from(subscriptionsTable)
|
||||||
|
.orderBy(desc(subscriptionsTable.updatedAt))
|
||||||
|
.all()
|
||||||
|
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): SubscriptionRule | undefined {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const row = database
|
||||||
|
.select()
|
||||||
|
.from(subscriptionsTable)
|
||||||
|
.where(eq(subscriptionsTable.id, id))
|
||||||
|
.get()
|
||||||
|
if (!row) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const keywords = sanitizeList(payload.keywords)
|
||||||
|
const tags = sanitizeList(payload.tags)
|
||||||
|
const record: SubscriptionRule = {
|
||||||
|
id: randomUUID(),
|
||||||
|
title: payload.sourceUrl,
|
||||||
|
sourceUrl: payload.sourceUrl,
|
||||||
|
feedUrl: payload.feedUrl,
|
||||||
|
platform: payload.platform,
|
||||||
|
keywords,
|
||||||
|
tags,
|
||||||
|
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
|
||||||
|
enabled: payload.enabled ?? true,
|
||||||
|
coverUrl: undefined,
|
||||||
|
latestVideoTitle: undefined,
|
||||||
|
latestVideoPublishedAt: undefined,
|
||||||
|
lastCheckedAt: undefined,
|
||||||
|
lastSuccessAt: undefined,
|
||||||
|
status: 'idle',
|
||||||
|
lastError: undefined,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
downloadDirectory: payload.downloadDirectory,
|
||||||
|
namingTemplate: payload.namingTemplate
|
||||||
|
? sanitizeFilenameTemplate(payload.namingTemplate)
|
||||||
|
: undefined,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirectoryExists(payload.downloadDirectory)
|
||||||
|
this.insertRecord(record)
|
||||||
|
this.emitUpdates()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
id: string,
|
||||||
|
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
|
||||||
|
): SubscriptionRule | undefined {
|
||||||
|
const existing = this.getById(id)
|
||||||
|
if (!existing) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
|
||||||
|
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
|
||||||
|
const next: SubscriptionRule = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
keywords: keywords ?? existing.keywords,
|
||||||
|
tags: tags ?? existing.tags,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
|
||||||
|
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
|
||||||
|
next.title = updates.sourceUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.namingTemplate) {
|
||||||
|
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDirectoryExists(next.downloadDirectory)
|
||||||
|
this.updateRecord(next)
|
||||||
|
this.emitUpdates()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): boolean {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
|
||||||
|
if ((result.changes ?? 0) > 0) {
|
||||||
|
database
|
||||||
|
.delete(subscriptionItemsTable)
|
||||||
|
.where(eq(subscriptionItemsTable.subscriptionId, id))
|
||||||
|
.run()
|
||||||
|
this.emitUpdates()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceFeedItems(
|
||||||
|
subscriptionId: string,
|
||||||
|
items: SubscriptionFeedItem[],
|
||||||
|
silent: boolean = false
|
||||||
|
): void {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
|
||||||
|
const now = Date.now()
|
||||||
|
database.transaction((tx) => {
|
||||||
|
tx.delete(subscriptionItemsTable)
|
||||||
|
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
|
||||||
|
.run()
|
||||||
|
for (const item of orderedItems) {
|
||||||
|
tx.insert(subscriptionItemsTable)
|
||||||
|
.values({
|
||||||
|
subscriptionId,
|
||||||
|
itemId: item.id,
|
||||||
|
title: item.title,
|
||||||
|
url: item.url,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
thumbnail: item.thumbnail ?? null,
|
||||||
|
added: booleanToNumber(item.addedToQueue),
|
||||||
|
downloadId: item.downloadId ?? null,
|
||||||
|
createdAt: item.publishedAt,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!silent) {
|
||||||
|
this.emitUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFeedItemQueueState(
|
||||||
|
subscriptionId: string,
|
||||||
|
itemId: string,
|
||||||
|
updates: { added?: boolean; downloadId?: string | null }
|
||||||
|
): void {
|
||||||
|
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.added !== undefined) {
|
||||||
|
setPayload.added = booleanToNumber(updates.added)
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(updates, 'downloadId')) {
|
||||||
|
setPayload.downloadId = updates.downloadId ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const result = database
|
||||||
|
.update(subscriptionItemsTable)
|
||||||
|
.set(setPayload)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
|
||||||
|
eq(subscriptionItemsTable.itemId, itemId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
if ((result.changes ?? 0) > 0) {
|
||||||
|
this.emitUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
|
||||||
|
if (records.length === 0) {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
const ids = records.map((record) => record.id)
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const rows = database
|
||||||
|
.select()
|
||||||
|
.from(subscriptionItemsTable)
|
||||||
|
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
|
||||||
|
.orderBy(desc(subscriptionItemsTable.publishedAt))
|
||||||
|
.all()
|
||||||
|
|
||||||
|
const grouped = new Map<string, SubscriptionFeedItem[]>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const item = this.mapItemRowToFeedItem(row)
|
||||||
|
const list = grouped.get(row.subscriptionId)
|
||||||
|
if (list) {
|
||||||
|
list.push(item)
|
||||||
|
} else {
|
||||||
|
grouped.set(row.subscriptionId, [item])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.map((record) => ({
|
||||||
|
...record,
|
||||||
|
items: grouped.get(record.id) ?? []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatabase(): BetterSQLite3Database {
|
||||||
|
if (this.db) {
|
||||||
|
return this.db
|
||||||
|
}
|
||||||
|
|
||||||
|
const databasePath = this.getDatabasePath()
|
||||||
|
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||||
|
this.sqlite.pragma('journal_mode = WAL')
|
||||||
|
this.sqlite.pragma('foreign_keys = ON')
|
||||||
|
this.db = drizzle(this.sqlite)
|
||||||
|
runMigrations(this.db)
|
||||||
|
this.ensureSubscriptionsSchema()
|
||||||
|
this.ensureItemsSchema()
|
||||||
|
log.info('subscriptions: database initialized at', databasePath)
|
||||||
|
return this.db
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureItemsSchema(): void {
|
||||||
|
if (!this.sqlite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
const hasAdded = columns.some((column) => column.name === 'added')
|
||||||
|
if (!hasAdded) {
|
||||||
|
this.sqlite
|
||||||
|
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
|
||||||
|
const hasLegacyStatus = columns.some((column) => column.name === 'status')
|
||||||
|
const needsMigration = hasDownloaded || hasLegacyStatus
|
||||||
|
if (needsMigration) {
|
||||||
|
if (hasDownloaded) {
|
||||||
|
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
|
||||||
|
}
|
||||||
|
const sqlite = this.sqlite
|
||||||
|
const migrate = sqlite.transaction(() => {
|
||||||
|
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`CREATE TABLE subscription_items_new (
|
||||||
|
subscription_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
published_at INTEGER NOT NULL,
|
||||||
|
thumbnail TEXT,
|
||||||
|
added INTEGER NOT NULL,
|
||||||
|
download_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (subscription_id, item_id)
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO subscription_items_new (
|
||||||
|
subscription_id,
|
||||||
|
item_id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
published_at,
|
||||||
|
thumbnail,
|
||||||
|
added,
|
||||||
|
download_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
subscription_id,
|
||||||
|
item_id,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
published_at,
|
||||||
|
thumbnail,
|
||||||
|
added,
|
||||||
|
download_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM subscription_items`
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
sqlite.prepare(`DROP TABLE subscription_items`).run()
|
||||||
|
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
})
|
||||||
|
migrate()
|
||||||
|
log.info('subscriptions: removed legacy columns from subscription_items')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('subscriptions: failed to ensure subscription_items schema', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureSubscriptionsSchema(): void {
|
||||||
|
if (!this.sqlite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
|
||||||
|
name: string
|
||||||
|
}>
|
||||||
|
const hasLegacyColumns = columns.some((column) =>
|
||||||
|
['seen_item_ids', 'last_item_id'].includes(column.name)
|
||||||
|
)
|
||||||
|
if (!hasLegacyColumns) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sqlite = this.sqlite
|
||||||
|
const migrate = sqlite.transaction(() => {
|
||||||
|
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`CREATE TABLE subscriptions_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
feed_url TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
keywords TEXT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
only_latest INTEGER NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
cover_url TEXT,
|
||||||
|
latest_video_title TEXT,
|
||||||
|
latest_video_published_at INTEGER,
|
||||||
|
last_checked_at INTEGER,
|
||||||
|
last_success_at INTEGER,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_error TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
download_directory TEXT,
|
||||||
|
naming_template TEXT
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO subscriptions_new (
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
source_url,
|
||||||
|
feed_url,
|
||||||
|
platform,
|
||||||
|
keywords,
|
||||||
|
tags,
|
||||||
|
only_latest,
|
||||||
|
enabled,
|
||||||
|
cover_url,
|
||||||
|
latest_video_title,
|
||||||
|
latest_video_published_at,
|
||||||
|
last_checked_at,
|
||||||
|
last_success_at,
|
||||||
|
status,
|
||||||
|
last_error,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
download_directory,
|
||||||
|
naming_template
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
source_url,
|
||||||
|
feed_url,
|
||||||
|
platform,
|
||||||
|
keywords,
|
||||||
|
tags,
|
||||||
|
only_latest,
|
||||||
|
enabled,
|
||||||
|
cover_url,
|
||||||
|
latest_video_title,
|
||||||
|
latest_video_published_at,
|
||||||
|
last_checked_at,
|
||||||
|
last_success_at,
|
||||||
|
status,
|
||||||
|
last_error,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
download_directory,
|
||||||
|
naming_template
|
||||||
|
FROM subscriptions`
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
sqlite.prepare(`DROP TABLE subscriptions`).run()
|
||||||
|
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
|
||||||
|
})
|
||||||
|
migrate()
|
||||||
|
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('subscriptions: failed to ensure subscriptions schema', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDatabasePath(): string {
|
||||||
|
return getDatabaseFilePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateLegacyStore(): void {
|
||||||
|
try {
|
||||||
|
const LegacyStore = require('electron-store')
|
||||||
|
const store = new LegacyStore({
|
||||||
|
name: 'subscriptions',
|
||||||
|
defaults: {
|
||||||
|
subscriptions: []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
|
||||||
|
if (!legacyData || legacyData.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.getAll().length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const legacyItem of legacyData) {
|
||||||
|
try {
|
||||||
|
const normalized: SubscriptionRule = {
|
||||||
|
...legacyItem,
|
||||||
|
keywords: sanitizeList(legacyItem.keywords),
|
||||||
|
tags: sanitizeList(legacyItem.tags),
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
this.insertRecord(normalized)
|
||||||
|
const legacyFeedItemsRaw = Array.isArray(
|
||||||
|
(legacyItem as { items?: SubscriptionFeedItem[] }).items
|
||||||
|
)
|
||||||
|
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
|
||||||
|
: []
|
||||||
|
if (legacyFeedItemsRaw.length > 0) {
|
||||||
|
const converted = legacyFeedItemsRaw.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
thumbnail: item.thumbnail,
|
||||||
|
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
|
||||||
|
downloadId: item.downloadId
|
||||||
|
}))
|
||||||
|
this.replaceFeedItems(normalized.id, converted, true)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('subscriptions: failed to migrate legacy record', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
this.emitUpdates()
|
||||||
|
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
|
||||||
|
} catch (error) {
|
||||||
|
log.warn('subscriptions: legacy migration skipped', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insertRecord(record: SubscriptionRule): void {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const payload = this.mapRecordToInsert(record)
|
||||||
|
database
|
||||||
|
.insert(subscriptionsTable)
|
||||||
|
.values(payload)
|
||||||
|
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateRecord(record: SubscriptionRule): void {
|
||||||
|
const database = this.getDatabase()
|
||||||
|
const payload = this.mapRecordToInsert(record)
|
||||||
|
database
|
||||||
|
.insert(subscriptionsTable)
|
||||||
|
.values(payload)
|
||||||
|
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
|
||||||
|
return {
|
||||||
|
id: record.id,
|
||||||
|
title: record.title,
|
||||||
|
sourceUrl: record.sourceUrl,
|
||||||
|
feedUrl: record.feedUrl,
|
||||||
|
platform: record.platform,
|
||||||
|
keywords: stringifyArray(record.keywords),
|
||||||
|
tags: stringifyArray(record.tags),
|
||||||
|
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
|
||||||
|
enabled: booleanToNumber(record.enabled),
|
||||||
|
coverUrl: record.coverUrl,
|
||||||
|
latestVideoTitle: record.latestVideoTitle,
|
||||||
|
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
|
||||||
|
lastCheckedAt: record.lastCheckedAt ?? null,
|
||||||
|
lastSuccessAt: record.lastSuccessAt ?? null,
|
||||||
|
status: record.status,
|
||||||
|
lastError: record.lastError,
|
||||||
|
createdAt: record.createdAt,
|
||||||
|
updatedAt: record.updatedAt,
|
||||||
|
downloadDirectory: record.downloadDirectory,
|
||||||
|
namingTemplate: record.namingTemplate
|
||||||
|
? sanitizeFilenameTemplate(record.namingTemplate)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
sourceUrl: row.sourceUrl,
|
||||||
|
feedUrl: row.feedUrl,
|
||||||
|
platform: row.platform as SubscriptionRule['platform'],
|
||||||
|
keywords: parseStringArray(row.keywords),
|
||||||
|
tags: parseStringArray(row.tags),
|
||||||
|
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
|
||||||
|
enabled: numberToBoolean(row.enabled),
|
||||||
|
coverUrl: row.coverUrl ?? undefined,
|
||||||
|
latestVideoTitle: row.latestVideoTitle ?? undefined,
|
||||||
|
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
|
||||||
|
lastCheckedAt: row.lastCheckedAt ?? undefined,
|
||||||
|
lastSuccessAt: row.lastSuccessAt ?? undefined,
|
||||||
|
status: row.status as SubscriptionStatus,
|
||||||
|
lastError: row.lastError ?? undefined,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
downloadDirectory: row.downloadDirectory ?? undefined,
|
||||||
|
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
|
||||||
|
items: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
|
||||||
|
return {
|
||||||
|
id: row.itemId,
|
||||||
|
url: row.url,
|
||||||
|
title: row.title,
|
||||||
|
publishedAt: row.publishedAt,
|
||||||
|
thumbnail: row.thumbnail ?? undefined,
|
||||||
|
addedToQueue: numberToBoolean(row.added),
|
||||||
|
downloadId: row.downloadId ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitUpdates(): void {
|
||||||
|
this.emit('subscriptions:updated', this.getAll())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscriptionManager = new SubscriptionManager()
|
||||||
458
src/main/lib/subscription-scheduler.ts
Normal file
458
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import log from 'electron-log/main'
|
||||||
|
import Parser from 'rss-parser'
|
||||||
|
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||||
|
import { settingsManager } from '../settings'
|
||||||
|
import { downloadEngine } from './download-engine'
|
||||||
|
import { historyManager } from './history-manager'
|
||||||
|
import { subscriptionManager } from './subscription-manager'
|
||||||
|
|
||||||
|
const logger = log.scope('subscriptions')
|
||||||
|
|
||||||
|
type ParserItem = {
|
||||||
|
title?: string
|
||||||
|
link?: string
|
||||||
|
guid?: string
|
||||||
|
id?: string
|
||||||
|
isoDate?: string
|
||||||
|
pubDate?: string
|
||||||
|
youtubeId?: string
|
||||||
|
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||||
|
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||||
|
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackedDownload = {
|
||||||
|
subscriptionId: string
|
||||||
|
itemId: string
|
||||||
|
url: string
|
||||||
|
retries: number
|
||||||
|
downloadId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedItem = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
publishedAt: number
|
||||||
|
thumbnail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new Parser<{ item: ParserItem }>({
|
||||||
|
customFields: {
|
||||||
|
item: [
|
||||||
|
['yt:videoId', 'youtubeId'],
|
||||||
|
['media:thumbnail', 'mediaThumbnail'],
|
||||||
|
['media:content', 'mediaContent'],
|
||||||
|
['enclosure', 'enclosure']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const clampIntervalHours = (value: number | undefined): number => {
|
||||||
|
if (!value || Number.isNaN(value)) {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return Math.min(24, Math.max(1, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||||
|
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||||
|
return `sub_${base}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDirectoryExists = (dir?: string): void => {
|
||||||
|
if (!dir) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to ensure subscription download directory:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubscriptionScheduler extends EventEmitter {
|
||||||
|
private timer?: NodeJS.Timeout
|
||||||
|
private checking = false
|
||||||
|
private pendingRun = false
|
||||||
|
private downloads: Map<string, TrackedDownload> = new Map()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
downloadEngine.on('download-completed', (id: string) => {
|
||||||
|
const tracked = this.downloads.get(id)
|
||||||
|
if (!tracked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.downloads.delete(id)
|
||||||
|
subscriptionManager.update(tracked.subscriptionId, {
|
||||||
|
status: 'up-to-date',
|
||||||
|
lastSuccessAt: Date.now()
|
||||||
|
})
|
||||||
|
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||||
|
downloadId: id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
downloadEngine.on('download-error', (id: string, error: Error) => {
|
||||||
|
const tracked = this.downloads.get(id)
|
||||||
|
if (!tracked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const currentRetries = tracked.retries ?? 0
|
||||||
|
if (currentRetries < 1) {
|
||||||
|
logger.warn('Retrying failed subscription download', { id, error })
|
||||||
|
this.queueDownload(
|
||||||
|
tracked.subscriptionId,
|
||||||
|
tracked.itemId,
|
||||||
|
tracked.url,
|
||||||
|
currentRetries + 1
|
||||||
|
).catch((queueError) => {
|
||||||
|
logger.error('Retry queue failed:', queueError)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.downloads.delete(id)
|
||||||
|
subscriptionManager.update(tracked.subscriptionId, {
|
||||||
|
status: 'failed',
|
||||||
|
lastCheckedAt: Date.now()
|
||||||
|
})
|
||||||
|
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||||
|
downloadId: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.scheduleNextRun(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshInterval(): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
this.scheduleNextRun()
|
||||||
|
}
|
||||||
|
|
||||||
|
async runNow(subscriptionId?: string): Promise<void> {
|
||||||
|
if (subscriptionId) {
|
||||||
|
const target = subscriptionManager.getById(subscriptionId)
|
||||||
|
if (target?.enabled) {
|
||||||
|
await this.checkSubscription(target)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await this.checkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
|
||||||
|
const subscription = subscriptionManager.getById(subscriptionId)
|
||||||
|
if (!subscription) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const item = subscription.items.find((entry) => entry.id === itemId)
|
||||||
|
if (!item || item.addedToQueue) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await this.queueDownload(subscriptionId, itemId, item.url)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextRun(initialDelay?: number): void {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
}
|
||||||
|
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||||
|
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAll(): Promise<void> {
|
||||||
|
if (this.checking) {
|
||||||
|
this.pendingRun = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.checking = true
|
||||||
|
try {
|
||||||
|
const subscriptions = subscriptionManager
|
||||||
|
.getAll()
|
||||||
|
.filter((subscription) => subscription.enabled)
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await this.checkSubscription(subscription)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to run subscription sync', error)
|
||||||
|
} finally {
|
||||||
|
this.checking = false
|
||||||
|
if (this.pendingRun) {
|
||||||
|
this.pendingRun = false
|
||||||
|
void this.checkAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
subscriptionManager.update(subscription.id, {
|
||||||
|
status: 'checking',
|
||||||
|
lastCheckedAt: startedAt,
|
||||||
|
lastError: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const feed = await parser.parseURL(subscription.feedUrl)
|
||||||
|
const feedItems = Array.isArray(feed.items) ? feed.items : []
|
||||||
|
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
|
||||||
|
const recentItems = this.filterRecentItems(subscription, normalizedItems)
|
||||||
|
const unseenItems = this.filterNewItems(subscription, recentItems)
|
||||||
|
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
|
||||||
|
const keywordFiltered =
|
||||||
|
keywords.length > 0
|
||||||
|
? unseenItems.filter((item) => {
|
||||||
|
const lowered = item.title.toLowerCase()
|
||||||
|
return keywords.some((keyword) => lowered.includes(keyword))
|
||||||
|
})
|
||||||
|
: unseenItems
|
||||||
|
|
||||||
|
const deduped = keywordFiltered
|
||||||
|
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
|
||||||
|
.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||||
|
|
||||||
|
const itemsToDownload =
|
||||||
|
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
|
||||||
|
|
||||||
|
subscriptionManager.replaceFeedItems(
|
||||||
|
subscription.id,
|
||||||
|
this.buildFeedItems(normalizedItems, subscription)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (itemsToDownload.length > 0) {
|
||||||
|
for (const item of itemsToDownload) {
|
||||||
|
await this.queueDownload(subscription.id, item.id, item.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestItem = normalizedItems[0]
|
||||||
|
subscriptionManager.update(subscription.id, {
|
||||||
|
status: 'up-to-date',
|
||||||
|
lastSuccessAt: Date.now(),
|
||||||
|
lastError: undefined,
|
||||||
|
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||||
|
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||||
|
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||||
|
title:
|
||||||
|
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||||
|
? feed.title.trim()
|
||||||
|
: subscription.title,
|
||||||
|
sourceUrl:
|
||||||
|
typeof feed.link === 'string' && feed.link.trim().length > 0
|
||||||
|
? feed.link.trim()
|
||||||
|
: subscription.sourceUrl
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown RSS error'
|
||||||
|
subscriptionManager.update(subscription.id, {
|
||||||
|
status: 'failed',
|
||||||
|
lastError: message,
|
||||||
|
lastCheckedAt: Date.now()
|
||||||
|
})
|
||||||
|
logger.error('Subscription check failed:', { id: subscription.id, error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
|
||||||
|
const normalized: FeedItem[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
const id = this.resolveItemId(item)
|
||||||
|
if (!id || !item.link || !item.title) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized.push({
|
||||||
|
id,
|
||||||
|
url: item.link,
|
||||||
|
title: item.title,
|
||||||
|
publishedAt: this.resolvePublishedAt(item),
|
||||||
|
thumbnail: this.resolveThumbnail(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFeedItems(
|
||||||
|
items: FeedItem[],
|
||||||
|
subscription: SubscriptionRule
|
||||||
|
): SubscriptionFeedItem[] {
|
||||||
|
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
|
||||||
|
return items.map((item) => {
|
||||||
|
const tracked = this.getTrackedDownloadByUrl(item.url)
|
||||||
|
const existing = existingItems.get(item.id)
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
publishedAt: item.publishedAt,
|
||||||
|
thumbnail: item.thumbnail,
|
||||||
|
addedToQueue:
|
||||||
|
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
|
||||||
|
downloadId: tracked?.downloadId ?? existing?.downloadId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveItemId(item: ParserItem): string | null {
|
||||||
|
const idCandidate =
|
||||||
|
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
|
||||||
|
if (!idCandidate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return idCandidate.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePublishedAt(item: ParserItem): number {
|
||||||
|
const candidates = [item.isoDate, item.pubDate]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const timestamp = Date.parse(candidate)
|
||||||
|
if (!Number.isNaN(timestamp)) {
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveThumbnail(item: ParserItem): string | undefined {
|
||||||
|
// Try media:thumbnail first
|
||||||
|
const thumbnail = item.mediaThumbnail
|
||||||
|
if (Array.isArray(thumbnail)) {
|
||||||
|
const found = thumbnail.find((entry) => entry?.url)
|
||||||
|
if (found?.url) return found.url
|
||||||
|
}
|
||||||
|
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
|
||||||
|
return thumbnail.url as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try enclosure (for RSS feeds with image/jpeg type)
|
||||||
|
const enclosure = item.enclosure
|
||||||
|
if (Array.isArray(enclosure)) {
|
||||||
|
const imageEnclosure = enclosure.find(
|
||||||
|
(entry) => entry?.url && entry?.type?.startsWith('image/')
|
||||||
|
)
|
||||||
|
if (imageEnclosure?.url) return imageEnclosure.url
|
||||||
|
}
|
||||||
|
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
|
||||||
|
const enc = enclosure as { url?: string; type?: string }
|
||||||
|
if (enc.url && enc.type?.startsWith('image/')) {
|
||||||
|
return enc.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try media:content as fallback
|
||||||
|
const mediaContent = item.mediaContent
|
||||||
|
if (Array.isArray(mediaContent)) {
|
||||||
|
const found = mediaContent.find((entry) => entry?.url)
|
||||||
|
if (found?.url) return found.url
|
||||||
|
}
|
||||||
|
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
|
||||||
|
return mediaContent.url as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||||
|
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
|
||||||
|
if (lastKnownPublishedAt === 0) {
|
||||||
|
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
|
||||||
|
}
|
||||||
|
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
|
||||||
|
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
|
||||||
|
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||||
|
const seenIds = new Set(subscription.items.map((item) => item.id))
|
||||||
|
return items.filter((item) => !seenIds.has(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queueDownload(
|
||||||
|
subscriptionId: string,
|
||||||
|
itemId: string,
|
||||||
|
url: string,
|
||||||
|
retryCount = 0
|
||||||
|
): Promise<void> {
|
||||||
|
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
|
||||||
|
const isRetry = retryCount > 0
|
||||||
|
if (this.downloads.has(downloadId) && !isRetry) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = subscriptionManager.getById(subscriptionId)
|
||||||
|
if (!subscription) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = settingsManager.getAll()
|
||||||
|
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||||
|
const namingTemplate =
|
||||||
|
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
|
||||||
|
ensureDirectoryExists(downloadDirectory)
|
||||||
|
|
||||||
|
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||||
|
|
||||||
|
try {
|
||||||
|
downloadEngine.startDownload(downloadId, {
|
||||||
|
url,
|
||||||
|
type: 'video',
|
||||||
|
customDownloadPath: downloadDirectory,
|
||||||
|
customFilenameTemplate: namingTemplate,
|
||||||
|
tags,
|
||||||
|
origin: 'subscription',
|
||||||
|
subscriptionId
|
||||||
|
})
|
||||||
|
|
||||||
|
this.downloads.set(downloadId, {
|
||||||
|
subscriptionId,
|
||||||
|
itemId,
|
||||||
|
url,
|
||||||
|
retries: retryCount,
|
||||||
|
downloadId
|
||||||
|
})
|
||||||
|
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||||
|
added: true,
|
||||||
|
downloadId
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
|
||||||
|
subscriptionManager.update(subscriptionId, {
|
||||||
|
status: 'failed'
|
||||||
|
})
|
||||||
|
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||||
|
added: false,
|
||||||
|
downloadId: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
|
||||||
|
for (const tracked of this.downloads.values()) {
|
||||||
|
if (tracked.url === url) {
|
||||||
|
return tracked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscriptionScheduler = new SubscriptionScheduler()
|
||||||
@@ -77,11 +77,14 @@ class SettingsManager {
|
|||||||
private ensureDownloadDirectory(): void {
|
private ensureDownloadDirectory(): void {
|
||||||
try {
|
try {
|
||||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||||
if (!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH) {
|
const normalizedDownloadPath =
|
||||||
this.store.set('downloadPath', DEFAULT_DOWNLOAD_PATH)
|
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
|
||||||
return
|
? DEFAULT_DOWNLOAD_PATH
|
||||||
|
: currentPath
|
||||||
|
if (normalizedDownloadPath !== currentPath) {
|
||||||
|
this.store.set('downloadPath', normalizedDownloadPath)
|
||||||
}
|
}
|
||||||
ensureDirectoryExists(currentPath)
|
ensureDirectoryExists(normalizedDownloadPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to verify download directory:', error)
|
console.error('Failed to verify download directory:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>VidBee</title>
|
<title>VidBee</title>
|
||||||
<meta http-equiv="Content-Security-Policy"
|
<meta http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self'" />
|
content="default-src 'self'; script-src 'self' 'unsafe-eval' https://rybbit.102417.xyz; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: https://i.ytimg.com https://img.youtube.com; connect-src 'self' https://rybbit.102417.xyz" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
|||||||
import { Sidebar } from '@renderer/components/ui/sidebar'
|
import { Sidebar } from '@renderer/components/ui/sidebar'
|
||||||
import { Toaster } from '@renderer/components/ui/sonner'
|
import { Toaster } from '@renderer/components/ui/sonner'
|
||||||
import { TitleBar } from '@renderer/components/ui/title-bar'
|
import { TitleBar } from '@renderer/components/ui/title-bar'
|
||||||
import { useAtom } from 'jotai'
|
import type { SubscriptionRule } from '@shared/types'
|
||||||
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -11,18 +12,81 @@ import { ipcEvents, ipcServices } from './lib/ipc'
|
|||||||
import { About } from './pages/About'
|
import { About } from './pages/About'
|
||||||
import { Home } from './pages/Home'
|
import { Home } from './pages/Home'
|
||||||
import { Settings } from './pages/Settings'
|
import { Settings } from './pages/Settings'
|
||||||
|
import { Subscriptions } from './pages/Subscriptions'
|
||||||
import { SupportedSites } from './pages/SupportedSites'
|
import { SupportedSites } from './pages/SupportedSites'
|
||||||
import { settingsAtom } from './store/settings'
|
import { loadSettingsAtom, settingsAtom } from './store/settings'
|
||||||
|
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
|
||||||
|
|
||||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||||
const [platform, setPlatform] = useState<string>('')
|
const [platform, setPlatform] = useState<string>('')
|
||||||
|
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||||
|
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||||
const [settings] = useAtom(settingsAtom)
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const autoUpdateEnabled = settings.autoUpdate
|
|
||||||
const updateDownloadInProgressRef = useRef(false)
|
const updateDownloadInProgressRef = useRef(false)
|
||||||
|
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSettings()
|
||||||
|
}, [loadSettings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubscriptions()
|
||||||
|
|
||||||
|
const handleSubscriptions = (...args: unknown[]) => {
|
||||||
|
const list = args[0]
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
setSubscriptions(list as SubscriptionRule[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcEvents.on('subscriptions:updated', handleSubscriptions)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcEvents.removeListener('subscriptions:updated', handleSubscriptions)
|
||||||
|
}
|
||||||
|
}, [loadSubscriptions, setSubscriptions])
|
||||||
|
|
||||||
|
// Load or remove analytics script based on settings
|
||||||
|
useEffect(() => {
|
||||||
|
const scriptId = 'analytics-script'
|
||||||
|
const existingScript = document.getElementById(scriptId) as HTMLScriptElement | null
|
||||||
|
|
||||||
|
if (settings.enableAnalytics) {
|
||||||
|
// Remove existing script if it exists
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and append new script
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.id = scriptId
|
||||||
|
script.src = 'https://rybbit.102417.xyz/api/script.js'
|
||||||
|
script.setAttribute('data-site-id', '7bc6f6d625a4')
|
||||||
|
script.defer = true
|
||||||
|
script.async = true
|
||||||
|
document.head.appendChild(script)
|
||||||
|
analyticsScriptRef.current = script
|
||||||
|
} else {
|
||||||
|
// Remove script if analytics is disabled
|
||||||
|
if (existingScript) {
|
||||||
|
existingScript.remove()
|
||||||
|
analyticsScriptRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup on unmount
|
||||||
|
const script = document.getElementById(scriptId)
|
||||||
|
if (script) {
|
||||||
|
script.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [settings.enableAnalytics])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get platform info to determine if we should show title bar
|
// Get platform info to determine if we should show title bar
|
||||||
@@ -119,7 +183,7 @@ function AppContent() {
|
|||||||
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
|
||||||
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
|
||||||
}
|
}
|
||||||
}, [autoUpdateEnabled, t])
|
}, [t])
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
@@ -132,6 +196,8 @@ function AppContent() {
|
|||||||
)
|
)
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return <Settings />
|
return <Settings />
|
||||||
|
case 'subscriptions':
|
||||||
|
return <Subscriptions />
|
||||||
case 'about':
|
case 'about':
|
||||||
return <About />
|
return <About />
|
||||||
case 'sites':
|
case 'sites':
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import './theme.css';
|
@import './theme.css';
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@@ -8,5 +10,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
}
|
}
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,16 +27,42 @@ import {
|
|||||||
} from '../../store/downloads'
|
} from '../../store/downloads'
|
||||||
import { settingsAtom } from '../../store/settings'
|
import { settingsAtom } from '../../store/settings'
|
||||||
|
|
||||||
|
const normalizeSavedFileName = (fileName?: string): string | undefined => {
|
||||||
|
if (!fileName) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const trimmed = fileName.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return trimmed.replace(/\.f\d+(?=\.[^.]+$)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
const generateFilePathCandidates = (
|
const generateFilePathCandidates = (
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
title: string,
|
title: string,
|
||||||
format: string,
|
format: string,
|
||||||
savedFileName?: string
|
savedFileName?: string
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const candidateFileNames = savedFileName
|
|
||||||
? [savedFileName]
|
|
||||||
: [`${title} via VidBee.${format}`, `${title}.${format}`]
|
|
||||||
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
const normalizedDownloadPath = downloadPath.replace(/\\/g, '/')
|
||||||
|
const safeTitle = title.trim() || 'Unknown'
|
||||||
|
|
||||||
|
const savedNameCandidates: string[] = []
|
||||||
|
const trimmedSavedFileName = savedFileName?.trim()
|
||||||
|
if (trimmedSavedFileName) {
|
||||||
|
const normalized = normalizeSavedFileName(trimmedSavedFileName)
|
||||||
|
if (normalized) {
|
||||||
|
savedNameCandidates.push(normalized)
|
||||||
|
}
|
||||||
|
if (!normalized || normalized !== trimmedSavedFileName) {
|
||||||
|
savedNameCandidates.push(trimmedSavedFileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateFileNames =
|
||||||
|
savedNameCandidates.length > 0
|
||||||
|
? savedNameCandidates
|
||||||
|
: [`${safeTitle} via VidBee.${format}`, `${safeTitle}.${format}`]
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||||
)
|
)
|
||||||
@@ -55,6 +81,73 @@ const tryFileOperation = async (
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSavedFileExtension = (fileName?: string): string | undefined => {
|
||||||
|
const normalized = normalizeSavedFileName(fileName)
|
||||||
|
if (!normalized) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!normalized.includes('.')) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const ext = normalized.split('.').pop()
|
||||||
|
return ext?.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||||
|
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||||
|
if (savedExt) {
|
||||||
|
return savedExt
|
||||||
|
}
|
||||||
|
const selectedExt = download.selectedFormat?.ext?.toLowerCase()
|
||||||
|
if (selectedExt) {
|
||||||
|
return selectedExt
|
||||||
|
}
|
||||||
|
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFormatLabel = (download: DownloadRecord): string | undefined => {
|
||||||
|
if (download.selectedFormat?.ext) {
|
||||||
|
return download.selectedFormat.ext.toUpperCase()
|
||||||
|
}
|
||||||
|
const savedExt = getSavedFileExtension(download.savedFileName)
|
||||||
|
return savedExt ? savedExt.toUpperCase() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getQualityLabel = (download: DownloadRecord): string | undefined => {
|
||||||
|
const format = download.selectedFormat
|
||||||
|
if (!format) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (format.height) {
|
||||||
|
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||||
|
}
|
||||||
|
if (format.format_note) {
|
||||||
|
return format.format_note
|
||||||
|
}
|
||||||
|
if (typeof format.quality === 'number') {
|
||||||
|
return format.quality.toString()
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeCodec = (codec?: string | null): string | undefined => {
|
||||||
|
if (!codec || codec === 'none') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return codec
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCodecLabel = (download: DownloadRecord): string | undefined => {
|
||||||
|
const format = download.selectedFormat
|
||||||
|
if (!format) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (download.type === 'audio' || download.type === 'extract') {
|
||||||
|
return sanitizeCodec(format.acodec)
|
||||||
|
}
|
||||||
|
return sanitizeCodec(format.vcodec) ?? sanitizeCodec(format.acodec)
|
||||||
|
}
|
||||||
|
|
||||||
interface DownloadItemProps {
|
interface DownloadItemProps {
|
||||||
download: DownloadRecord
|
download: DownloadRecord
|
||||||
}
|
}
|
||||||
@@ -105,6 +198,8 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||||
const isHistory = download.entryType === 'history'
|
const isHistory = download.entryType === 'history'
|
||||||
|
const isSubscriptionDownload = download.origin === 'subscription'
|
||||||
|
const subscriptionLabel = download.subscriptionId ?? t('subscriptions.labels.unknown')
|
||||||
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
|
||||||
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
const showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||||
const actionsContainerBaseClass =
|
const actionsContainerBaseClass =
|
||||||
@@ -112,6 +207,8 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
const actionsContainerClass = showActionsWithoutHover
|
const actionsContainerClass = showActionsWithoutHover
|
||||||
? actionsContainerBaseClass
|
? actionsContainerBaseClass
|
||||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
||||||
|
const resolvedExtension = resolveDownloadExtension(download)
|
||||||
|
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||||
|
|
||||||
// Track if the file exists
|
// Track if the file exists
|
||||||
const [fileExists, setFileExists] = useState(false)
|
const [fileExists, setFileExists] = useState(false)
|
||||||
@@ -126,7 +223,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formatForPath = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
const formatForPath = resolvedExtension
|
||||||
const filePaths = generateFilePathCandidates(
|
const filePaths = generateFilePathCandidates(
|
||||||
download.downloadPath,
|
download.downloadPath,
|
||||||
download.title,
|
download.title,
|
||||||
@@ -148,13 +245,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checkFileExists()
|
checkFileExists()
|
||||||
}, [
|
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||||
download.title,
|
|
||||||
download.downloadPath,
|
|
||||||
download.format,
|
|
||||||
download.savedFileName,
|
|
||||||
download.type
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleCancel = async () => {
|
const handleCancel = async () => {
|
||||||
if (isHistory) return
|
if (isHistory) return
|
||||||
@@ -169,7 +260,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
const handleOpenFolder = async () => {
|
const handleOpenFolder = async () => {
|
||||||
try {
|
try {
|
||||||
const downloadPath = download.downloadPath || settings.downloadPath
|
const downloadPath = download.downloadPath || settings.downloadPath
|
||||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
const format = resolvedExtension
|
||||||
const filePaths = generateFilePathCandidates(
|
const filePaths = generateFilePathCandidates(
|
||||||
downloadPath,
|
downloadPath,
|
||||||
download.title,
|
download.title,
|
||||||
@@ -190,12 +281,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
}
|
}
|
||||||
// Check if copy to clipboard is available
|
// Check if copy to clipboard is available
|
||||||
const canCopyToClipboard = () => {
|
const canCopyToClipboard = () => {
|
||||||
return !!(
|
return Boolean(download.title && download.downloadPath && fileExists)
|
||||||
download.title &&
|
|
||||||
download.downloadPath &&
|
|
||||||
fileExists &&
|
|
||||||
(download.savedFileName || download.format)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// need title, downloadPath, format
|
// need title, downloadPath, format
|
||||||
@@ -207,10 +293,10 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
|
|
||||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||||
const downloadPath = download.downloadPath
|
const downloadPath = download.downloadPath
|
||||||
const format = download.format
|
const format = resolvedExtension
|
||||||
const title = download.title
|
const title = download.title
|
||||||
|
|
||||||
if (!downloadPath || !format || !title) {
|
if (!downloadPath || !title) {
|
||||||
toast.error(t('notifications.copyFailed'))
|
toast.error(t('notifications.copyFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -243,7 +329,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
if (!isHistory) return
|
if (!isHistory) return
|
||||||
try {
|
try {
|
||||||
const downloadPath = download.downloadPath || settings.downloadPath
|
const downloadPath = download.downloadPath || settings.downloadPath
|
||||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
const format = resolvedExtension
|
||||||
const filePaths = generateFilePathCandidates(
|
const filePaths = generateFilePathCandidates(
|
||||||
downloadPath,
|
downloadPath,
|
||||||
download.title,
|
download.title,
|
||||||
@@ -360,11 +446,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||||
|
|
||||||
const formatLabelValue = download.selectedFormat?.ext
|
const formatLabelValue = getFormatLabel(download)
|
||||||
? download.selectedFormat.ext.toUpperCase()
|
|
||||||
: download.format
|
|
||||||
? download.format.toUpperCase()
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (formatLabelValue) {
|
if (formatLabelValue) {
|
||||||
metadataDetails.push({
|
metadataDetails.push({
|
||||||
@@ -373,14 +455,12 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const qualityValue = download.selectedFormat?.height
|
const qualityLabel = getQualityLabel(download)
|
||||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
|
||||||
: download.quality
|
|
||||||
|
|
||||||
if (qualityValue) {
|
if (qualityLabel) {
|
||||||
metadataDetails.push({
|
metadataDetails.push({
|
||||||
label: t('download.metadata.quality'),
|
label: t('download.metadata.quality'),
|
||||||
value: qualityValue
|
value: qualityLabel
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,17 +471,18 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.codec) {
|
const codecValue = getCodecLabel(download)
|
||||||
|
if (codecValue) {
|
||||||
metadataDetails.push({
|
metadataDetails.push({
|
||||||
label: t('download.metadata.codec'),
|
label: t('download.metadata.codec'),
|
||||||
value: download.codec
|
value: codecValue
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.savedFileName) {
|
if (normalizedSavedFileName || download.savedFileName) {
|
||||||
metadataDetails.push({
|
metadataDetails.push({
|
||||||
label: t('download.metadata.savedFile'),
|
label: t('download.metadata.savedFile'),
|
||||||
value: download.savedFileName
|
value: normalizedSavedFileName ?? download.savedFileName
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +586,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download.selectedFormat.height && !qualityValue) {
|
if (download.selectedFormat.height && !qualityLabel) {
|
||||||
metadataDetails.push({
|
metadataDetails.push({
|
||||||
label: t('download.metadata.height'),
|
label: t('download.metadata.height'),
|
||||||
value: `${download.selectedFormat.height}px`
|
value: `${download.selectedFormat.height}px`
|
||||||
@@ -548,6 +629,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSubscriptionDownload) {
|
||||||
|
metadataDetails.push({
|
||||||
|
label: t('download.metadata.subscription'),
|
||||||
|
value: subscriptionLabel
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const hasMetadataDetails = metadataDetails.length > 0
|
const hasMetadataDetails = metadataDetails.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -567,10 +655,15 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
|
||||||
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
|
||||||
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
|
||||||
<div className="w-full min-w-0 overflow-hidden">
|
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
||||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||||
{download.title}
|
{download.title}
|
||||||
</p>
|
</p>
|
||||||
|
{isSubscriptionDownload && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||||
|
{t('subscriptions.labels.subscription')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
{(statusIcon || statusText) && (
|
{(statusIcon || statusText) && (
|
||||||
@@ -624,11 +717,9 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quality badge */}
|
{/* Quality badge */}
|
||||||
{(download.selectedFormat?.height || download.quality) && (
|
{qualityLabel && (
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||||
{download.selectedFormat?.height
|
{qualityLabel}
|
||||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
|
||||||
: download.quality}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@renderer/components/ui/dialog'
|
||||||
|
import { Input } from '@renderer/components/ui/input'
|
||||||
|
import { Label } from '@renderer/components/ui/label'
|
||||||
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
|
import { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { settingsAtom } from '@renderer/store/settings'
|
||||||
|
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||||
|
import type { SubscriptionRule } from '@shared/types'
|
||||||
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const sanitizeCommaList = (value: string) =>
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||||
|
|
||||||
|
const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-')
|
||||||
|
|
||||||
|
export interface SubscriptionFormData {
|
||||||
|
url?: string
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
onlyDownloadLatest?: boolean
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionFormDialogProps {
|
||||||
|
mode: 'add' | 'edit'
|
||||||
|
subscription?: SubscriptionRule
|
||||||
|
open: boolean
|
||||||
|
onSave: (data: SubscriptionFormData) => Promise<void>
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubscriptionFormDialog({
|
||||||
|
mode,
|
||||||
|
subscription,
|
||||||
|
open,
|
||||||
|
onSave,
|
||||||
|
onClose
|
||||||
|
}: SubscriptionFormDialogProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [settings] = useAtom(settingsAtom)
|
||||||
|
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [keywords, setKeywords] = useState('')
|
||||||
|
const [tags, setTags] = useState('')
|
||||||
|
const [onlyLatest, setOnlyLatest] = useState(false)
|
||||||
|
const [downloadDirectory, setDownloadDirectory] = useState('')
|
||||||
|
const [namingTemplate, setNamingTemplate] = useState('')
|
||||||
|
|
||||||
|
// Feed detection state
|
||||||
|
const [detectingFeed, setDetectingFeed] = useState(false)
|
||||||
|
|
||||||
|
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const prevDefaultPathRef = useRef(settings.downloadPath)
|
||||||
|
const urlInputId = useId()
|
||||||
|
|
||||||
|
// Initialize form values based on mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && subscription) {
|
||||||
|
setUrl(subscription.feedUrl)
|
||||||
|
setKeywords(subscription.keywords.join(', '))
|
||||||
|
setTags(subscription.tags.join(', '))
|
||||||
|
setOnlyLatest(subscription.onlyDownloadLatest)
|
||||||
|
setDownloadDirectory(subscription.downloadDirectory || '')
|
||||||
|
setNamingTemplate(subscription.namingTemplate || '')
|
||||||
|
} else {
|
||||||
|
// Add mode - use defaults from settings
|
||||||
|
setUrl('')
|
||||||
|
setKeywords('')
|
||||||
|
setTags('')
|
||||||
|
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||||
|
setDownloadDirectory(settings.downloadPath)
|
||||||
|
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
mode,
|
||||||
|
subscription,
|
||||||
|
settings.subscriptionOnlyLatestDefault,
|
||||||
|
settings.downloadPath,
|
||||||
|
settings.subscriptionFilenameTemplate
|
||||||
|
])
|
||||||
|
|
||||||
|
// Sync download directory with settings changes (only in add mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'add') {
|
||||||
|
const newPath = settings.downloadPath
|
||||||
|
setDownloadDirectory((prev) => {
|
||||||
|
if (!prev || prev === prevDefaultPathRef.current) {
|
||||||
|
return newPath
|
||||||
|
}
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
prevDefaultPathRef.current = newPath
|
||||||
|
}
|
||||||
|
}, [settings.downloadPath, mode])
|
||||||
|
|
||||||
|
// Sync naming template with settings changes (only in add mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'add') {
|
||||||
|
setNamingTemplate(settings.subscriptionFilenameTemplate)
|
||||||
|
}
|
||||||
|
}, [settings.subscriptionFilenameTemplate, mode])
|
||||||
|
|
||||||
|
// Sync onlyLatest with settings changes (only in add mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'add') {
|
||||||
|
setOnlyLatest(settings.subscriptionOnlyLatestDefault)
|
||||||
|
}
|
||||||
|
}, [settings.subscriptionOnlyLatestDefault, mode])
|
||||||
|
|
||||||
|
// Feed detection logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// In edit mode, don't detect if URL hasn't changed
|
||||||
|
if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectTimeout.current) {
|
||||||
|
clearTimeout(detectTimeout.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
detectTimeout.current = setTimeout(async () => {
|
||||||
|
setDetectingFeed(true)
|
||||||
|
try {
|
||||||
|
await resolveFeed(url.trim())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve feed:', error)
|
||||||
|
} finally {
|
||||||
|
setDetectingFeed(false)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (detectTimeout.current) {
|
||||||
|
clearTimeout(detectTimeout.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [url, resolveFeed, mode, subscription])
|
||||||
|
|
||||||
|
const handleSelectDirectory = async () => {
|
||||||
|
try {
|
||||||
|
const path = await ipcServices.fs.selectDirectory()
|
||||||
|
if (path) {
|
||||||
|
setDownloadDirectory(path)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select directory:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.directoryError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenRSSHubDocs = async () => {
|
||||||
|
try {
|
||||||
|
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open RSSHub documentation:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validate URL for add mode
|
||||||
|
if (mode === 'add' && !url.trim()) {
|
||||||
|
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData: SubscriptionFormData = {
|
||||||
|
keywords: sanitizeCommaList(keywords),
|
||||||
|
tags: sanitizeCommaList(tags),
|
||||||
|
onlyDownloadLatest: onlyLatest,
|
||||||
|
downloadDirectory: downloadDirectory || undefined,
|
||||||
|
namingTemplate: namingTemplate || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include URL if it's provided and different from current (for edit mode)
|
||||||
|
if (url.trim()) {
|
||||||
|
if (
|
||||||
|
mode === 'add' ||
|
||||||
|
(mode === 'edit' && subscription && url.trim() !== subscription.feedUrl)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await resolveFeed(url.trim())
|
||||||
|
formData.url = url.trim()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve feed:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.resolveError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSave(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title'
|
||||||
|
const descriptionKey =
|
||||||
|
mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description'
|
||||||
|
const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mode === 'edit' && subscription
|
||||||
|
? t(titleKey, { name: subscription.title })
|
||||||
|
: t(titleKey)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t(descriptionKey)}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
|
||||||
|
<Input
|
||||||
|
id={urlInputId}
|
||||||
|
value={url}
|
||||||
|
placeholder={t('subscriptions.placeholders.url')}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
{detectingFeed && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
|
||||||
|
)}
|
||||||
|
{mode === 'add' && !url.trim() && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground flex-1">
|
||||||
|
{t('subscriptions.rssHub.hint')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleOpenRSSHubDocs()}
|
||||||
|
className="h-5 w-5 p-0 shrink-0"
|
||||||
|
title={t('subscriptions.rssHub.openDocs')}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||||
|
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.tags')}</Label>
|
||||||
|
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.customDirectory')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={downloadDirectory} readOnly />
|
||||||
|
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
|
||||||
|
{t('subscriptions.actions.selectDirectory')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
|
||||||
|
<Input
|
||||||
|
value={namingTemplate}
|
||||||
|
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
|
||||||
|
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||||
|
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
{mode === 'add' && (
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('download.cancel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
221
src/renderer/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup
|
||||||
|
}
|
||||||
@@ -1,101 +1,126 @@
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import { cn } from '@renderer/lib/utils'
|
import { cn } from '@renderer/lib/utils'
|
||||||
import { X } from 'lucide-react'
|
import { XIcon } from 'lucide-react'
|
||||||
import * as React from 'react'
|
import type * as React from 'react'
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
const DialogTrigger = DialogPrimitive.Trigger
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
const DialogPortal = DialogPrimitive.Portal
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
const DialogClose = DialogPrimitive.Close
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
function DialogOverlay({
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
className,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
...props
|
||||||
>(({ className, ...props }, ref) => (
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
<DialogPrimitive.Overlay
|
return (
|
||||||
ref={ref}
|
<DialogPrimitive.Overlay
|
||||||
className={cn(
|
data-slot="dialog-overlay"
|
||||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DialogPortal>
|
|
||||||
<DialogOverlay />
|
|
||||||
<DialogPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
/>
|
||||||
{children}
|
)
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
}
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
))
|
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
function DialogContent({
|
||||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
className,
|
||||||
)
|
children,
|
||||||
DialogHeader.displayName = 'DialogHeader'
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
<div
|
return (
|
||||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
<div
|
||||||
{...props}
|
data-slot="dialog-header"
|
||||||
/>
|
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||||
)
|
{...props}
|
||||||
DialogFooter.displayName = 'DialogFooter'
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const DialogTitle = React.forwardRef<
|
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
return (
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
<div
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="dialog-footer"
|
||||||
<DialogPrimitive.Title
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const DialogDescription = React.forwardRef<
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
return (
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
<DialogPrimitive.Title
|
||||||
>(({ className, ...props }, ref) => (
|
data-slot="dialog-title"
|
||||||
<DialogPrimitive.Description
|
className={cn('text-lg leading-none font-semibold', className)}
|
||||||
ref={ref}
|
{...props}
|
||||||
className={cn('text-sm text-muted-foreground', className)}
|
/>
|
||||||
{...props}
|
)
|
||||||
/>
|
}
|
||||||
))
|
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
|
||||||
DialogOverlay,
|
|
||||||
DialogClose,
|
DialogClose,
|
||||||
DialogTrigger,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription
|
DialogTrigger
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
35
src/renderer/src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = 'center',
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||||
@@ -19,10 +19,12 @@ import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
|
|||||||
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
|
||||||
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
import MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||||
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
import MingcuteInformationLine from '~icons/mingcute/information-line'
|
||||||
|
import MingcuteRssFill from '~icons/mingcute/rss-fill'
|
||||||
|
import MingcuteRssLine from '~icons/mingcute/rss-line'
|
||||||
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill'
|
||||||
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
import MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||||
|
|
||||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
id: Page
|
id: Page
|
||||||
@@ -53,6 +55,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
},
|
},
|
||||||
label: t('menu.download')
|
label: t('menu.download')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'subscriptions',
|
||||||
|
icon: {
|
||||||
|
active: MingcuteRssFill,
|
||||||
|
inactive: MingcuteRssLine
|
||||||
|
},
|
||||||
|
label: t('menu.rss')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'sites',
|
id: 'sites',
|
||||||
icon: {
|
icon: {
|
||||||
@@ -102,21 +112,15 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
size="icon"
|
||||||
variant="ghost"
|
onClick={() => onPageChange(item.id)}
|
||||||
size="icon"
|
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
||||||
onClick={() => onPageChange(item.id)}
|
>
|
||||||
className={`no-drag w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
|
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
||||||
>
|
</Button>
|
||||||
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">
|
|
||||||
<p>{item.label}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -129,7 +133,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
<aside className="drag-region w-20 max-w-20 min-w-20 border-r border-border/60 bg-background/77 flex flex-col items-center py-4 gap-2">
|
||||||
{/* App Logo */}
|
{/* App Logo */}
|
||||||
<div className="flex flex-col items-center gap-1 py-4 mt-2">
|
<div className="flex flex-col items-center gap-1 py-3 mt-4">
|
||||||
<div className="w-12 h-12 flex items-center justify-center">
|
<div className="w-12 h-12 flex items-center justify-center">
|
||||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -190,7 +190,8 @@
|
|||||||
"videoCodec": "Video codec",
|
"videoCodec": "Video codec",
|
||||||
"audioCodec": "Audio codec",
|
"audioCodec": "Audio codec",
|
||||||
"formatNote": "Format note",
|
"formatNote": "Format note",
|
||||||
"protocol": "Protocol"
|
"protocol": "Protocol",
|
||||||
|
"subscription": "Subscription"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -245,6 +246,8 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"playlist": "Download Playlist",
|
"playlist": "Download Playlist",
|
||||||
|
"rss": "RSS",
|
||||||
|
"subscriptions": "Subscriptions",
|
||||||
"preferences": "Preferences",
|
"preferences": "Preferences",
|
||||||
"supportedSites": "Supported Sites",
|
"supportedSites": "Supported Sites",
|
||||||
"theme": "Theme:"
|
"theme": "Theme:"
|
||||||
@@ -325,6 +328,7 @@
|
|||||||
},
|
},
|
||||||
"configFile": "Use configuration file",
|
"configFile": "Use configuration file",
|
||||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||||
|
"clearConfigFile": "Clear",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"description": "Configure your download preferences and application settings",
|
"description": "Configure your download preferences and application settings",
|
||||||
"directorySelectError": "Failed to select directory",
|
"directorySelectError": "Failed to select directory",
|
||||||
@@ -336,6 +340,8 @@
|
|||||||
"light": "Light",
|
"light": "Light",
|
||||||
"hideDockIcon": "Hide Dock icon",
|
"hideDockIcon": "Hide Dock icon",
|
||||||
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",
|
||||||
|
"enableAnalytics": "Help improve VidBee",
|
||||||
|
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
@@ -360,6 +366,10 @@
|
|||||||
"selectPath": "Select",
|
"selectPath": "Select",
|
||||||
"showMoreFormats": "Show more format options",
|
"showMoreFormats": "Show more format options",
|
||||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||||
|
"subscriptionDefaults": {
|
||||||
|
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
||||||
|
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
||||||
|
},
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||||
@@ -370,6 +380,120 @@
|
|||||||
},
|
},
|
||||||
"video": "Video Preferences"
|
"video": "Video Preferences"
|
||||||
},
|
},
|
||||||
|
"subscriptions": {
|
||||||
|
"title": "Subscriptions",
|
||||||
|
"subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}",
|
||||||
|
"description": "Automatically monitor RSS feeds and queue new downloads without manual work.",
|
||||||
|
"defaults": {
|
||||||
|
"title": "Automation defaults",
|
||||||
|
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||||
|
"downloadDirectory": "Download directory",
|
||||||
|
"filenameTemplate": "Filename template (file only)",
|
||||||
|
"checkInterval": "Check interval (hours)",
|
||||||
|
"onlyLatest": "Download only the latest video",
|
||||||
|
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||||
|
},
|
||||||
|
"add": {
|
||||||
|
"title": "Add RSS",
|
||||||
|
"description": "Paste an RSS feed link. VidBee will detect the feed automatically."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"url": "Feed URL",
|
||||||
|
"keywords": "Keyword filter (comma separated)",
|
||||||
|
"tags": "Auto tags",
|
||||||
|
"customDirectory": "Custom directory",
|
||||||
|
"namingTemplate": "Custom filename template (file only)",
|
||||||
|
"onlyLatest": "Download only the latest video",
|
||||||
|
"onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"onlyLatestShort": "Only latest"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Add",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"edit": "Edit",
|
||||||
|
"remove": "Remove",
|
||||||
|
"save": "Save changes",
|
||||||
|
"selectDirectory": "Browse",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"title": "Latest uploads ({{count}})",
|
||||||
|
"count": "{{count}} items",
|
||||||
|
"empty": "No recent feed items found.",
|
||||||
|
"status": {
|
||||||
|
"queued": "Queued",
|
||||||
|
"notQueued": "Not queued",
|
||||||
|
"pending": "Pending",
|
||||||
|
"downloading": "Downloading",
|
||||||
|
"processing": "Processing",
|
||||||
|
"completed": "Completed",
|
||||||
|
"error": "Failed",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
},
|
||||||
|
"fromChannel": "From {{channel}}",
|
||||||
|
"tooltip": {
|
||||||
|
"downloadStatus": "Download status: {{status}}",
|
||||||
|
"downloadPending": "Waiting for download details...",
|
||||||
|
"notQueued": "Not in the download queue yet"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"open": "Open in browser",
|
||||||
|
"queue": "Add to download queue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"unknown": "Unknown subscription",
|
||||||
|
"noThumbnail": "No thumbnail"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"directoryError": "Failed to open the directory picker.",
|
||||||
|
"missingUrl": "Please paste a channel link first.",
|
||||||
|
"created": "Subscription added",
|
||||||
|
"createError": "Failed to add subscription.",
|
||||||
|
"refreshStarted": "Refresh started",
|
||||||
|
"removed": "Subscription removed",
|
||||||
|
"updated": "Subscription updated",
|
||||||
|
"itemQueued": "Added to download queue",
|
||||||
|
"itemAlreadyQueued": "This video is already queued",
|
||||||
|
"queueError": "Failed to add to download queue.",
|
||||||
|
"openLinkError": "Failed to open the video link.",
|
||||||
|
"resolveError": "Failed to resolve RSS feed URL."
|
||||||
|
},
|
||||||
|
"detectedFeed": "Detected {{platform}} feed -> {{feed}}",
|
||||||
|
"detecting": "Detecting feed...",
|
||||||
|
"latestVideo": "Latest video: {{title}}",
|
||||||
|
"lastChecked": "Last checked: {{time}}",
|
||||||
|
"never": "Never",
|
||||||
|
"empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.",
|
||||||
|
"edit": {
|
||||||
|
"title": "Edit {{name}}",
|
||||||
|
"description": "Tweak filters, tags, and overrides for this feed."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "Status",
|
||||||
|
"up-to-date": "Up to date",
|
||||||
|
"checking": "Checking",
|
||||||
|
"failed": "Failed",
|
||||||
|
"idle": "Idle",
|
||||||
|
"tooltip": {
|
||||||
|
"updatedAt": "Updated: {{time}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rssHub": {
|
||||||
|
"title": "Automated Subscriptions with RSSHub",
|
||||||
|
"description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.",
|
||||||
|
"learnMore": "Learn more about RSSHub",
|
||||||
|
"openDocs": "Open RSSHub Documentation",
|
||||||
|
"hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms."
|
||||||
|
}
|
||||||
|
},
|
||||||
"sites": {
|
"sites": {
|
||||||
"homeInlineDescription": "Supports {{sites}} and more.",
|
"homeInlineDescription": "Supports {{sites}} and more.",
|
||||||
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
|
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
|||||||
|
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||||
const inlinePreviewSites = popularSites
|
const inlinePreviewSites = popularSites
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((site) => t(`sites.popular.${site.id}.label`))
|
.map((site) => t(`sites.popular.${site.id}.label`))
|
||||||
@@ -536,262 +537,275 @@ export function Home({ onOpenSupportedSites, onOpenSettings }: HomeProps) {
|
|||||||
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
|
className="container mx-auto max-w-7xl p-6 space-y-6 overflow-hidden w-full"
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
>
|
>
|
||||||
<Tabs defaultValue="single" className="w-full">
|
<Card>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<Tabs
|
||||||
<TabsTrigger value="single" className="flex items-center gap-2">
|
defaultValue="single"
|
||||||
{t('download.singleVideo')}
|
value={activeTab}
|
||||||
</TabsTrigger>
|
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||||
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
className="w-full gap-0"
|
||||||
{t('playlist.title')}
|
>
|
||||||
</TabsTrigger>
|
<CardHeader>
|
||||||
</TabsList>
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
{/* Single Video Download Tab */}
|
<CardTitle>
|
||||||
<TabsContent value="single" className="space-y-6">
|
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
|
||||||
{/* URL Input Card */}
|
</CardTitle>
|
||||||
{!videoInfo && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('download.enterUrl')}</CardTitle>
|
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
{activeTab === 'single' ? (
|
||||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
className="px-0"
|
|
||||||
onClick={() => onOpenSupportedSites?.()}
|
|
||||||
>
|
|
||||||
{t('sites.viewAll')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
placeholder={t('download.urlPlaceholder')}
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="pr-10"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
{loading && (
|
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
|
||||||
{t('download.paste')}
|
|
||||||
</Button>
|
|
||||||
{settings.oneClickDownload ? (
|
|
||||||
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('download.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{t('download.oneClickDownloadNow')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
{t('download.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Search className="mr-2 h-4 w-4" />
|
|
||||||
{t('download.fetch')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* One-Click Download Info */}
|
|
||||||
{settings.oneClickDownload && (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg bg-card px-4 py-3 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
|
||||||
</div>
|
|
||||||
{onOpenSettings && (
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="link"
|
variant="link"
|
||||||
className="h-auto px-2 py-0 text-xs"
|
className="p-0 h-auto"
|
||||||
onClick={onOpenSettings}
|
onClick={() => onOpenSupportedSites?.()}
|
||||||
>
|
>
|
||||||
{t('download.goToSettings')}
|
{t('sites.viewAll')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('playlist.playlistUrlDescription')
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<TabsList className="grid grid-cols-2">
|
||||||
|
<TabsTrigger value="single" className="flex items-center gap-2">
|
||||||
|
{t('download.singleVideo')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="playlist" className="flex items-center gap-2">
|
||||||
|
{t('playlist.title')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{/* Single Video Download Tab */}
|
||||||
|
<TabsContent value="single" className="space-y-6 mt-0">
|
||||||
|
{/* URL Input Card */}
|
||||||
|
{!videoInfo && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={t('download.urlPlaceholder')}
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="pr-10"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handlePasteUrl} variant="outline" disabled={loading}>
|
||||||
|
{t('download.paste')}
|
||||||
|
</Button>
|
||||||
|
{settings.oneClickDownload ? (
|
||||||
|
<Button onClick={handleOneClickDownload} disabled={loading || !url.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t('download.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{t('download.oneClickDownloadNow')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleFetchVideo} disabled={loading || !url.trim()}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t('download.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
{t('download.fetch')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* One-Click Download Info */}
|
||||||
{error && (
|
{settings.oneClickDownload && (
|
||||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||||
<div className="flex-1 space-y-2">
|
</div>
|
||||||
<p className="text-sm font-medium text-destructive">
|
{onOpenSettings && (
|
||||||
{t('errors.fetchInfoFailed')}
|
<Button
|
||||||
</p>
|
type="button"
|
||||||
<p className="text-sm text-muted-foreground">{error}</p>
|
variant="link"
|
||||||
|
className="h-auto px-2 py-0 text-xs"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
>
|
||||||
|
{t('download.goToSettings')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-destructive bg-destructive/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{t('errors.fetchInfoFailed')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{error}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Video Info and Download Options */}
|
|
||||||
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* Playlist Download Tab */}
|
|
||||||
<TabsContent value="playlist" className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('playlist.enterPlaylistUrl')}</CardTitle>
|
|
||||||
<CardDescription>{t('playlist.playlistUrlDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id={playlistUrlId}
|
|
||||||
placeholder="https://www.youtube.com/playlist?list=..."
|
|
||||||
value={playlistUrl}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPlaylistUrl(e.target.value)
|
|
||||||
setPlaylistInfo(null)
|
|
||||||
setPlaylistPreviewError(null)
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
disabled={playlistBusy}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handlePastePlaylistUrl}
|
|
||||||
variant="outline"
|
|
||||||
disabled={playlistBusy}
|
|
||||||
>
|
|
||||||
{t('download.paste')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
|
||||||
<Select
|
|
||||||
value={downloadType}
|
|
||||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
|
||||||
disabled={playlistBusy}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={downloadTypeId}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
|
||||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={t('playlist.startIndex')}
|
|
||||||
value={startIndex}
|
|
||||||
onChange={(e) => setStartIndex(e.target.value)}
|
|
||||||
min="1"
|
|
||||||
disabled={playlistBusy}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={t('playlist.endIndex')}
|
|
||||||
value={endIndex}
|
|
||||||
onChange={(e) => setEndIndex(e.target.value)}
|
|
||||||
min="1"
|
|
||||||
disabled={playlistBusy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<Button
|
|
||||||
onClick={handlePreviewPlaylist}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
disabled={playlistBusy || !playlistUrl.trim()}
|
|
||||||
>
|
|
||||||
{playlistPreviewLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
||||||
{t('download.loading')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Search className="mr-2 h-5 w-5" />
|
|
||||||
{t('playlist.previewButton')}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
{playlistInfo && (
|
)}
|
||||||
|
|
||||||
|
{/* Video Info and Download Options */}
|
||||||
|
{videoInfo && !loading && <VideoInfoCard videoInfo={videoInfo} />}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Playlist Download Tab */}
|
||||||
|
<TabsContent value="playlist" className="space-y-6 mt-0">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={playlistUrlId}>{t('playlist.linkLabel')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id={playlistUrlId}
|
||||||
|
placeholder="https://www.youtube.com/playlist?list=..."
|
||||||
|
value={playlistUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlaylistUrl(e.target.value)
|
||||||
|
setPlaylistInfo(null)
|
||||||
|
setPlaylistPreviewError(null)
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={playlistBusy}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handlePastePlaylistUrl}
|
||||||
|
variant="outline"
|
||||||
|
disabled={playlistBusy}
|
||||||
|
>
|
||||||
|
{t('download.paste')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||||
|
<Select
|
||||||
|
value={downloadType}
|
||||||
|
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||||
|
disabled={playlistBusy}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={downloadTypeId}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||||
|
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-muted-foreground">{t('playlist.range')}</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t('playlist.startIndex')}
|
||||||
|
value={startIndex}
|
||||||
|
onChange={(e) => setStartIndex(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
disabled={playlistBusy}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={t('playlist.endIndex')}
|
||||||
|
value={endIndex}
|
||||||
|
onChange={(e) => setEndIndex(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
disabled={playlistBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDownloadPlaylist}
|
onClick={handlePreviewPlaylist}
|
||||||
className="w-full sm:flex-1"
|
variant="outline"
|
||||||
size="lg"
|
className="w-full sm:w-auto"
|
||||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
disabled={playlistBusy || !playlistUrl.trim()}
|
||||||
>
|
>
|
||||||
{playlistDownloadLoading ? (
|
{playlistPreviewLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
{t('download.loading')}
|
{t('download.loading')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t('playlist.downloadPlaylist')
|
<>
|
||||||
|
<Search className="mr-2 h-5 w-5" />
|
||||||
|
{t('playlist.previewButton')}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{playlistInfo && (
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadPlaylist}
|
||||||
|
className="w-full sm:flex-1"
|
||||||
|
size="lg"
|
||||||
|
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||||
|
>
|
||||||
|
{playlistDownloadLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
{t('download.loading')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('playlist.downloadPlaylist')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playlistPreviewError && (
|
||||||
|
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{playlistPreviewError}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</CardContent>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{playlistUrl.trim() && !playlistInfo && !playlistPreviewError && !playlistBusy && (
|
{/* Playlist Preview Card (outside main card) */}
|
||||||
<p className="text-xs text-muted-foreground">{t('playlist.previewRequired')}</p>
|
{playlistInfo && (
|
||||||
)}
|
<PlaylistPreviewCard
|
||||||
|
playlist={playlistInfo}
|
||||||
{playlistPreviewError && (
|
entries={selectedPlaylistEntries}
|
||||||
<div className="rounded-lg border border-destructive bg-destructive/10 p-3 text-sm text-destructive">
|
onClear={handleClearPlaylistPreview}
|
||||||
{playlistPreviewError}
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{playlistInfo && (
|
|
||||||
<PlaylistPreviewCard
|
|
||||||
playlist={playlistInfo}
|
|
||||||
entries={selectedPlaylistEntries}
|
|
||||||
onClear={handleClearPlaylistPreview}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* Unified Download History */}
|
{/* Unified Download History */}
|
||||||
<UnifiedDownloadHistory />
|
<UnifiedDownloadHistory />
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||||
|
|
||||||
|
const clampSubscriptionInterval = (value: string) => {
|
||||||
|
const parsed = Number.parseInt(value, 10)
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return Math.min(24, Math.max(1, parsed))
|
||||||
|
}
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
@@ -293,6 +301,33 @@ export function Settings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
{t('settings.subscriptionDefaults.intervalDescription')}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
defaultValue={settings.subscriptionCheckIntervalHours}
|
||||||
|
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
|
||||||
|
onBlur={(event) =>
|
||||||
|
void handleSettingChange(
|
||||||
|
'subscriptionCheckIntervalHours',
|
||||||
|
clampSubscriptionInterval(event.target.value)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<ItemSeparator />
|
||||||
|
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||||
@@ -349,6 +384,13 @@ export function Settings() {
|
|||||||
<div className="flex gap-2 w-full max-w-md">
|
<div className="flex gap-2 w-full max-w-md">
|
||||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => void handleSettingChange('configPath', '')}
|
||||||
|
disabled={!settings.configPath}
|
||||||
|
>
|
||||||
|
{t('settings.clearConfigFile')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
@@ -423,6 +465,21 @@ export function Settings() {
|
|||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Item variant="muted">
|
||||||
|
<ItemContent>
|
||||||
|
<ItemTitle>{t('settings.enableAnalytics')}</ItemTitle>
|
||||||
|
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions>
|
||||||
|
<Switch
|
||||||
|
checked={settings.enableAnalytics}
|
||||||
|
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
||||||
|
/>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
665
src/renderer/src/pages/Subscriptions.tsx
Normal file
665
src/renderer/src/pages/Subscriptions.tsx
Normal file
@@ -0,0 +1,665 @@
|
|||||||
|
import {
|
||||||
|
type SubscriptionFormData,
|
||||||
|
SubscriptionFormDialog
|
||||||
|
} from '@renderer/components/subscription/SubscriptionFormDialog'
|
||||||
|
import { Badge } from '@renderer/components/ui/badge'
|
||||||
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '@renderer/components/ui/card'
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuTrigger
|
||||||
|
} from '@renderer/components/ui/context-menu'
|
||||||
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card'
|
||||||
|
import { RemoteImage } from '@renderer/components/ui/remote-image'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
|
||||||
|
import { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
import { type DownloadRecord, downloadsArrayAtom } from '@renderer/store/downloads'
|
||||||
|
import {
|
||||||
|
createSubscriptionAtom,
|
||||||
|
refreshSubscriptionAtom,
|
||||||
|
removeSubscriptionAtom,
|
||||||
|
resolveFeedAtom,
|
||||||
|
subscriptionsAtom,
|
||||||
|
updateSubscriptionAtom
|
||||||
|
} from '@renderer/store/subscriptions'
|
||||||
|
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||||
|
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const statusStyles: Record<
|
||||||
|
SubscriptionRule['status'],
|
||||||
|
{ dotClass: string; textClass: string; label: string }
|
||||||
|
> = {
|
||||||
|
'up-to-date': {
|
||||||
|
dotClass: 'bg-emerald-500',
|
||||||
|
textClass: 'text-emerald-600',
|
||||||
|
label: 'subscriptions.status.up-to-date'
|
||||||
|
},
|
||||||
|
checking: {
|
||||||
|
dotClass: 'bg-sky-500',
|
||||||
|
textClass: 'text-sky-600',
|
||||||
|
label: 'subscriptions.status.checking'
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
dotClass: 'bg-red-500',
|
||||||
|
textClass: 'text-red-600',
|
||||||
|
label: 'subscriptions.status.failed'
|
||||||
|
},
|
||||||
|
idle: {
|
||||||
|
dotClass: 'bg-muted-foreground',
|
||||||
|
textClass: 'text-muted-foreground',
|
||||||
|
label: 'subscriptions.status.idle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledStatusStyle = {
|
||||||
|
dotClass: 'bg-zinc-400',
|
||||||
|
textClass: 'text-muted-foreground',
|
||||||
|
label: 'subscriptions.fields.disabled'
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionItemStatus = DownloadStatus | 'queued' | 'notQueued'
|
||||||
|
|
||||||
|
const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
|
||||||
|
notQueued: 'subscriptions.items.status.notQueued',
|
||||||
|
queued: 'subscriptions.items.status.queued',
|
||||||
|
pending: 'subscriptions.items.status.pending',
|
||||||
|
downloading: 'subscriptions.items.status.downloading',
|
||||||
|
processing: 'subscriptions.items.status.processing',
|
||||||
|
completed: 'subscriptions.items.status.completed',
|
||||||
|
error: 'subscriptions.items.status.error',
|
||||||
|
cancelled: 'subscriptions.items.status.cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubscriptionTab({
|
||||||
|
subscription,
|
||||||
|
onRefresh,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
isActive
|
||||||
|
}: SubscriptionTabProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const isDisabled = !subscription.enabled
|
||||||
|
const statusMeta = isDisabled ? disabledStatusStyle : statusStyles[subscription.status]
|
||||||
|
const statusDescription =
|
||||||
|
subscription.status === 'failed' && subscription.lastError
|
||||||
|
? subscription.lastError
|
||||||
|
: t(statusStyles[subscription.status].label)
|
||||||
|
const lastUpdatedTimestamp =
|
||||||
|
subscription.lastCheckedAt ?? subscription.updatedAt ?? subscription.createdAt ?? null
|
||||||
|
const lastUpdatedLabel = lastUpdatedTimestamp
|
||||||
|
? dayjs(lastUpdatedTimestamp).format('YYYY-MM-DD HH:mm')
|
||||||
|
: t('subscriptions.never')
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (checked: boolean) => {
|
||||||
|
await onUpdate({ enabled: checked })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await onRefresh()
|
||||||
|
toast.success(t('subscriptions.notifications.refreshStarted'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
await onRemove()
|
||||||
|
toast.success(t('subscriptions.notifications.removed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setEditOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContextMenu>
|
||||||
|
<HoverCard openDelay={0} closeDelay={0}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<TabsTrigger
|
||||||
|
value={subscription.id}
|
||||||
|
className={cn(
|
||||||
|
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
|
||||||
|
isActive && 'bg-muted/45'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative h-12 w-12 shrink-0 overflow-hidden transition-colors">
|
||||||
|
<RemoteImage
|
||||||
|
src={subscription.coverUrl}
|
||||||
|
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||||
|
className="h-full w-full object-cover rounded-full overflow-hidden"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full border-2 border-background transition-colors',
|
||||||
|
statusMeta.dotClass
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center text-center">
|
||||||
|
<span className="w-full truncate text-xs font-medium">
|
||||||
|
{subscription.title || t('subscriptions.labels.unknown')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TabsTrigger>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<HoverCardContent className="max-w-xs space-y-1">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{subscription.title || t('subscriptions.labels.unknown')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">{statusDescription}</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
{t('subscriptions.status.tooltip.updatedAt', { time: lastUpdatedLabel })}
|
||||||
|
</p>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem onClick={handleRefresh}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
{t('subscriptions.actions.refresh')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleEdit}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
{t('subscriptions.actions.edit')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
{subscription.enabled
|
||||||
|
? t('subscriptions.actions.disable')
|
||||||
|
: t('subscriptions.actions.enable')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{t('subscriptions.actions.remove')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<SubscriptionFormDialog
|
||||||
|
mode="edit"
|
||||||
|
subscription={subscription}
|
||||||
|
open={editOpen}
|
||||||
|
onSave={async (data) => {
|
||||||
|
await onUpdate(data)
|
||||||
|
toast.success(t('subscriptions.notifications.updated'))
|
||||||
|
setEditOpen(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setEditOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subscriptions() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [subscriptions] = useAtom(subscriptionsAtom)
|
||||||
|
const updateSubscription = useSetAtom(updateSubscriptionAtom)
|
||||||
|
const removeSubscription = useSetAtom(removeSubscriptionAtom)
|
||||||
|
const refreshSubscription = useSetAtom(refreshSubscriptionAtom)
|
||||||
|
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string>('')
|
||||||
|
|
||||||
|
const sortedSubscriptions = useMemo(
|
||||||
|
() =>
|
||||||
|
[...subscriptions].sort(
|
||||||
|
(a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)
|
||||||
|
),
|
||||||
|
[subscriptions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolveFeed = useSetAtom(resolveFeedAtom)
|
||||||
|
const handleUpdateSubscription = useCallback(
|
||||||
|
async (id: string, data: SubscriptionRuleUpdateForm) => {
|
||||||
|
const updatePayload: Parameters<typeof updateSubscription>[0]['data'] = {
|
||||||
|
keywords: data.keywords,
|
||||||
|
tags: data.tags,
|
||||||
|
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||||
|
downloadDirectory: data.downloadDirectory,
|
||||||
|
namingTemplate: data.namingTemplate,
|
||||||
|
enabled: data.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform
|
||||||
|
if (data.url) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveFeed(data.url)
|
||||||
|
updatePayload.sourceUrl = resolved.sourceUrl
|
||||||
|
updatePayload.feedUrl = resolved.feedUrl
|
||||||
|
updatePayload.platform = resolved.platform
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve feed URL:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.resolveError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSubscription({ id, data: updatePayload })
|
||||||
|
await refreshSubscription(id)
|
||||||
|
},
|
||||||
|
[refreshSubscription, updateSubscription, resolveFeed, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createSubscription = useSetAtom(createSubscriptionAtom)
|
||||||
|
|
||||||
|
const handleCreateSubscription = useCallback(
|
||||||
|
async (data: SubscriptionFormData) => {
|
||||||
|
if (!data.url) {
|
||||||
|
toast.error(t('subscriptions.notifications.missingUrl'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createSubscription({
|
||||||
|
url: data.url,
|
||||||
|
keywords: data.keywords?.join(', '),
|
||||||
|
tags: data.tags?.join(', '),
|
||||||
|
onlyDownloadLatest: data.onlyDownloadLatest,
|
||||||
|
downloadDirectory: data.downloadDirectory,
|
||||||
|
namingTemplate: data.namingTemplate,
|
||||||
|
enabled: data.enabled
|
||||||
|
})
|
||||||
|
toast.success(t('subscriptions.notifications.created'))
|
||||||
|
setAddDialogOpen(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create subscription:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.createError'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createSubscription, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleOpenRSSHubDocs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open RSSHub documentation:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
// Filter subscriptions based on selected tab
|
||||||
|
const displayedSubscriptions = useMemo(() => {
|
||||||
|
if (!selectedTab) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return sortedSubscriptions.filter((sub) => sub.id === selectedTab)
|
||||||
|
}, [selectedTab, sortedSubscriptions])
|
||||||
|
|
||||||
|
// Set default tab to first subscription if available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedTab && sortedSubscriptions.length > 0) {
|
||||||
|
// Set to first subscription if no tab is selected
|
||||||
|
setSelectedTab(sortedSubscriptions[0].id)
|
||||||
|
} else if (selectedTab && !sortedSubscriptions.find((s) => s.id === selectedTab)) {
|
||||||
|
// If selected subscription no longer exists, switch to first available
|
||||||
|
if (sortedSubscriptions.length > 0) {
|
||||||
|
setSelectedTab(sortedSubscriptions[0].id)
|
||||||
|
} else {
|
||||||
|
setSelectedTab('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedTab, sortedSubscriptions])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Channel Tabs Header */}
|
||||||
|
<div className="">
|
||||||
|
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||||
|
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-auto">
|
||||||
|
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0 px-6">
|
||||||
|
{/* Subscription Channel Tabs */}
|
||||||
|
{sortedSubscriptions.map((subscription) => (
|
||||||
|
<SubscriptionTab
|
||||||
|
key={subscription.id}
|
||||||
|
subscription={subscription}
|
||||||
|
isActive={subscription.id === selectedTab}
|
||||||
|
onRefresh={() => refreshSubscription(subscription.id)}
|
||||||
|
onRemove={() => removeSubscription(subscription.id)}
|
||||||
|
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Add RSS Button */}
|
||||||
|
<Button
|
||||||
|
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setAddDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
|
||||||
|
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col items-center text-center">
|
||||||
|
<span className="w-full truncate text-xs font-medium">
|
||||||
|
{t('subscriptions.add.title')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="relative space-y-8 p-6">
|
||||||
|
<section className="space-y-4">
|
||||||
|
{sortedSubscriptions.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{t('subscriptions.empty')}
|
||||||
|
</div>
|
||||||
|
) : !selectedTab ? (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{t('subscriptions.empty')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{displayedSubscriptions.map((subscription) => (
|
||||||
|
<SubscriptionCard key={subscription.id} subscription={subscription} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* RSSHub Info Card */}
|
||||||
|
<Card className="border-primary/20 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{t('subscriptions.rssHub.title')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void handleOpenRSSHubDocs()}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{t('subscriptions.rssHub.openDocs')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubscriptionFormDialog
|
||||||
|
mode="add"
|
||||||
|
open={addDialogOpen}
|
||||||
|
onSave={handleCreateSubscription}
|
||||||
|
onClose={() => setAddDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubscriptionTabProps {
|
||||||
|
subscription: SubscriptionRule
|
||||||
|
isActive: boolean
|
||||||
|
onRefresh: () => Promise<void>
|
||||||
|
onRemove: () => Promise<void>
|
||||||
|
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionRuleUpdateForm = SubscriptionFormData
|
||||||
|
|
||||||
|
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
|
||||||
|
const downloads = useAtomValue(downloadsArrayAtom)
|
||||||
|
const [historyStatusMap, setHistoryStatusMap] = useState<Record<string, DownloadStatus | null>>(
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
const downloadLookup = useMemo(() => {
|
||||||
|
const map = new Map<string, DownloadRecord>()
|
||||||
|
downloads.forEach((record) => {
|
||||||
|
map.set(record.id, record)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [downloads])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const queuedDownloadIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
feedItems
|
||||||
|
.filter((item) => item.addedToQueue && item.downloadId)
|
||||||
|
.map((item) => item.downloadId as string)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const missingIds = queuedDownloadIds.filter(
|
||||||
|
(downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
if (missingIds.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const fetchHistoryStatuses = async () => {
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
missingIds.map(async (downloadId) => {
|
||||||
|
try {
|
||||||
|
const historyItem = await ipcServices.history.getHistoryById(downloadId)
|
||||||
|
return { downloadId, status: historyItem?.status ?? null }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch download history entry:', error)
|
||||||
|
return { downloadId, status: null }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHistoryStatusMap((prev) => {
|
||||||
|
let changed = false
|
||||||
|
const next = { ...prev }
|
||||||
|
|
||||||
|
for (const { downloadId, status } of results) {
|
||||||
|
if (next[downloadId] === status) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[downloadId] = status
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve download history statuses:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchHistoryStatuses()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [feedItems, downloadLookup, historyStatusMap])
|
||||||
|
|
||||||
|
const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {
|
||||||
|
if (!item.addedToQueue) {
|
||||||
|
return 'notQueued'
|
||||||
|
}
|
||||||
|
if (!item.downloadId) {
|
||||||
|
return 'queued'
|
||||||
|
}
|
||||||
|
const matchedDownload = downloadLookup.get(item.downloadId)
|
||||||
|
if (!matchedDownload) {
|
||||||
|
const cachedHistoryStatus = historyStatusMap[item.downloadId]
|
||||||
|
if (cachedHistoryStatus) {
|
||||||
|
return cachedHistoryStatus
|
||||||
|
}
|
||||||
|
return 'queued'
|
||||||
|
}
|
||||||
|
return matchedDownload.status
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenItem = async (url: string) => {
|
||||||
|
try {
|
||||||
|
await ipcServices.fs.openExternal(url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open subscription item link:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQueueItem = useCallback(
|
||||||
|
async (item: SubscriptionFeedItem) => {
|
||||||
|
if (item.addedToQueue) {
|
||||||
|
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const queued = await ipcServices.subscriptions.queueItem(subscription.id, item.id)
|
||||||
|
if (queued) {
|
||||||
|
toast.success(t('subscriptions.notifications.itemQueued'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.info(t('subscriptions.notifications.itemAlreadyQueued'))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to queue subscription item:', error)
|
||||||
|
toast.error(t('subscriptions.notifications.queueError'))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[subscription.id, t]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (feedItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
{t('subscriptions.items.empty')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{feedItems.map((item) => {
|
||||||
|
const itemStatus = resolveItemStatus(item)
|
||||||
|
const hasResolvedDownloadStatus =
|
||||||
|
item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued'
|
||||||
|
const badgeLabel = item.addedToQueue
|
||||||
|
? t('subscriptions.items.status.queued')
|
||||||
|
: t('subscriptions.items.status.notQueued')
|
||||||
|
const tooltipLabel = item.addedToQueue
|
||||||
|
? hasResolvedDownloadStatus
|
||||||
|
? t('subscriptions.items.tooltip.downloadStatus', {
|
||||||
|
status: t(subscriptionItemStatusLabels[itemStatus])
|
||||||
|
})
|
||||||
|
: t('subscriptions.items.tooltip.downloadPending')
|
||||||
|
: t('subscriptions.items.tooltip.notQueued')
|
||||||
|
const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
|
||||||
|
return (
|
||||||
|
<ContextMenu key={`${subscription.id}-${item.id}`}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<article className="group transition-all">
|
||||||
|
<div className="relative w-full overflow-hidden bg-muted aspect-video rounded-2xl">
|
||||||
|
{item.thumbnail ? (
|
||||||
|
<RemoteImage
|
||||||
|
src={item.thumbnail}
|
||||||
|
alt={item.title}
|
||||||
|
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t('subscriptions.labels.noThumbnail')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/70 via-black/5 to-transparent" />
|
||||||
|
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
|
||||||
|
{subscription.coverUrl ? (
|
||||||
|
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
|
||||||
|
<RemoteImage
|
||||||
|
src={subscription.coverUrl}
|
||||||
|
alt={subscription.title || t('subscriptions.labels.unknown')}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
|
||||||
|
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="max-w-40 truncate text-xs">
|
||||||
|
{subscription.title || t('subscriptions.labels.unknown')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
|
||||||
|
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
|
||||||
|
badgeClass
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{tooltipLabel}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p
|
||||||
|
className="text-base font-semibold leading-snug text-card-foreground"
|
||||||
|
title={item.title}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-full px-4"
|
||||||
|
onClick={() => void handleOpenItem(item.url)}
|
||||||
|
title={t('subscriptions.items.actions.open')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => void handleQueueItem(item)}
|
||||||
|
disabled={item.addedToQueue}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t('subscriptions.items.actions.queue')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => void handleOpenItem(item.url)}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
{t('subscriptions.items.actions.open')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,9 +28,6 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
|||||||
speed: undefined,
|
speed: undefined,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
fileSize: item.fileSize,
|
fileSize: item.fileSize,
|
||||||
format: item.format,
|
|
||||||
quality: item.quality,
|
|
||||||
codec: item.codec,
|
|
||||||
createdAt: item.downloadedAt,
|
createdAt: item.downloadedAt,
|
||||||
startedAt: item.downloadedAt,
|
startedAt: item.downloadedAt,
|
||||||
completedAt: item.completedAt ?? item.downloadedAt,
|
completedAt: item.completedAt ?? item.downloadedAt,
|
||||||
|
|||||||
79
src/renderer/src/store/subscriptions.ts
Normal file
79
src/renderer/src/store/subscriptions.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type {
|
||||||
|
SubscriptionResolvedFeed,
|
||||||
|
SubscriptionRule,
|
||||||
|
SubscriptionUpdatePayload
|
||||||
|
} from '@shared/types'
|
||||||
|
import { atom } from 'jotai'
|
||||||
|
import { ipcServices } from '../lib/ipc'
|
||||||
|
|
||||||
|
const normalizeCommaList = (value?: string): string[] => {
|
||||||
|
if (!value) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscriptionsAtom = atom<SubscriptionRule[]>([])
|
||||||
|
|
||||||
|
export const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => {
|
||||||
|
set(subscriptionsAtom, subscriptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loadSubscriptionsAtom = atom(null, async (_get, set) => {
|
||||||
|
try {
|
||||||
|
const subscriptions = await ipcServices.subscriptions.list()
|
||||||
|
set(subscriptionsAtom, subscriptions)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load subscriptions:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface CreateSubscriptionForm {
|
||||||
|
url: string
|
||||||
|
keywords?: string
|
||||||
|
tags?: string
|
||||||
|
onlyDownloadLatest?: boolean
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSubscriptionAtom = atom(
|
||||||
|
null,
|
||||||
|
async (_get, _set, payload: CreateSubscriptionForm) => {
|
||||||
|
await ipcServices.subscriptions.create({
|
||||||
|
url: payload.url,
|
||||||
|
keywords: normalizeCommaList(payload.keywords),
|
||||||
|
tags: normalizeCommaList(payload.tags),
|
||||||
|
onlyDownloadLatest: payload.onlyDownloadLatest,
|
||||||
|
downloadDirectory: payload.downloadDirectory,
|
||||||
|
namingTemplate: payload.namingTemplate,
|
||||||
|
enabled: payload.enabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const updateSubscriptionAtom = atom(
|
||||||
|
null,
|
||||||
|
async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => {
|
||||||
|
await ipcServices.subscriptions.update(update.id, update.data)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => {
|
||||||
|
await ipcServices.subscriptions.remove(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => {
|
||||||
|
await ipcServices.subscriptions.refresh(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resolveFeedAtom = atom(
|
||||||
|
null,
|
||||||
|
async (_get, _set, url: string): Promise<SubscriptionResolvedFeed> => {
|
||||||
|
return ipcServices.subscriptions.resolve(url)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -62,9 +62,6 @@ export interface DownloadItem {
|
|||||||
// Enhanced video information
|
// Enhanced video information
|
||||||
duration?: number
|
duration?: number
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
format?: string
|
|
||||||
quality?: string
|
|
||||||
codec?: string
|
|
||||||
savedFileName?: string
|
savedFileName?: string
|
||||||
// Timestamps
|
// Timestamps
|
||||||
createdAt: number
|
createdAt: number
|
||||||
@@ -76,6 +73,8 @@ export interface DownloadItem {
|
|||||||
uploader?: string
|
uploader?: string
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
origin?: 'manual' | 'subscription'
|
||||||
|
subscriptionId?: string
|
||||||
// Download-specific format info
|
// Download-specific format info
|
||||||
selectedFormat?: VideoFormat
|
selectedFormat?: VideoFormat
|
||||||
// Playlist context (optional)
|
// Playlist context (optional)
|
||||||
@@ -85,6 +84,16 @@ export interface DownloadItem {
|
|||||||
playlistSize?: number
|
playlistSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionFeedItem {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
title: string
|
||||||
|
publishedAt: number
|
||||||
|
thumbnail?: string
|
||||||
|
addedToQueue: boolean
|
||||||
|
downloadId?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadHistoryItem {
|
export interface DownloadHistoryItem {
|
||||||
id: string
|
id: string
|
||||||
url: string
|
url: string
|
||||||
@@ -99,16 +108,14 @@ export interface DownloadHistoryItem {
|
|||||||
downloadedAt: number
|
downloadedAt: number
|
||||||
completedAt?: number
|
completedAt?: number
|
||||||
error?: string
|
error?: string
|
||||||
// Enhanced video information
|
|
||||||
format?: string
|
|
||||||
quality?: string
|
|
||||||
codec?: string
|
|
||||||
// Additional metadata
|
// Additional metadata
|
||||||
description?: string
|
description?: string
|
||||||
channel?: string
|
channel?: string
|
||||||
uploader?: string
|
uploader?: string
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
origin?: 'manual' | 'subscription'
|
||||||
|
subscriptionId?: string
|
||||||
// Download-specific format info
|
// Download-specific format info
|
||||||
selectedFormat?: VideoFormat
|
selectedFormat?: VideoFormat
|
||||||
// Playlist context (optional)
|
// Playlist context (optional)
|
||||||
@@ -128,6 +135,11 @@ export interface DownloadOptions {
|
|||||||
startTime?: string
|
startTime?: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
downloadSubs?: boolean
|
downloadSubs?: boolean
|
||||||
|
customDownloadPath?: string
|
||||||
|
customFilenameTemplate?: string
|
||||||
|
tags?: string[]
|
||||||
|
origin?: 'manual' | 'subscription'
|
||||||
|
subscriptionId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistEntry {
|
export interface PlaylistEntry {
|
||||||
@@ -173,6 +185,67 @@ export interface PlaylistDownloadResult {
|
|||||||
entries: PlaylistDownloadEntry[]
|
entries: PlaylistDownloadEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription types
|
||||||
|
export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'
|
||||||
|
|
||||||
|
export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'
|
||||||
|
|
||||||
|
export interface SubscriptionRule {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sourceUrl: string
|
||||||
|
feedUrl: string
|
||||||
|
platform: SubscriptionPlatform
|
||||||
|
keywords: string[]
|
||||||
|
tags: string[]
|
||||||
|
onlyDownloadLatest: boolean
|
||||||
|
enabled: boolean
|
||||||
|
coverUrl?: string
|
||||||
|
latestVideoTitle?: string
|
||||||
|
latestVideoPublishedAt?: number
|
||||||
|
lastCheckedAt?: number
|
||||||
|
lastSuccessAt?: number
|
||||||
|
status: SubscriptionStatus
|
||||||
|
lastError?: string
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
items: SubscriptionFeedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionResolvedFeed {
|
||||||
|
sourceUrl: string
|
||||||
|
feedUrl: string
|
||||||
|
platform: SubscriptionPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionCreatePayload {
|
||||||
|
sourceUrl: string
|
||||||
|
feedUrl: string
|
||||||
|
platform: SubscriptionPlatform
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
onlyDownloadLatest?: boolean
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionUpdatePayload {
|
||||||
|
title?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
feedUrl?: string
|
||||||
|
platform?: SubscriptionPlatform
|
||||||
|
keywords?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
onlyDownloadLatest?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
downloadDirectory?: string
|
||||||
|
namingTemplate?: string
|
||||||
|
items?: SubscriptionFeedItem[]
|
||||||
|
}
|
||||||
|
|
||||||
// Settings types
|
// Settings types
|
||||||
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||||
|
|
||||||
@@ -193,6 +266,10 @@ export interface AppSettings {
|
|||||||
closeToTray: boolean
|
closeToTray: boolean
|
||||||
hideDockIcon: boolean
|
hideDockIcon: boolean
|
||||||
autoUpdate: boolean
|
autoUpdate: boolean
|
||||||
|
subscriptionFilenameTemplate: string
|
||||||
|
subscriptionOnlyLatestDefault: boolean
|
||||||
|
subscriptionCheckIntervalHours: number
|
||||||
|
enableAnalytics: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultSettings: AppSettings = {
|
export const defaultSettings: AppSettings = {
|
||||||
@@ -211,5 +288,9 @@ export const defaultSettings: AppSettings = {
|
|||||||
oneClickQuality: 'best',
|
oneClickQuality: 'best',
|
||||||
closeToTray: false,
|
closeToTray: false,
|
||||||
hideDockIcon: false,
|
hideDockIcon: false,
|
||||||
autoUpdate: true
|
autoUpdate: true,
|
||||||
|
subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s',
|
||||||
|
subscriptionOnlyLatestDefault: true,
|
||||||
|
subscriptionCheckIntervalHours: 3,
|
||||||
|
enableAnalytics: true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user