diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..1c47d74 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/main/lib/database/schema.ts', + out: './resources/drizzle', + dialect: 'sqlite' +}) diff --git a/package.json b/package.json index 7aa71d0..c07ea11 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cbbcce..1682c00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/resources/drizzle/0000_swift_aaron_stack.sql b/resources/drizzle/0000_swift_aaron_stack.sql new file mode 100644 index 0000000..a635795 --- /dev/null +++ b/resources/drizzle/0000_swift_aaron_stack.sql @@ -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 +); diff --git a/resources/drizzle/meta/0000_snapshot.json b/resources/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..ac97b4b --- /dev/null +++ b/resources/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/resources/drizzle/meta/_journal.json b/resources/drizzle/meta/_journal.json new file mode 100644 index 0000000..d99cc45 --- /dev/null +++ b/resources/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1763176841336, + "tag": "0000_swift_aaron_stack", + "breakpoints": true + } + ] +} diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 860e4fb..788cee1 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 81b9104..7d69106 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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. diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5a9bc54..4b676a9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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 diff --git a/src/main/ipc/services/settings-service.ts b/src/main/ipc/services/settings-service.ts index 6c494e2..b85b2b0 100644 --- a/src/main/ipc/services/settings-service.ts +++ b/src/main/ipc/services/settings-service.ts @@ -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(_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(_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): 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() } } diff --git a/src/main/ipc/services/subscription-service.ts b/src/main/ipc/services/subscription-service.ts new file mode 100644 index 0000000..e8669b9 --- /dev/null +++ b/src/main/ipc/services/subscription-service.ts @@ -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 { + 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 { + await subscriptionScheduler.runNow(id) + } + + @IpcMethod() + async queueItem(_context: IpcContext, id: string, itemId: string): Promise { + return subscriptionScheduler.queueItem(id, itemId) + } +} + +export { SubscriptionService } diff --git a/src/main/lib/database-path.ts b/src/main/lib/database-path.ts new file mode 100644 index 0000000..b5a782a --- /dev/null +++ b/src/main/lib/database-path.ts @@ -0,0 +1,6 @@ +import { join } from 'node:path' +import { app } from 'electron' + +export const getDatabaseFilePath = (): string => { + return join(app.getPath('userData'), 'vidbee.db') +} diff --git a/src/main/lib/database/migrate.ts b/src/main/lib/database/migrate.ts new file mode 100644 index 0000000..aaa44c0 --- /dev/null +++ b/src/main/lib/database/migrate.ts @@ -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() + 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 +} diff --git a/src/main/lib/database/schema.ts b/src/main/lib/database/schema.ts new file mode 100644 index 0000000..0e2de38 --- /dev/null +++ b/src/main/lib/database/schema.ts @@ -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 diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 3c115e9..c3f3c4f 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -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 ): 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) diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index 54ed701..8eb5416 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -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 = 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() diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts new file mode 100644 index 0000000..95e1089 --- /dev/null +++ b/src/main/lib/subscription-manager.ts @@ -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 | 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 = { + 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() + 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() diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts new file mode 100644 index 0000000..c4e02f2 --- /dev/null +++ b/src/main/lib/subscription-scheduler.ts @@ -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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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() diff --git a/src/main/settings.ts b/src/main/settings.ts index 4d84a3b..779d4ad 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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) } diff --git a/src/renderer/index.html b/src/renderer/index.html index 41cbb87..0988ce5 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -5,7 +5,7 @@ VidBee + 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" /> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 17fd322..232a3cb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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('home') const [platform, setPlatform] = useState('') + 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(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 + case 'subscriptions': + return case 'about': return case 'sites': diff --git a/src/renderer/src/assets/global.css b/src/renderer/src/assets/global.css index b8840ac..eb47861 100644 --- a/src/renderer/src/assets/global.css +++ b/src/renderer/src/assets/global.css @@ -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; + } } diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index f1c8b42..2c91e13 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -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) {
-
-

+

+

{download.title}

+ {isSubscriptionDownload && ( + + {t('subscriptions.labels.subscription')} + + )}
{(statusIcon || statusText) && ( @@ -624,11 +717,9 @@ export function DownloadItem({ download }: DownloadItemProps) { )} {/* Quality badge */} - {(download.selectedFormat?.height || download.quality) && ( + {qualityLabel && ( - {download.selectedFormat?.height - ? `${download.selectedFormat.height}p${download.selectedFormat.fps === 60 ? '60' : ''}` - : download.quality} + {qualityLabel} )} diff --git a/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx new file mode 100644 index 0000000..2801418 --- /dev/null +++ b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx @@ -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 + 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(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 ( + !isOpen && onClose()}> + + + + {mode === 'edit' && subscription + ? t(titleKey, { name: subscription.title }) + : t(titleKey)} + + {t(descriptionKey)} + +
+
+ + setUrl(event.target.value)} + /> + {detectingFeed && ( +

{t('subscriptions.detecting')}

+ )} + {mode === 'add' && !url.trim() && ( +
+

+ {t('subscriptions.rssHub.hint')} +

+ +
+ )} +
+
+ + setKeywords(event.target.value)} /> +
+
+ + setTags(event.target.value)} /> +
+
+ +
+ + +
+
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + /> +
+
+

{t('subscriptions.fields.onlyLatest')}

+ +
+
+ + {mode === 'add' && ( + + )} + + +
+
+ ) +} diff --git a/src/renderer/src/components/ui/context-menu.tsx b/src/renderer/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..2b65854 --- /dev/null +++ b/src/renderer/src/components/ui/context-menu.tsx @@ -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) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup +} diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx index 71d9761..036abba 100644 --- a/src/renderer/src/components/ui/dialog.tsx +++ b/src/renderer/src/components/ui/dialog.tsx @@ -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) { + return +} -const DialogTrigger = DialogPrimitive.Trigger +function DialogTrigger({ ...props }: React.ComponentProps) { + return +} -const DialogPortal = DialogPrimitive.Portal +function DialogPortal({ ...props }: React.ComponentProps) { + return +} -const DialogClose = DialogPrimitive.Close +function DialogClose({ ...props }: React.ComponentProps) { + return +} -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - ) { + return ( + - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName + /> + ) +} -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogHeader.displayName = 'DialogHeader' +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-) -DialogFooter.displayName = 'DialogFooter' +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} export { Dialog, - DialogPortal, - DialogOverlay, DialogClose, - DialogTrigger, DialogContent, - DialogHeader, + DialogDescription, DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, DialogTitle, - DialogDescription + DialogTrigger } diff --git a/src/renderer/src/components/ui/hover-card.tsx b/src/renderer/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..8726488 --- /dev/null +++ b/src/renderer/src/components/ui/hover-card.tsx @@ -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) { + return +} + +function HoverCardTrigger({ ...props }: React.ComponentProps) { + return +} + +function HoverCardContent({ + className, + align = 'center', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index 7972159..bf249d2 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -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 (
- - - - - -

{item.label}

-
-
+ + {showLabel && ( {item.label} @@ -129,7 +133,7 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { return (