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: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",
|
||||
"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": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
@@ -27,8 +28,10 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@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-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
@@ -58,9 +61,11 @@
|
||||
"react-hook-form": "^7.63.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"react-router": "^7.9.4",
|
||||
"rss-parser": "^3.13.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"yt-dlp-wrap-plus": "^2.3.20",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
@@ -73,6 +78,7 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"bumpp": "^10.3.1",
|
||||
"drizzle-kit": "^0.31.7",
|
||||
"electron": "^38.1.2",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^4.0.1",
|
||||
|
||||
393
pnpm-lock.yaml
generated
393
pnpm-lock.yaml
generated
@@ -23,12 +23,18 @@ importers:
|
||||
'@radix-ui/react-checkbox':
|
||||
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)
|
||||
'@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':
|
||||
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-dropdown-menu':
|
||||
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)
|
||||
'@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':
|
||||
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)
|
||||
@@ -116,6 +122,9 @@ importers:
|
||||
react-router:
|
||||
specifier: ^7.9.4
|
||||
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:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
@@ -125,6 +134,9 @@ importers:
|
||||
tailwindcss:
|
||||
specifier: ^4.1.13
|
||||
version: 4.1.15
|
||||
tw-animate-css:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
yt-dlp-wrap-plus:
|
||||
specifier: ^2.3.20
|
||||
version: 2.3.20
|
||||
@@ -156,6 +168,9 @@ importers:
|
||||
bumpp:
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.7
|
||||
version: 0.31.7
|
||||
electron:
|
||||
specifier: ^38.1.2
|
||||
version: 38.3.0
|
||||
@@ -342,6 +357,9 @@ packages:
|
||||
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
|
||||
engines: {node: '>= 8.9.0'}
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
'@electron-toolkit/preload@3.0.2':
|
||||
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
|
||||
peerDependencies:
|
||||
@@ -384,102 +402,206 @@ packages:
|
||||
resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==}
|
||||
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':
|
||||
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
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':
|
||||
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
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':
|
||||
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
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':
|
||||
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
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':
|
||||
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
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':
|
||||
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
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':
|
||||
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
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':
|
||||
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
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':
|
||||
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
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':
|
||||
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -492,6 +614,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -504,6 +632,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -516,24 +650,48 @@ packages:
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
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':
|
||||
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
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':
|
||||
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
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':
|
||||
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -701,6 +859,19 @@ packages:
|
||||
'@types/react':
|
||||
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':
|
||||
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||
peerDependencies:
|
||||
@@ -780,6 +951,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
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':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
@@ -1896,6 +2080,10 @@ packages:
|
||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
drizzle-kit@0.31.7:
|
||||
resolution: {integrity: sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A==}
|
||||
hasBin: true
|
||||
|
||||
drizzle-orm@0.44.7:
|
||||
resolution: {integrity: sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ==}
|
||||
peerDependencies:
|
||||
@@ -2063,6 +2251,9 @@ packages:
|
||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@2.2.0:
|
||||
resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
@@ -2100,6 +2291,16 @@ packages:
|
||||
es6-error@4.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2233,6 +2434,9 @@ packages:
|
||||
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
giget@2.0.0:
|
||||
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
|
||||
hasBin: true
|
||||
@@ -3063,6 +3267,9 @@ packages:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
responselike@2.0.1:
|
||||
resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==}
|
||||
|
||||
@@ -3088,6 +3295,9 @@ packages:
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
rss-parser@3.13.0:
|
||||
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
@@ -3294,6 +3504,9 @@ packages:
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
type-fest@0.13.1:
|
||||
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3471,6 +3684,14 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
|
||||
engines: {node: '>=8.0'}
|
||||
@@ -3685,6 +3906,8 @@ snapshots:
|
||||
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)':
|
||||
dependencies:
|
||||
electron: 38.3.0
|
||||
@@ -3768,81 +3991,157 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.11':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.18.20':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.11':
|
||||
optional: true
|
||||
|
||||
@@ -4030,6 +4329,20 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
@@ -4109,6 +4422,23 @@ snapshots:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@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: {}
|
||||
|
||||
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):
|
||||
optionalDependencies:
|
||||
better-sqlite3: 12.4.1
|
||||
@@ -5407,6 +5746,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.0
|
||||
|
||||
entities@2.2.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
@@ -5437,6 +5778,38 @@ snapshots:
|
||||
es6-error@4.1.1:
|
||||
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:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.11
|
||||
@@ -5602,6 +5975,10 @@ snapshots:
|
||||
dependencies:
|
||||
pump: 3.0.3
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
giget@2.0.0:
|
||||
dependencies:
|
||||
citty: 0.1.6
|
||||
@@ -6404,6 +6781,8 @@ snapshots:
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
responselike@2.0.1:
|
||||
dependencies:
|
||||
lowercase-keys: 2.0.0
|
||||
@@ -6457,6 +6836,11 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||
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.2.1: {}
|
||||
@@ -6665,6 +7049,8 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
type-fest@0.13.1:
|
||||
optional: true
|
||||
|
||||
@@ -6794,6 +7180,13 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
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 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 => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
@@ -80,7 +86,12 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Add options for better filename handling
|
||||
|
||||
@@ -8,6 +8,8 @@ import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
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 { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
@@ -22,6 +24,10 @@ configureLogger()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
@@ -80,6 +86,10 @@ export function createWindow(): void {
|
||||
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
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -214,6 +224,8 @@ app.whenReady().then(async () => {
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
app.on('activate', () => {
|
||||
// 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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DownloadService } from './services/download-service'
|
||||
import { FileSystemService } from './services/file-system-service'
|
||||
import { HistoryService } from './services/history-service'
|
||||
import { SettingsService } from './services/settings-service'
|
||||
import { SubscriptionService } from './services/subscription-service'
|
||||
import { ThumbnailService } from './services/thumbnail-service'
|
||||
import { UpdateService } from './services/update-service'
|
||||
import { WindowService } from './services/window-service'
|
||||
@@ -15,6 +16,7 @@ export const services = createServices([
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
SettingsService,
|
||||
SubscriptionService,
|
||||
ThumbnailService,
|
||||
UpdateService,
|
||||
WindowService
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
@@ -9,12 +11,20 @@ class SettingsService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
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()
|
||||
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') {
|
||||
updateTrayMenu()
|
||||
@@ -23,15 +33,30 @@ class SettingsService extends IpcService {
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAll(_context: IpcContext): AppSettings {
|
||||
return settingsManager.getAll()
|
||||
const settings = settingsManager.getAll()
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
settingsManager.setAll(settings)
|
||||
|
||||
if (settings.language) {
|
||||
@@ -41,12 +66,17 @@ class SettingsService extends IpcService {
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
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 settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -402,7 +404,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
@@ -411,7 +416,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
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)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
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
|
||||
if (process.platform === 'win32') {
|
||||
@@ -430,9 +439,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let actualQuality: string | null = null
|
||||
let actualCodec: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -444,20 +452,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
if (selectedFormat) {
|
||||
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, {
|
||||
@@ -502,18 +496,6 @@ class DownloadEngine extends EventEmitter {
|
||||
selectedFormat = candidate
|
||||
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, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
@@ -521,7 +503,39 @@ class DownloadEngine extends EventEmitter {
|
||||
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
|
||||
const formatSelector =
|
||||
@@ -629,16 +643,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (extMatch && !actualFormat) {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
@@ -675,32 +683,44 @@ class DownloadEngine extends EventEmitter {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fileName = `${sanitizedTitle}.${extension}`
|
||||
const finalOutputPath = path.join(downloadPath, fileName)
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Generated file path for ID:',
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = finalOutputPath
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
// Try to find the actual file - yt-dlp may generate files with slightly different names
|
||||
const stats = await fs.stat(finalOutputPath)
|
||||
fileSize = stats.size
|
||||
actualFilePath = finalOutputPath
|
||||
} catch (error) {
|
||||
// If the expected file doesn't exist, try to find it by scanning the directory
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const files = await fs.readdir(downloadPath)
|
||||
// Look for files matching the title pattern with the correct extension
|
||||
let located = false
|
||||
for (const candidate of candidatePaths) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(candidate)
|
||||
fileSize = stats.size
|
||||
actualFilePath = candidate
|
||||
located = true
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!located) {
|
||||
const files = await fs.readdir(resolvedDownloadPath)
|
||||
const matchingFiles = files.filter((file) => {
|
||||
const baseName = file.replace(/\.[^.]+$/, '')
|
||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||
@@ -711,30 +731,33 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
// Use the most recently modified file if multiple matches
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(downloadPath, file)
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
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]
|
||||
actualFilePath = mostRecent.path
|
||||
fileSize = mostRecent.size
|
||||
located = true
|
||||
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)
|
||||
} 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',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined,
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
@@ -837,15 +857,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.fileSize !== undefined) {
|
||||
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) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
@@ -873,6 +884,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -882,9 +896,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
@@ -913,14 +924,13 @@ class DownloadEngine extends EventEmitter {
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
format: completedDownload?.item.format,
|
||||
quality: completedDownload?.item.quality,
|
||||
codec: completedDownload?.item.codec,
|
||||
description: completedDownload?.item.description,
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
viewCount: completedDownload?.item.viewCount,
|
||||
tags: completedDownload?.item.tags,
|
||||
origin: completedDownload?.item.origin,
|
||||
subscriptionId: completedDownload?.item.subscriptionId,
|
||||
playlistId: completedDownload?.item.playlistId,
|
||||
playlistTitle: completedDownload?.item.playlistTitle,
|
||||
playlistIndex: completedDownload?.item.playlistIndex,
|
||||
@@ -934,6 +944,8 @@ class DownloadEngine extends EventEmitter {
|
||||
updates: Partial<DownloadHistoryItem>
|
||||
): void {
|
||||
const existing = historyManager.getHistoryById(id)
|
||||
const resolvedDownloadPath =
|
||||
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||
const base: DownloadHistoryItem = existing ?? {
|
||||
id,
|
||||
url: options.url,
|
||||
@@ -941,21 +953,20 @@ class DownloadEngine extends EventEmitter {
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
format: updates.format,
|
||||
quality: updates.quality,
|
||||
codec: updates.codec,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
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
|
||||
selectedFormat: updates.selectedFormat,
|
||||
playlistId: updates.playlistId,
|
||||
@@ -972,7 +983,11 @@ class DownloadEngine extends EventEmitter {
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
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)
|
||||
|
||||
@@ -1,18 +1,149 @@
|
||||
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { Database as BetterSqlite3Instance } 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 { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
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 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(),
|
||||
status: text('status').notNull(),
|
||||
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
||||
@@ -20,14 +151,12 @@ const downloadHistoryTable = sqliteTable('download_history', {
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
payload: text('payload').notNull()
|
||||
})
|
||||
|
||||
type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
|
||||
type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
|
||||
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
|
||||
|
||||
class HistoryManager {
|
||||
private sqlite: BetterSqlite3Instance | null = null
|
||||
private db: BetterSQLite3Database | null = null
|
||||
private history: Map<string, DownloadHistoryItem> = new Map()
|
||||
private schemaChecked = false
|
||||
private migrationChecked = false
|
||||
|
||||
constructor() {
|
||||
@@ -37,6 +166,7 @@ class HistoryManager {
|
||||
private initialize(): void {
|
||||
try {
|
||||
this.getDatabase()
|
||||
this.ensureStructuredSchema()
|
||||
this.ensureLegacyMigration()
|
||||
this.loadHistoryFromDatabase()
|
||||
} catch (error) {
|
||||
@@ -51,35 +181,125 @@ class HistoryManager {
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
|
||||
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
this.sqlite.pragma('journal_mode = WAL')
|
||||
this.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()
|
||||
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
|
||||
this.db = drizzle(this.sqlite)
|
||||
const database = drizzle(sqlite)
|
||||
runMigrations(database)
|
||||
|
||||
this.db = database
|
||||
logger.info(`history-db initialized at ${databasePath}`)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return join(app.getPath('userData'), 'download-history.sqlite')
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private getLegacyStorePath(): string {
|
||||
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 {
|
||||
if (this.migrationChecked) {
|
||||
return
|
||||
@@ -154,11 +374,31 @@ class HistoryManager {
|
||||
private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert {
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
type: item.type,
|
||||
status: item.status,
|
||||
downloadPath: item.downloadPath ?? null,
|
||||
savedFileName: item.savedFileName ?? null,
|
||||
fileSize: item.fileSize ?? null,
|
||||
duration: item.duration ?? null,
|
||||
downloadedAt: item.downloadedAt,
|
||||
completedAt: item.completedAt ?? null,
|
||||
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 {
|
||||
try {
|
||||
const parsed = JSON.parse(row.payload) as DownloadHistoryItem
|
||||
return {
|
||||
...parsed,
|
||||
status: row.status as DownloadHistoryItem['status'],
|
||||
downloadedAt: row.downloadedAt,
|
||||
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
|
||||
let selectedFormat: DownloadHistoryItem['selectedFormat']
|
||||
if (row.selectedFormat) {
|
||||
try {
|
||||
selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat']
|
||||
} catch (error) {
|
||||
logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -299,6 +557,15 @@ class HistoryManager {
|
||||
|
||||
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()
|
||||
|
||||
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 {
|
||||
try {
|
||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||
if (!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH) {
|
||||
this.store.set('downloadPath', DEFAULT_DOWNLOAD_PATH)
|
||||
return
|
||||
const normalizedDownloadPath =
|
||||
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
|
||||
? DEFAULT_DOWNLOAD_PATH
|
||||
: currentPath
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(currentPath)
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to verify download directory:', error)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>VidBee</title>
|
||||
<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>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import { Sidebar } from '@renderer/components/ui/sidebar'
|
||||
import { Toaster } from '@renderer/components/ui/sonner'
|
||||
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 { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -11,18 +12,81 @@ import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
import { Settings } from './pages/Settings'
|
||||
import { Subscriptions } from './pages/Subscriptions'
|
||||
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() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const loadSubscriptions = useSetAtom(loadSubscriptionsAtom)
|
||||
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const { t } = useTranslation()
|
||||
const autoUpdateEnabled = settings.autoUpdate
|
||||
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(() => {
|
||||
// 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:show-notification', handleUpdateNotification)
|
||||
}
|
||||
}, [autoUpdateEnabled, t])
|
||||
}, [t])
|
||||
|
||||
const renderPage = () => {
|
||||
switch (currentPage) {
|
||||
@@ -132,6 +196,8 @@ function AppContent() {
|
||||
)
|
||||
case 'settings':
|
||||
return <Settings />
|
||||
case 'subscriptions':
|
||||
return <Subscriptions />
|
||||
case 'about':
|
||||
return <About />
|
||||
case 'sites':
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@import './theme.css';
|
||||
@import "tw-animate-css";
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -8,5 +10,9 @@
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,16 +27,42 @@ import {
|
||||
} from '../../store/downloads'
|
||||
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 = (
|
||||
downloadPath: string,
|
||||
title: string,
|
||||
format: string,
|
||||
savedFileName?: string
|
||||
): string[] => {
|
||||
const candidateFileNames = savedFileName
|
||||
? [savedFileName]
|
||||
: [`${title} via VidBee.${format}`, `${title}.${format}`]
|
||||
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(
|
||||
new Set(candidateFileNames.map((fileName) => `${normalizedDownloadPath}/${fileName}`))
|
||||
)
|
||||
@@ -55,6 +81,73 @@ const tryFileOperation = async (
|
||||
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 {
|
||||
download: DownloadRecord
|
||||
}
|
||||
@@ -105,6 +198,8 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
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 showActionsWithoutHover = isHistory || download.status === 'completed'
|
||||
const actionsContainerBaseClass =
|
||||
@@ -112,6 +207,8 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const actionsContainerClass = showActionsWithoutHover
|
||||
? actionsContainerBaseClass
|
||||
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
@@ -126,7 +223,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
const formatForPath = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const formatForPath = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
download.downloadPath,
|
||||
download.title,
|
||||
@@ -148,13 +245,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
|
||||
checkFileExists()
|
||||
}, [
|
||||
download.title,
|
||||
download.downloadPath,
|
||||
download.format,
|
||||
download.savedFileName,
|
||||
download.type
|
||||
])
|
||||
}, [download.title, download.downloadPath, download.savedFileName, resolvedExtension])
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (isHistory) return
|
||||
@@ -169,7 +260,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
@@ -190,12 +281,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
}
|
||||
// Check if copy to clipboard is available
|
||||
const canCopyToClipboard = () => {
|
||||
return !!(
|
||||
download.title &&
|
||||
download.downloadPath &&
|
||||
fileExists &&
|
||||
(download.savedFileName || download.format)
|
||||
)
|
||||
return Boolean(download.title && download.downloadPath && fileExists)
|
||||
}
|
||||
|
||||
// need title, downloadPath, format
|
||||
@@ -207,10 +293,10 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
|
||||
// Type guard: these values are guaranteed to exist after canCopyToClipboard() check
|
||||
const downloadPath = download.downloadPath
|
||||
const format = download.format
|
||||
const format = resolvedExtension
|
||||
const title = download.title
|
||||
|
||||
if (!downloadPath || !format || !title) {
|
||||
if (!downloadPath || !title) {
|
||||
toast.error(t('notifications.copyFailed'))
|
||||
return
|
||||
}
|
||||
@@ -243,7 +329,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
if (!isHistory) return
|
||||
try {
|
||||
const downloadPath = download.downloadPath || settings.downloadPath
|
||||
const format = download.format || (download.type === 'audio' ? 'mp3' : 'mp4')
|
||||
const format = resolvedExtension
|
||||
const filePaths = generateFilePathCandidates(
|
||||
downloadPath,
|
||||
download.title,
|
||||
@@ -360,11 +446,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
download.selectedFormat?.filesize || download.selectedFormat?.filesize_approx
|
||||
const inlineFileSize = selectedFormatSize ? formatFileSize(selectedFormatSize) : undefined
|
||||
|
||||
const formatLabelValue = download.selectedFormat?.ext
|
||||
? download.selectedFormat.ext.toUpperCase()
|
||||
: download.format
|
||||
? download.format.toUpperCase()
|
||||
: undefined
|
||||
const formatLabelValue = getFormatLabel(download)
|
||||
|
||||
if (formatLabelValue) {
|
||||
metadataDetails.push({
|
||||
@@ -373,14 +455,12 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const qualityValue = download.selectedFormat?.height
|
||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
||||
: download.quality
|
||||
const qualityLabel = getQualityLabel(download)
|
||||
|
||||
if (qualityValue) {
|
||||
if (qualityLabel) {
|
||||
metadataDetails.push({
|
||||
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({
|
||||
label: t('download.metadata.codec'),
|
||||
value: download.codec
|
||||
value: codecValue
|
||||
})
|
||||
}
|
||||
|
||||
if (download.savedFileName) {
|
||||
if (normalizedSavedFileName || download.savedFileName) {
|
||||
metadataDetails.push({
|
||||
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({
|
||||
label: t('download.metadata.height'),
|
||||
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
|
||||
|
||||
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 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="w-full min-w-0 overflow-hidden">
|
||||
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
|
||||
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
|
||||
{download.title}
|
||||
</p>
|
||||
{isSubscriptionDownload && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{t('subscriptions.labels.subscription')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{(statusIcon || statusText) && (
|
||||
@@ -624,11 +717,9 @@ export function DownloadItem({ download }: DownloadItemProps) {
|
||||
)}
|
||||
|
||||
{/* Quality badge */}
|
||||
{(download.selectedFormat?.height || download.quality) && (
|
||||
{qualityLabel && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0.5 shrink-0">
|
||||
{download.selectedFormat?.height
|
||||
? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}`
|
||||
: download.quality}
|
||||
{qualityLabel}
|
||||
</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 { cn } from '@renderer/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { XIcon } from 'lucide-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<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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}
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
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
|
||||
)}
|
||||
{...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>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
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>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
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 MingcuteInformationFill from '~icons/mingcute/information-fill'
|
||||
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 MingcuteSettingsLine from '~icons/mingcute/settings-3-line'
|
||||
|
||||
type Page = 'home' | 'settings' | 'about' | 'sites'
|
||||
type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites'
|
||||
|
||||
interface NavigationItem {
|
||||
id: Page
|
||||
@@ -53,6 +55,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
},
|
||||
label: t('menu.download')
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
icon: {
|
||||
active: MingcuteRssFill,
|
||||
inactive: MingcuteRssLine
|
||||
},
|
||||
label: t('menu.rss')
|
||||
},
|
||||
{
|
||||
id: 'sites',
|
||||
icon: {
|
||||
@@ -102,21 +112,15 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
|
||||
{showLabel && (
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight px-3">
|
||||
{item.label}
|
||||
@@ -129,7 +133,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
|
||||
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">
|
||||
{/* 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">
|
||||
<img src="./app-icon.png" alt="VidBee" className="w-10 h-10" />
|
||||
</div>
|
||||
|
||||
@@ -190,7 +190,8 @@
|
||||
"videoCodec": "Video codec",
|
||||
"audioCodec": "Audio codec",
|
||||
"formatNote": "Format note",
|
||||
"protocol": "Protocol"
|
||||
"protocol": "Protocol",
|
||||
"subscription": "Subscription"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@@ -245,6 +246,8 @@
|
||||
"about": "About",
|
||||
"download": "Download",
|
||||
"playlist": "Download Playlist",
|
||||
"rss": "RSS",
|
||||
"subscriptions": "Subscriptions",
|
||||
"preferences": "Preferences",
|
||||
"supportedSites": "Supported Sites",
|
||||
"theme": "Theme:"
|
||||
@@ -325,6 +328,7 @@
|
||||
},
|
||||
"configFile": "Use configuration file",
|
||||
"configFileDescription": "Custom configuration file for yt-dlp",
|
||||
"clearConfigFile": "Clear",
|
||||
"dark": "Dark",
|
||||
"description": "Configure your download preferences and application settings",
|
||||
"directorySelectError": "Failed to select directory",
|
||||
@@ -336,6 +340,8 @@
|
||||
"light": "Light",
|
||||
"hideDockIcon": "Hide Dock icon",
|
||||
"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",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
@@ -360,6 +366,10 @@
|
||||
"selectPath": "Select",
|
||||
"showMoreFormats": "Show more format options",
|
||||
"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",
|
||||
"theme": "Theme",
|
||||
"themeDescription": "Choose a light, dark, or system theme for VidBee",
|
||||
@@ -370,6 +380,120 @@
|
||||
},
|
||||
"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": {
|
||||
"homeInlineDescription": "Supports {{sites}} and more.",
|
||||
"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 inputRef = useRef<HTMLInputElement>(null)
|
||||
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||
const inlinePreviewSites = popularSites
|
||||
.slice(0, 3)
|
||||
.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"
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
<Tabs defaultValue="single" className="w-full">
|
||||
<TabsList className="grid w-full 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>
|
||||
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="space-y-6">
|
||||
{/* URL Input Card */}
|
||||
{!videoInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('download.enterUrl')}</CardTitle>
|
||||
<Card>
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||
className="w-full gap-0"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{activeTab === 'single' ? t('download.enterUrl') : t('playlist.enterPlaylistUrl')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
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 && (
|
||||
{activeTab === 'single' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{t('sites.homeInlineDescription', { sites: inlinePreviewSites })}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto px-2 py-0 text-xs"
|
||||
onClick={onOpenSettings}
|
||||
className="p-0 h-auto"
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
{/* One-Click Download Info */}
|
||||
{settings.oneClickDownload && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('download.oneClickDownloadEnabled')}</span>
|
||||
</div>
|
||||
{onOpenSettings && (
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
</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>
|
||||
{playlistInfo && (
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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
|
||||
onClick={handleDownloadPlaylist}
|
||||
className="w-full sm:flex-1"
|
||||
size="lg"
|
||||
disabled={playlistDownloadLoading || !playlistUrl.trim()}
|
||||
onClick={handlePreviewPlaylist}
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
{playlistPreviewLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
{t('download.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('playlist.downloadPlaylist')
|
||||
<>
|
||||
<Search className="mr-2 h-5 w-5" />
|
||||
{t('playlist.previewButton')}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Playlist Preview Card (outside main card) */}
|
||||
{playlistInfo && (
|
||||
<PlaylistPreviewCard
|
||||
playlist={playlistInfo}
|
||||
entries={selectedPlaylistEntries}
|
||||
onClear={handleClearPlaylistPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unified Download History */}
|
||||
<UnifiedDownloadHistory />
|
||||
|
||||
@@ -26,6 +26,14 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
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() {
|
||||
const { t } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
@@ -293,6 +301,33 @@ export function Settings() {
|
||||
)}
|
||||
|
||||
<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">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||
@@ -349,6 +384,13 @@ export function Settings() {
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('configPath', '')}
|
||||
disabled={!settings.configPath}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
@@ -423,6 +465,21 @@ export function Settings() {
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</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>
|
||||
</Tabs>
|
||||
</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,
|
||||
duration: item.duration,
|
||||
fileSize: item.fileSize,
|
||||
format: item.format,
|
||||
quality: item.quality,
|
||||
codec: item.codec,
|
||||
createdAt: item.downloadedAt,
|
||||
startedAt: 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
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
savedFileName?: string
|
||||
// Timestamps
|
||||
createdAt: number
|
||||
@@ -76,6 +73,8 @@ export interface DownloadItem {
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
@@ -85,6 +84,16 @@ export interface DownloadItem {
|
||||
playlistSize?: number
|
||||
}
|
||||
|
||||
export interface SubscriptionFeedItem {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
addedToQueue: boolean
|
||||
downloadId?: string
|
||||
}
|
||||
|
||||
export interface DownloadHistoryItem {
|
||||
id: string
|
||||
url: string
|
||||
@@ -99,16 +108,14 @@ export interface DownloadHistoryItem {
|
||||
downloadedAt: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
// Enhanced video information
|
||||
format?: string
|
||||
quality?: string
|
||||
codec?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
uploader?: string
|
||||
viewCount?: number
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
// Download-specific format info
|
||||
selectedFormat?: VideoFormat
|
||||
// Playlist context (optional)
|
||||
@@ -128,6 +135,11 @@ export interface DownloadOptions {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
downloadSubs?: boolean
|
||||
customDownloadPath?: string
|
||||
customFilenameTemplate?: string
|
||||
tags?: string[]
|
||||
origin?: 'manual' | 'subscription'
|
||||
subscriptionId?: string
|
||||
}
|
||||
|
||||
export interface PlaylistEntry {
|
||||
@@ -173,6 +185,67 @@ export interface PlaylistDownloadResult {
|
||||
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
|
||||
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'
|
||||
|
||||
@@ -193,6 +266,10 @@ export interface AppSettings {
|
||||
closeToTray: boolean
|
||||
hideDockIcon: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionFilenameTemplate: string
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
@@ -211,5 +288,9 @@ export const defaultSettings: AppSettings = {
|
||||
oneClickQuality: 'best',
|
||||
closeToTray: 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