From f60d49425e7394f0ccbfdfefb930a35984536c17 Mon Sep 17 00:00:00 2001 From: Karam Ajaj <37446151+karam-ajaj@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:49:12 +0000 Subject: [PATCH 1/5] feat: Add complete playlist support with UI redesign - Add backend playlist module with CRUD operations - Add database models for playlists and playlistvideos tables - Add migration for playlist tables creation - Add playlist API routes with full REST endpoints - Add PlaylistManager component with list view and operations - Add PlaylistPage with channel-page-like layout and features - Add PlaylistSettingsDialog matching ChannelSettingsDialog functionality - Add download tab for playlists in DownloadNew component - Add volume mount for migrations in docker-compose.yml - Implement 'Download New from All Playlists' functionality - Support playlist-level settings: quality, subfolder, duration filters, regex - Reuse SubfolderAutocomplete and other shared components - Match channel page UI/UX patterns for consistency --- .husky/pre-commit | 28 - Dockerfile | 4 + client/package-lock.json | 49 +- client/src/App.tsx | 38 +- .../DownloadManager/DownloadNew.tsx | 70 +- client/src/components/PlaylistManager.tsx | 367 +++++++ .../components/PlaylistListRow.tsx | 124 +++ .../PlaylistManager/hooks/usePlaylistList.ts | 100 ++ .../hooks/usePlaylistMutations.ts | 179 ++++ client/src/components/PlaylistPage.tsx | 392 +++++++ .../PlaylistPage/PlaylistSettingsDialog.tsx | 449 ++++++++ .../PlaylistPage/PlaylistVideos.tsx | 454 ++++++++ client/src/types/Playlist.ts | 30 + docker-compose.yml | 3 + downloads/README.md | 2 - .../20260129000000-create-playlists-table.js | 175 ++++ server/models/playlist.js | 98 ++ server/models/playlistvideo.js | 82 ++ .../modules/download/ytdlpCommandBuilder.js | 35 + server/modules/playlistModule.js | 977 ++++++++++++++++++ server/routes/index.js | 5 + server/routes/playlists.js | 757 ++++++++++++++ server/server.js | 2 + 23 files changed, 4379 insertions(+), 41 deletions(-) delete mode 100755 .husky/pre-commit create mode 100644 client/src/components/PlaylistManager.tsx create mode 100644 client/src/components/PlaylistManager/components/PlaylistListRow.tsx create mode 100644 client/src/components/PlaylistManager/hooks/usePlaylistList.ts create mode 100644 client/src/components/PlaylistManager/hooks/usePlaylistMutations.ts create mode 100644 client/src/components/PlaylistPage.tsx create mode 100644 client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx create mode 100644 client/src/components/PlaylistPage/PlaylistVideos.tsx create mode 100644 client/src/types/Playlist.ts delete mode 100644 downloads/README.md create mode 100644 migrations/20260129000000-create-playlists-table.js create mode 100644 server/models/playlist.js create mode 100644 server/models/playlistvideo.js create mode 100644 server/modules/playlistModule.js create mode 100644 server/routes/playlists.js diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100755 index 9b196259..00000000 --- a/.husky/pre-commit +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" - -echo "๐Ÿ” Running linter..." -npm run lint -if [ $? -ne 0 ]; then - echo "โŒ Linting failed. Please fix the errors before committing." - exit 1 -fi - -echo "๐Ÿ” Running TypeScript type checking..." -npm run lint:ts -if [ $? -ne 0 ]; then - echo "โŒ TypeScript type checking failed. Please fix the type errors before committing." - exit 1 -fi - -echo "๐Ÿงช Running tests..." -cd client && CI=true npm test -- --watchAll=false --passWithNoTests --silent -TEST_RESULT=$? -cd .. - -if [ $TEST_RESULT -ne 0 ]; then - echo "โŒ Tests failed. Please fix the failing tests before committing." - exit 1 -fi - -echo "โœ… All checks passed!" diff --git a/Dockerfile b/Dockerfile index 10750379..88ce1575 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,10 @@ COPY --from=build /app/client/build ./client/build COPY --from=build /app/migrations ./migrations COPY --from=build /app/package.json ./package.json +# Copy Sequelize configuration +COPY .sequelizerc ./.sequelizerc +COPY config/dbconfig.js ./config/dbconfig.js + # Copy config.example.json to server directory (guaranteed to exist and accessible) COPY config/config.example.json /app/server/config.example.json diff --git a/client/package-lock.json b/client/package-lock.json index 4d86f4b3..477162d4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -91,6 +91,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.8.tgz", "integrity": "sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.21.4", @@ -962,6 +963,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.21.4.tgz", "integrity": "sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1523,6 +1525,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.21.5.tgz", "integrity": "sha512-ELdlq61FpoEkHO6gFRpfj0kUgSwQTGoaEU8eMRoS8Dv3v6e7BjEAj5WMtIBRdHUeAioMhKP5HyxNzNnP+heKbA==", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-module-imports": "^7.21.4", @@ -2346,6 +2349,7 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.0.tgz", "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2386,6 +2390,7 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -3028,6 +3033,7 @@ "version": "5.13.1", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz", "integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0", "@mui/base": "5.0.0-beta.1", @@ -3129,6 +3135,7 @@ "version": "5.13.1", "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz", "integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0", "@mui/private-theming": "^5.13.1", @@ -3814,8 +3821,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.0", @@ -4080,6 +4086,7 @@ "version": "18.2.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz", "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4090,6 +4097,7 @@ "version": "18.2.4", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", + "peer": true, "dependencies": { "@types/react": "*" } @@ -4207,6 +4215,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.59.6", @@ -4258,6 +4267,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.59.6", "@typescript-eslint/types": "5.59.6", @@ -4588,6 +4598,7 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4674,6 +4685,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5398,6 +5410,7 @@ "url": "https://tidelift.com/funding/github/npm/browserslist" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001449", "electron-to-chromium": "^1.4.284", @@ -5515,9 +5528,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001488", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", - "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", "funding": [ { "type": "opencollective", @@ -5531,7 +5544,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/case-sensitive-paths-webpack-plugin": { "version": "2.4.0", @@ -6163,6 +6177,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6470,6 +6485,7 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -6741,8 +6757,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dom-converter": { "version": "0.2.0", @@ -7209,6 +7224,7 @@ "version": "8.40.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -7604,6 +7620,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -9694,6 +9711,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10880,7 +10898,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11628,6 +11645,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12426,6 +12444,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13492,6 +13511,7 @@ "version": "6.0.13", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13839,6 +13859,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13908,6 +13929,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -13957,6 +13979,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14449,6 +14472,7 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -15903,6 +15927,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "peer": true, "engines": { "node": ">=10" }, @@ -15947,6 +15972,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16339,6 +16365,7 @@ "version": "5.82.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.0.tgz", "integrity": "sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg==", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -16407,6 +16434,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16456,6 +16484,7 @@ "version": "4.15.0", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.0.tgz", "integrity": "sha512-HmNB5QeSl1KpulTBQ8UT4FPrByYyaLxpJoQ0+s7EvUrMc16m0ZS1sgb1XGqzmgCPk0c9y+aaXxn11tbLzuM7NQ==", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -16514,6 +16543,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16859,6 +16889,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index a294f069..9e8f4059 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,6 +36,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import SettingsIcon from '@mui/icons-material/Settings'; import SubscriptionsIcon from '@mui/icons-material/Subscriptions'; +import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; import DownloadIcon from '@mui/icons-material/Download'; import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'; import LoginIcon from '@mui/icons-material/Login'; @@ -44,11 +45,13 @@ import ShieldIcon from '@mui/icons-material/Shield'; import NewReleasesIcon from '@mui/icons-material/NewReleases'; import Configuration from './components/Configuration'; import ChannelManager from './components/ChannelManager'; +import PlaylistManager from './components/PlaylistManager'; import DownloadManager from './components/DownloadManager'; import VideosPage from './components/VideosPage'; import LocalLogin from './components/LocalLogin'; import InitialSetup from './components/InitialSetup'; import ChannelPage from './components/ChannelPage'; +import PlaylistPage from './components/PlaylistPage'; import ChangelogPage from './components/ChangelogPage'; import StorageStatus from './components/StorageStatus'; import { useConfig } from './hooks/useConfig'; @@ -555,6 +558,31 @@ function AppContent() { primary='Your Channels' /> + `4px solid ${theme.palette.primary.main}` : 'none', + '&:hover': { + bgcolor: 'action.hover', + }, + paddingX: isMobile ? '8px' : '16px' + }} + > + + + + + } /> + } + /> + } + /> } diff --git a/client/src/components/DownloadManager/DownloadNew.tsx b/client/src/components/DownloadManager/DownloadNew.tsx index 7a8bb43f..e7784fe4 100644 --- a/client/src/components/DownloadManager/DownloadNew.tsx +++ b/client/src/components/DownloadManager/DownloadNew.tsx @@ -32,6 +32,7 @@ const DownloadNew: React.FC = ({ }) => { const [tabValue, setTabValue] = useState(0); const [showChannelSettingsDialog, setShowChannelSettingsDialog] = useState(false); + const [showPlaylistSettingsDialog, setShowPlaylistSettingsDialog] = useState(false); // Use config hook to get default resolution and video count const { config } = useConfig(token); @@ -42,6 +43,11 @@ const DownloadNew: React.FC = ({ setShowChannelSettingsDialog(true); }; + const handleOpenPlaylistSettings = () => { + console.log('Opening playlist settings dialog'); + setShowPlaylistSettingsDialog(true); + }; + const handleTriggerChannelDownloads = async (settings: DownloadSettings | null) => { setShowChannelSettingsDialog(false); downloadInitiatedRef.current = true; @@ -68,6 +74,35 @@ const DownloadNew: React.FC = ({ setTimeout(fetchRunningJobs, 500); }; + const handleTriggerPlaylistDownloads = async (settings: DownloadSettings | null) => { + console.log('Triggering playlist downloads with settings:', settings); + setShowPlaylistSettingsDialog(false); + downloadInitiatedRef.current = true; + + const body: any = {}; + // Add settings to the request body if provided + if (settings) { + body.overrideSettings = settings; + } + + console.log('Fetching /api/playlists/download-all with body:', body); + const result = await fetch('/api/playlists/download-all', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-access-token': token || '', + }, + body: JSON.stringify(body), + }); + console.log('Playlist download result status:', result.status); + // If the result is a 400 then we already have a running Playlist Download + // job and we should display an alert + if (result.status === 400) { + alert('Playlist Download already running'); + } + setTimeout(fetchRunningJobs, 500); + }; + const handleManualDownload = useCallback(async (urls: string[], settings?: DownloadSettings | null) => { downloadInitiatedRef.current = true; const strippedUrls = urls.map((url) => @@ -109,6 +144,7 @@ const DownloadNew: React.FC = ({ + @@ -123,7 +159,7 @@ const DownloadNew: React.FC = ({ defaultResolution={defaultResolution} /> - ) : ( + ) : tabValue === 1 ? ( setTabValue(1)} @@ -145,6 +181,28 @@ const DownloadNew: React.FC = ({ + ) : ( + setTabValue(2)} + > + + + + )} @@ -158,6 +216,16 @@ const DownloadNew: React.FC = ({ mode="channel" defaultResolutionSource="global" /> + + setShowPlaylistSettingsDialog(false)} + onConfirm={handleTriggerPlaylistDownloads} + defaultResolution={defaultResolution} + defaultVideoCount={defaultVideoCount} + mode="channel" + defaultResolutionSource="global" + /> ); }; diff --git a/client/src/components/PlaylistManager.tsx b/client/src/components/PlaylistManager.tsx new file mode 100644 index 00000000..89cb8fee --- /dev/null +++ b/client/src/components/PlaylistManager.tsx @@ -0,0 +1,367 @@ +import React, { useContext, useState, useEffect, useCallback } from 'react'; +import { + Alert, + Box, + Button, + Card, + CardHeader, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Fab, + IconButton, + List, + Pagination, + TextField, + Tooltip, + Typography, + Zoom, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import SaveIcon from '@mui/icons-material/Save'; +import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useNavigate } from 'react-router-dom'; +import WebSocketContext from '../contexts/WebSocketContext'; +import { Playlist } from '../types/Playlist'; +import { usePlaylistList } from './PlaylistManager/hooks/usePlaylistList'; +import { usePlaylistMutations } from './PlaylistManager/hooks/usePlaylistMutations'; +import PlaylistListRow from './PlaylistManager/components/PlaylistListRow'; + +interface PlaylistManagerProps { + token: string | null; +} + +const PlaylistManager: React.FC = ({ token }) => { + const websocketContext = useContext(WebSocketContext); + if (!websocketContext) { + throw new Error('WebSocketContext not found'); + } + + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const [newPlaylistUrl, setNewPlaylistUrl] = useState(''); + const [page, setPage] = useState(1); + const [filterValue, setFilterValue] = useState(''); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [pendingSave, setPendingSave] = useState(false); + const [addingPlaylist, setAddingPlaylist] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const pageSize = isMobile ? 16 : 20; + + const { + playlists, + total, + totalPages, + loading, + error, + refetch: refetchPlaylists, + } = usePlaylistList({ + token, + page, + pageSize, + searchTerm: filterValue, + sortOrder: 'asc', + }); + + const { + addPlaylist, + deletePlaylist, + updatePlaylist, + fetchPlaylistVideos, + downloadPlaylist, + loading: mutationLoading, + error: mutationError, + } = usePlaylistMutations(token, refetchPlaylists); + + const [modifiedPlaylists, setModifiedPlaylists] = useState>>(new Map()); + + // Message filter for playlist updates + const messageFilter = useCallback( + (message: any) => ( + message.destination === 'broadcast' && + message.source === 'playlist' && + message.type === 'playlistsUpdated' + ), + [] + ); + + // Handle playlist update messages + const handleMessage = useCallback(() => { + refetchPlaylists(); + }, [refetchPlaylists]); + + // Subscribe to playlist updates via WebSocket + useEffect(() => { + websocketContext.subscribe(messageFilter, handleMessage); + return () => { + websocketContext.unsubscribe(handleMessage); + }; + }, [websocketContext, messageFilter, handleMessage]); + + // Reset page if it exceeds total pages + useEffect(() => { + if (page > totalPages && totalPages > 0) { + setPage(totalPages); + } + }, [page, totalPages]); + + const handleAddPlaylist = useCallback(async () => { + if (!newPlaylistUrl.trim()) return; + + setAddingPlaylist(true); + try { + await addPlaylist(newPlaylistUrl); + setNewPlaylistUrl(''); + setAddDialogOpen(false); + } catch (err) { + console.error('Failed to add playlist:', err); + } finally { + setAddingPlaylist(false); + } + }, [newPlaylistUrl, addPlaylist]); + + const handleDeletePlaylist = useCallback(async (playlistId: string) => { + if (!window.confirm('Are you sure you want to delete this playlist?')) { + return; + } + + try { + await deletePlaylist(playlistId); + } catch (err) { + console.error('Failed to delete playlist:', err); + } + }, [deletePlaylist]); + + const handlePlaylistChange = useCallback((playlistId: string, updates: Partial) => { + setModifiedPlaylists(prev => { + const newMap = new Map(prev); + const existing = newMap.get(playlistId) || {}; + newMap.set(playlistId, { ...existing, ...updates }); + return newMap; + }); + setPendingSave(true); + }, []); + + const handleSaveChanges = useCallback(async () => { + try { + for (const [playlistId, updates] of modifiedPlaylists.entries()) { + await updatePlaylist(playlistId, updates); + } + setModifiedPlaylists(new Map()); + setPendingSave(false); + } catch (err) { + console.error('Failed to save changes:', err); + } + }, [modifiedPlaylists, updatePlaylist]); + + const handleNavigate = useCallback((playlist: Playlist) => { + if (!playlist.playlist_id) return; + navigate(`/playlist/${playlist.playlist_id}`); + }, [navigate]); + + const handleFetchVideos = useCallback(async (playlistId: string) => { + try { + await fetchPlaylistVideos(playlistId); + setSuccessMessage('Playlist videos fetched successfully!'); + setTimeout(() => setSuccessMessage(null), 3000); + } catch (err) { + console.error('Failed to fetch playlist videos:', err); + } + }, [fetchPlaylistVideos]); + + const handleDownloadPlaylist = useCallback(async (playlistId: string) => { + try { + const result = await downloadPlaylist(playlistId); + if (result?.videoCount) { + setSuccessMessage(`Queued ${result.videoCount} videos for download!`); + setTimeout(() => setSuccessMessage(null), 5000); + } else { + setSuccessMessage('No videos to download'); + setTimeout(() => setSuccessMessage(null), 3000); + } + } catch (err) { + console.error('Failed to download playlist:', err); + } + }, [downloadPlaylist]); + + const displayPlaylists = playlists.map(playlist => { + const modifications = modifiedPlaylists.get(playlist.playlist_id); + return modifications ? { ...playlist, ...modifications } : playlist; + }); + + return ( + <> + + } + onClick={() => setAddDialogOpen(true)} + size={isMobile ? 'small' : 'medium'} + > + Add Playlist + + } + /> + + + {pendingSave && ( + } + onClick={handleSaveChanges} + disabled={mutationLoading} + > + Save Changes + + } + sx={{ borderRadius: 0 }} + > + You have unsaved changes + + )} + + + setFilterValue(e.target.value)} + /> + + + + + {successMessage && ( + + setSuccessMessage(null)}>{successMessage} + + )} + + {error && ( + + {error} + + )} + + {mutationError && ( + + {mutationError} + + )} + + + {loading ? ( + + + + ) : displayPlaylists.length === 0 ? ( + + No playlists found. Add a playlist to get started. + + ) : ( + + {displayPlaylists.map((playlist) => ( + handleNavigate(playlist)} + onDelete={() => handleDeletePlaylist(playlist.playlist_id)} + onChange={(updates) => handlePlaylistChange(playlist.playlist_id, updates)} + onFetchVideos={() => handleFetchVideos(playlist.playlist_id)} + onDownload={() => handleDownloadPlaylist(playlist.playlist_id)} + /> + ))} + + )} + + + {totalPages > 1 && ( + <> + + + setPage(newPage)} + color="primary" + size={isMobile ? 'small' : 'medium'} + /> + + + )} + + + + + Total playlists: {total} + + + + + {/* Add Playlist Dialog */} + setAddDialogOpen(false)} maxWidth="sm" fullWidth> + Add Playlist + + setNewPlaylistUrl(e.target.value)} + placeholder="https://www.youtube.com/playlist?list=PLxxx..." + helperText="Enter a YouTube playlist URL" + /> + + + + + + + + {/* Floating Action Button (Mobile) */} + {isMobile && ( + + setAddDialogOpen(true)} + > + + + + )} + + ); +}; + +export default PlaylistManager; diff --git a/client/src/components/PlaylistManager/components/PlaylistListRow.tsx b/client/src/components/PlaylistManager/components/PlaylistListRow.tsx new file mode 100644 index 00000000..4eddcdb8 --- /dev/null +++ b/client/src/components/PlaylistManager/components/PlaylistListRow.tsx @@ -0,0 +1,124 @@ +import React from 'react'; +import { + Box, + Checkbox, + IconButton, + ListItem, + Tooltip, + Typography, +} from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay'; +import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { Playlist } from '../../../types/Playlist'; + +interface PlaylistListRowProps { + playlist: Playlist; + onNavigate: () => void; + onDelete: () => void; + onChange: (updates: Partial) => void; + onFetchVideos: () => void; + onDownload: () => void; +} + +const PlaylistListRow: React.FC = ({ + playlist, + onNavigate, + onDelete, + onChange, + onFetchVideos, + onDownload, +}) => { + return ( + + {/* Enabled Checkbox */} + + onChange({ enabled: e.target.checked })} + size="small" + /> + + + {/* Playlist Info */} + + + {playlist.title || playlist.uploader} + + + {playlist.uploader} + + + + {/* Auto-download Checkbox */} + + onChange({ auto_download_enabled: e.target.checked })} + size="small" + disabled={!playlist.enabled} + /> + + + {/* Actions */} + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default PlaylistListRow; diff --git a/client/src/components/PlaylistManager/hooks/usePlaylistList.ts b/client/src/components/PlaylistManager/hooks/usePlaylistList.ts new file mode 100644 index 00000000..b29fb491 --- /dev/null +++ b/client/src/components/PlaylistManager/hooks/usePlaylistList.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { Playlist } from '../../../types/Playlist'; + +interface UsePlaylistListParams { + token: string | null; + page: number; + pageSize: number; + searchTerm: string; + sortOrder: 'asc' | 'desc'; + subFolder?: string; +} + +interface PlaylistListResponse { + playlists: Playlist[]; + total: number; + totalPages: number; + subFolders?: Array; +} + +export const usePlaylistList = ({ + token, + page, + pageSize, + searchTerm, + sortOrder, + subFolder, +}: UsePlaylistListParams) => { + const [playlists, setPlaylists] = useState([]); + const [total, setTotal] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [subFolders, setSubFolders] = useState([]); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const fetchPlaylists = useCallback(async () => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + const params: any = { + page, + pageSize, + search: searchTerm, + sortBy: 'uploader', + sortOrder: sortOrder.toUpperCase(), + }; + + if (subFolder !== undefined) { + params.subFolder = subFolder; + } + + const response = await axios.get('/getplaylists', { + params, + headers: { + 'x-access-token': token, + }, + }); + + if (isMountedRef.current) { + setPlaylists(response.data.playlists || []); + setTotal(response.data.total || 0); + setTotalPages(response.data.totalPages || 0); + setSubFolders((response.data.subFolders || []).filter((f): f is string => f !== null)); + } + } catch (err: any) { + const message = err.response?.data?.error || err.message || 'Failed to load playlists'; + if (isMountedRef.current) { + setError(message); + } + } finally { + if (isMountedRef.current) { + setLoading(false); + } + } + }, [token, page, pageSize, searchTerm, sortOrder, subFolder]); + + useEffect(() => { + fetchPlaylists(); + }, [fetchPlaylists]); + + return { + playlists, + total, + totalPages, + loading, + error, + refetch: fetchPlaylists, + subFolders, + }; +}; diff --git a/client/src/components/PlaylistManager/hooks/usePlaylistMutations.ts b/client/src/components/PlaylistManager/hooks/usePlaylistMutations.ts new file mode 100644 index 00000000..9055ec0e --- /dev/null +++ b/client/src/components/PlaylistManager/hooks/usePlaylistMutations.ts @@ -0,0 +1,179 @@ +import { useState, useCallback } from 'react'; +import axios from 'axios'; +import { Playlist } from '../../../types/Playlist'; + +export const usePlaylistMutations = (token: string | null, onSuccess?: () => void) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const addPlaylist = useCallback(async (url: string) => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + const response = await axios.post( + '/addplaylistinfo', + { url }, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (response.data.status === 'success') { + // Now enable the playlist + const playlistId = response.data.playlistInfo.playlist_id; + await axios.put( + `/api/playlists/${playlistId}/settings`, + { enabled: true }, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (onSuccess) { + onSuccess(); + } + } else { + throw new Error(response.data.message || 'Failed to add playlist'); + } + } catch (err: any) { + const message = err.response?.data?.message || err.message || 'Failed to add playlist'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [token, onSuccess]); + + const deletePlaylist = useCallback(async (playlistId: string) => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + await axios.delete(`/api/playlists/${playlistId}`, { + headers: { + 'x-access-token': token, + }, + }); + + if (onSuccess) { + onSuccess(); + } + } catch (err: any) { + const message = err.response?.data?.error || err.message || 'Failed to delete playlist'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [token, onSuccess]); + + const updatePlaylist = useCallback(async (playlistId: string, updates: Partial) => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + await axios.put( + `/api/playlists/${playlistId}/settings`, + updates, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (onSuccess) { + onSuccess(); + } + } catch (err: any) { + const message = err.response?.data?.error || err.message || 'Failed to update playlist'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [token, onSuccess]); + + const fetchPlaylistVideos = useCallback(async (playlistId: string) => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + await axios.post( + `/fetchallplaylistvideos/${playlistId}`, + {}, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (onSuccess) { + onSuccess(); + } + + return { success: true }; + } catch (err: any) { + const message = err.response?.data?.error || err.message || 'Failed to fetch playlist videos'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [token, onSuccess]); + + const downloadPlaylist = useCallback(async (playlistId: string) => { + if (!token) return; + + setLoading(true); + setError(null); + + try { + const response = await axios.post( + `/api/playlists/${playlistId}/download`, + {}, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (onSuccess) { + onSuccess(); + } + + return response.data; + } catch (err: any) { + const message = err.response?.data?.error || err.message || 'Failed to queue playlist download'; + setError(message); + throw err; + } finally { + setLoading(false); + } + }, [token, onSuccess]); + + return { + addPlaylist, + deletePlaylist, + updatePlaylist, + fetchPlaylistVideos, + downloadPlaylist, + loading, + error, + }; +}; diff --git a/client/src/components/PlaylistPage.tsx b/client/src/components/PlaylistPage.tsx new file mode 100644 index 00000000..f4f586e5 --- /dev/null +++ b/client/src/components/PlaylistPage.tsx @@ -0,0 +1,392 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + Card, + CardContent, + Grid, + Typography, + Box, + IconButton, + Chip, + Alert, + Popover, + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SettingsIcon from '@mui/icons-material/Settings'; +import FolderIcon from '@mui/icons-material/Folder'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; +import PlaylistVideos from './PlaylistPage/PlaylistVideos'; +import PlaylistSettingsDialog from './PlaylistPage/PlaylistSettingsDialog'; + +interface Playlist { + playlist_id: string; + title: string; + uploader: string; + thumbnail: string | null; + enabled: boolean; + auto_download_enabled: boolean; + video_quality: string | null; + audio_format: string | null; + sub_folder: string | null; + min_duration: number | null; + max_duration: number | null; + title_filter_regex: string | null; + folder_name: string | null; + description?: string | null; +} + +interface PlaylistPageProps { + token: string; +} + +function PlaylistPage({ token }: PlaylistPageProps) { + const { playlist_id } = useParams<{ playlist_id: string }>(); + const navigate = useNavigate(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [playlist, setPlaylist] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [regexAnchorEl, setRegexAnchorEl] = useState(null); + const [regexDialogOpen, setRegexDialogOpen] = useState(false); + + useEffect(() => { + if (!playlist_id) return; + + const fetchPlaylist = async () => { + try { + const response = await fetch(`/api/playlists/${playlist_id}`, { + headers: { + 'x-access-token': token, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch playlist'); + } + + const data = await response.json(); + setPlaylist(data); + } catch (err) { + console.error('Error fetching playlist:', err); + setError('Failed to load playlist'); + } finally { + setLoading(false); + } + }; + + fetchPlaylist(); + }, [playlist_id, token]); + + const handleSettingsSaved = (updatedSettings: any) => { + if (playlist) { + setPlaylist({ + ...playlist, + ...updatedSettings + }); + } + }; + + const handleRegexClick = (event: React.MouseEvent) => { + if (isMobile) { + setRegexDialogOpen(true); + } else { + setRegexAnchorEl(event.currentTarget); + } + }; + + const handleRegexClose = () => { + setRegexAnchorEl(null); + }; + + if (loading) { + return ( + + Loading playlist... + + ); + } + + if (error || !playlist) { + return ( + + {error || 'Playlist not found'} + + ); + } + + // Helper functions (reused from ChannelPage pattern) + const formatDuration = (seconds: number | null) => { + if (!seconds) return null; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; + }; + + const isUsingDefaultSubfolder = () => { + return playlist.sub_folder === '__USE_GLOBAL_DEFAULT__' || (!playlist.sub_folder && !playlist.folder_name); + }; + + const isExplicitlyNoSubfolder = () => { + return playlist.sub_folder === '' || playlist.folder_name === ''; + }; + + const renderSubFolder = () => { + let displayText: string; + let isSpecial = false; + + if (isExplicitlyNoSubfolder()) { + // null/empty = root (backwards compatible) + displayText = 'root'; + isSpecial = true; + } else if (isUsingDefaultSubfolder()) { + // Use global default + displayText = 'global default'; + isSpecial = true; + } else { + // Specific subfolder + const actualSubfolder = playlist.folder_name || playlist.sub_folder; + displayText = `__${actualSubfolder}/`; + } + + return ( + + + + {displayText} + + + ); + }; + + const renderFilterIndicators = () => { + const filters = []; + + // Quality filter + if (playlist.video_quality) { + filters.push( + } + label={`${playlist.video_quality}p`} + size="small" + variant="outlined" + /> + ); + } + + // Duration filters + const minDur = formatDuration(playlist.min_duration); + const maxDur = formatDuration(playlist.max_duration); + if (minDur || maxDur) { + const label = minDur && maxDur + ? `${minDur} - ${maxDur}` + : minDur + ? `โ‰ฅ ${minDur}` + : `โ‰ค ${maxDur}`; + + filters.push( + + ); + } + + // Regex filter (clickable) + if (playlist.title_filter_regex) { + filters.push( + + ); + } + + return filters.length > 0 ? filters : null; + }; + + const textToHTML = (text: string | null | undefined) => { + if (!text) return null; + return text + .split('\n') + .map((line, i) => {line}
); + }; + + const filters = renderFilterIndicators(); + const regexPopoverOpen = Boolean(regexAnchorEl); + + return ( + <> + + + + {/* Header */} + + + navigate('/playlists')} size="small"> + + + + {playlist.title} + + + + {playlist.auto_download_enabled && ( + + )} + setSettingsOpen(true)} + size="small" + sx={{ ml: 1 }} + > + + + + + + + {/* Thumbnail */} + + {playlist.thumbnail && ( + + )} + + + {/* Details */} + + + {/* Uploader */} + + Uploader: {playlist.uploader} + + + {/* Subfolder */} + {renderSubFolder()} + + {/* Filters */} + {filters && ( + + + Filters: + + {filters} + + )} + + {/* Description */} + {playlist.description && ( + + + {textToHTML(playlist.description)} + + + )} + + + + + + + {/* Videos Component */} + + + {/* Settings Dialog */} + setSettingsOpen(false)} + playlistId={playlist_id!} + playlistName={playlist.title} + token={token} + onSettingsSaved={handleSettingsSaved} + /> + + {/* Regex Popover (Desktop) */} + + + + Title Filter Regex + + + {playlist.title_filter_regex} + + + + + {/* Regex Dialog (Mobile) */} + setRegexDialogOpen(false)} + maxWidth="sm" + fullWidth + > + Title Filter Regex + + + {playlist.title_filter_regex} + + + + + + + + ); +} + +export default PlaylistPage; diff --git a/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx b/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx new file mode 100644 index 00000000..00357c17 --- /dev/null +++ b/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + FormControl, + InputLabel, + Select, + MenuItem, + TextField, + CircularProgress, + Alert, + Box, + Typography, + Divider, + List, + ListItem, + ListItemText, + ListItemIcon, + IconButton, + Link, + Collapse +} from '@mui/material'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import InfoIcon from '@mui/icons-material/Info'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { useConfig } from '../../hooks/useConfig'; +import { SubfolderAutocomplete } from '../shared/SubfolderAutocomplete'; + +interface PlaylistSettings { + sub_folder: string | null; + video_quality: string | null; + min_duration: number | null; + max_duration: number | null; + title_filter_regex: string | null; + audio_format: string | null; +} + +interface PlaylistSettingsDialogProps { + open: boolean; + onClose: () => void; + playlistId: string; + playlistName: string; + token: string | null; + onSettingsSaved?: (settings: PlaylistSettings) => void; +} + +const regexExamples = [ + { + label: 'Exclude videos containing a word (case-insensitive)', + pattern: '(?i)^(?!.*roblox).*', + description: 'Excludes videos with "roblox" in the title' + }, + { + label: 'Exclude videos containing multiple words', + pattern: '(?i)^(?!.*(roblox|minecraft)).*', + description: 'Excludes videos with "roblox" OR "minecraft"' + }, + { + label: 'Include only videos matching specific phrases', + pattern: '(?i)(Official Trailer|New Trailer)', + description: 'Only matches videos containing these phrases' + } +]; + +function PlaylistSettingsDialog({ + open, + onClose, + playlistId, + playlistName, + token, + onSettingsSaved +}: PlaylistSettingsDialogProps) { + const [settings, setSettings] = useState({ + sub_folder: null, + video_quality: null, + min_duration: null, + max_duration: null, + title_filter_regex: null, + audio_format: null + }); + const [originalSettings, setOriginalSettings] = useState({ + sub_folder: null, + video_quality: null, + min_duration: null, + max_duration: null, + title_filter_regex: null, + audio_format: null + }); + const [subfolders, setSubfolders] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Use config hook to get global quality setting + const { config, refetch: refetchConfig } = useConfig(token); + const globalQuality = config.preferredResolution || '1080'; + + // Duration input state (in minutes for UI convenience) + const [minDurationMinutes, setMinDurationMinutes] = useState(''); + const [maxDurationMinutes, setMaxDurationMinutes] = useState(''); + + // Regex examples collapsible state + const [showRegexExamples, setShowRegexExamples] = useState(false); + + const effectiveQualityDisplay = settings.video_quality + ? `${settings.video_quality}p (playlist)` + : `${globalQuality}p (global)`; + + const qualityOptions = [ + { value: null, label: `Use Global Setting (${globalQuality}p)` }, + { value: '360', label: '360p' }, + { value: '480', label: '480p' }, + { value: '720', label: '720p (HD)' }, + { value: '1080', label: '1080p (Full HD)' }, + { value: '1440', label: '1440p (2K)' }, + { value: '2160', label: '2160p (4K)' } + ]; + + useEffect(() => { + if (open) { + refetchConfig().catch((err) => console.error('Failed to refresh config:', err)); + } + }, [open, refetchConfig]); + + useEffect(() => { + if (!open) { + setSuccess(false); + setError(null); + return; + } + + const loadAllData = async () => { + setLoading(true); + setError(null); + + try { + // Load playlist settings + const settingsResponse = await fetch(`/api/playlists/${playlistId}`, { + headers: { + 'x-access-token': token || '' + } + }); + + if (!settingsResponse.ok) { + throw new Error('Failed to load playlist settings'); + } + + const settingsData = await settingsResponse.json(); + const loadedSettings = { + sub_folder: settingsData.sub_folder || null, + video_quality: settingsData.video_quality || null, + min_duration: settingsData.min_duration || null, + max_duration: settingsData.max_duration || null, + title_filter_regex: settingsData.title_filter_regex || null, + audio_format: settingsData.audio_format || null + }; + setSettings(loadedSettings); + setOriginalSettings(loadedSettings); + + // Convert seconds to minutes for UI + if (settingsData.min_duration) { + setMinDurationMinutes(String(Math.floor(settingsData.min_duration / 60))); + } + if (settingsData.max_duration) { + setMaxDurationMinutes(String(Math.floor(settingsData.max_duration / 60))); + } + + // Load subfolders (non-critical) + try { + const subfoldersResponse = await fetch('/api/channels/subfolders', { + headers: { + 'x-access-token': token || '' + } + }); + + if (subfoldersResponse.ok) { + const subfoldersData = await subfoldersResponse.json(); + setSubfolders(subfoldersData); + } + } catch (err) { + console.error('Failed to load subfolders:', err); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load settings'); + } finally { + setLoading(false); + } + }; + + loadAllData(); + }, [open, playlistId, token]); + + const handleSave = async () => { + setSaving(true); + setError(null); + setSuccess(false); + + try { + const response = await fetch(`/api/playlists/${playlistId}/settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-access-token': token || '' + }, + body: JSON.stringify({ + sub_folder: settings.sub_folder || null, + video_quality: settings.video_quality || null, + min_duration: settings.min_duration, + max_duration: settings.max_duration, + title_filter_regex: settings.title_filter_regex || null, + audio_format: settings.audio_format || null + }) + }); + + if (!response.ok) { + let errorMessage = 'Failed to update settings'; + try { + const data = await response.json(); + errorMessage = data.error || errorMessage; + } catch (parseError) { + errorMessage = `Server error: ${response.status} ${response.statusText}`; + } + throw new Error(errorMessage); + } + + const result = await response.json(); + const updatedSettings = { + sub_folder: result?.playlist?.sub_folder ?? settings.sub_folder ?? null, + video_quality: result?.playlist?.video_quality ?? settings.video_quality ?? null, + min_duration: result?.playlist?.min_duration ?? settings.min_duration ?? null, + max_duration: result?.playlist?.max_duration ?? settings.max_duration ?? null, + title_filter_regex: result?.playlist?.title_filter_regex ?? settings.title_filter_regex ?? null, + audio_format: result?.playlist?.audio_format ?? settings.audio_format ?? null + }; + + setSettings(updatedSettings); + setOriginalSettings(updatedSettings); + setSuccess(true); + + if (onSettingsSaved) { + onSettingsSaved(updatedSettings); + } + + setTimeout(() => { + onClose(); + }, 1500); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to save settings'; + setError(errorMessage); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + setSettings(originalSettings); + onClose(); + }; + + const hasChanges = () => { + return settings.sub_folder !== originalSettings.sub_folder || + settings.video_quality !== originalSettings.video_quality || + settings.min_duration !== originalSettings.min_duration || + settings.max_duration !== originalSettings.max_duration || + settings.title_filter_regex !== originalSettings.title_filter_regex || + settings.audio_format !== originalSettings.audio_format; + }; + + const handleDurationChange = (type: 'min' | 'max', value: string) => { + if (type === 'min') { + setMinDurationMinutes(value); + const seconds = value ? parseInt(value) * 60 : null; + setSettings({ ...settings, min_duration: seconds }); + } else { + setMaxDurationMinutes(value); + const seconds = value ? parseInt(value) * 60 : null; + setSettings({ ...settings, max_duration: seconds }); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + return ( + + + Playlist Settings + + {playlistName} + + + + {loading ? ( + + + + ) : ( + <> + {error && ( + + {error} + + )} + {success && ( + }> + Settings saved successfully! + + )} + + {/* Subfolder */} + setSettings({ ...settings, sub_folder: newValue })} + subfolders={subfolders} + defaultSubfolderDisplay={config.defaultSubfolder || null} + label="Download Subfolder" + helperText="Optional subfolder for organizing downloads" + /> + + {/* Video Quality */} + + Video Quality + + + Effective quality: {effectiveQualityDisplay} + + + + + + Download Filters + + + {/* Duration Filters */} + + handleDurationChange('min', e.target.value)} + fullWidth + inputProps={{ min: 0 }} + helperText="Minimum video length" + /> + handleDurationChange('max', e.target.value)} + fullWidth + inputProps={{ min: 0 }} + helperText="Maximum video length" + /> + + + {/* Title Filter Regex */} + setSettings({ ...settings, title_filter_regex: e.target.value || null })} + fullWidth + sx={{ mt: 2 }} + helperText="Filter videos by title using regular expressions" + multiline + rows={2} + /> + + {/* Regex Examples */} + + + + + {regexExamples.map((example, index) => ( + + + copyToClipboard(example.pattern)} + title="Copy to clipboard" + > + + + + + + {example.pattern} + + + {example.description} + + + } + /> + + ))} + + + + Test your regex patterns at regex101.com + + + + + + )} + + + + + + + ); +} + +export default PlaylistSettingsDialog; diff --git a/client/src/components/PlaylistPage/PlaylistVideos.tsx b/client/src/components/PlaylistPage/PlaylistVideos.tsx new file mode 100644 index 00000000..c9b6ef57 --- /dev/null +++ b/client/src/components/PlaylistPage/PlaylistVideos.tsx @@ -0,0 +1,454 @@ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { + Card, + Box, + Typography, + Alert, + Skeleton, + Grid, + Pagination, + Checkbox, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableContainer, + IconButton, + Tooltip, + TextField, + InputAdornment, + ToggleButton, + ToggleButtonGroup, + Button, + Chip, +} from '@mui/material'; + +import SearchIcon from '@mui/icons-material/Search'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import ViewListIcon from '@mui/icons-material/ViewList'; +import DownloadIcon from '@mui/icons-material/Download'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import BlockIcon from '@mui/icons-material/Block'; +import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; +import CheckBoxIcon from '@mui/icons-material/CheckBox'; +import IndeterminateCheckBoxIcon from '@mui/icons-material/IndeterminateCheckBox'; + +import useMediaQuery from '@mui/material/useMediaQuery'; +import { useTheme } from '@mui/material/styles'; +import axios from 'axios'; + +interface PlaylistVideo { + youtube_id: string; + title: string; + thumbnail: string | null; + duration: number | null; + playlist_index: number; + ignored: boolean; + added: boolean; +} + +interface PlaylistVideosProps { + token: string; + playlistId: string; + playlistQuality: string | null; + playlistAudioFormat: string | null; +} + +type ViewMode = 'grid' | 'list'; + +function PlaylistVideos({ token, playlistId, playlistQuality, playlistAudioFormat }: PlaylistVideosProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + const [videos, setVideos] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [hideDownloaded, setHideDownloaded] = useState(true); + const [viewMode, setViewMode] = useState(isMobile ? 'list' : 'grid'); + const [checkedBoxes, setCheckedBoxes] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const pageSize = isMobile ? 8 : 12; + + const fetchVideos = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ + page: page.toString(), + pageSize: pageSize.toString(), + hideDownloaded: hideDownloaded.toString(), + }); + if (searchQuery) params.append('search', searchQuery); + + const response = await fetch(`/getplaylistvideos/${playlistId}?${params}`, { + headers: { + 'x-access-token': token, + }, + }); + + if (!response.ok) { + throw new Error('Failed to fetch playlist videos'); + } + + const data = await response.json(); + setVideos(data.videos || []); + setTotalCount(data.totalCount || 0); + } catch (error) { + console.error('Error fetching playlist videos:', error); + } finally { + setLoading(false); + } + }, [playlistId, page, pageSize, hideDownloaded, searchQuery, token]); + + useEffect(() => { + fetchVideos(); + }, [fetchVideos]); + + const handleRefresh = async () => { + setRefreshing(true); + try { + const response = await axios.post( + `/fetchallplaylistvideos/${playlistId}`, + {}, + { + headers: { + 'x-access-token': token, + }, + } + ); + + if (response.status === 200) { + await fetchVideos(); + } + } catch (error) { + console.error('Error refreshing playlist videos:', error); + } finally { + setRefreshing(false); + } + }; + + const handleDownload = async () => { + if (checkedBoxes.length === 0) return; + + try { + const urls = checkedBoxes.map(youtubeId => `https://www.youtube.com/watch?v=${youtubeId}`); + + await axios.post( + '/triggerspecificdownloads', + { + urls, + overrideSettings: { + resolution: playlistQuality || undefined, + audioFormat: playlistAudioFormat || undefined, + }, + }, + { + headers: { + 'x-access-token': token, + }, + } + ); + + setCheckedBoxes([]); + } catch (error) { + console.error('Error downloading videos:', error); + } + }; + + const toggleIgnore = async (youtubeId: string) => { + try { + const video = videos.find(v => v.youtube_id === youtubeId); + const endpoint = video?.ignored + ? `/api/playlists/${playlistId}/videos/${youtubeId}/unignore` + : `/api/playlists/${playlistId}/videos/${youtubeId}/ignore`; + + await axios.post( + endpoint, + {}, + { + headers: { + 'x-access-token': token, + }, + } + ); + + await fetchVideos(); + } catch (error) { + console.error('Error toggling ignore:', error); + } + }; + + const handleCheckChange = (youtubeId: string) => { + setCheckedBoxes(prev => + prev.includes(youtubeId) + ? prev.filter(id => id !== youtubeId) + : [...prev, youtubeId] + ); + }; + + const handleSelectAll = () => { + if (checkedBoxes.length === paginatedVideos.length) { + setCheckedBoxes([]); + } else { + setCheckedBoxes(paginatedVideos.map(v => v.youtube_id)); + } + }; + + const formatDuration = (seconds: number | null) => { + if (!seconds) return '--:--'; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + const paginatedVideos = useMemo(() => videos, [videos]); + const totalPages = Math.ceil(totalCount / pageSize); + + return ( + + + + setSearchQuery(e.target.value)} + size="small" + sx={{ flexGrow: 1, minWidth: 200 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + newMode && setViewMode(newMode)} + size="small" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + setHideDownloaded(!hideDownloaded)} + color={hideDownloaded ? 'primary' : 'default'} + size="small" + /> + + + {totalCount} videos + + + {checkedBoxes.length > 0 && ( + setCheckedBoxes([])} + size="small" + /> + )} + + + + {totalPages > 1 && ( + + setPage(newPage)} + color="primary" + size={isMobile ? 'small' : 'medium'} + /> + + )} + + + {loading ? ( + + + Loading playlist videos... + + + {[...Array(pageSize)].map((_, index) => ( + + + + + ))} + + + ) : videos.length === 0 ? ( + + + {searchQuery ? 'No videos found matching your search' : 'No videos in this playlist'} + + + ) : ( + <> + {viewMode === 'grid' && ( + + {paginatedVideos.map((video) => ( + + + handleCheckChange(video.youtube_id)} + sx={{ position: 'absolute', top: 8, left: 8, zIndex: 1, bgcolor: 'rgba(0,0,0,0.5)' }} + /> + {video.added && ( + + )} + {video.ignored && ( + + )} + + + + {video.title} + + + + {formatDuration(video.duration)} + + toggleIgnore(video.youtube_id)} + color={video.ignored ? 'primary' : 'default'} + > + + + + + + + ))} + + )} + + {viewMode === 'list' && ( + + + + + + 0 && checkedBoxes.length < paginatedVideos.length} + checked={paginatedVideos.length > 0 && checkedBoxes.length === paginatedVideos.length} + onChange={handleSelectAll} + /> + + Title + Duration + Status + Actions + + + + {paginatedVideos.map((video) => ( + + + handleCheckChange(video.youtube_id)} + /> + + + + + {video.title} + + + {formatDuration(video.duration)} + + + {video.added && } + {video.ignored && } + + + + toggleIgnore(video.youtube_id)} + color={video.ignored ? 'primary' : 'default'} + > + + + + + ))} + +
+
+ )} + + {totalPages > 1 && ( + + setPage(newPage)} + color="primary" + size={isMobile ? 'small' : 'medium'} + /> + + )} + + )} +
+
+ ); +} + +export default PlaylistVideos; diff --git a/client/src/types/Playlist.ts b/client/src/types/Playlist.ts new file mode 100644 index 00000000..6a3407b4 --- /dev/null +++ b/client/src/types/Playlist.ts @@ -0,0 +1,30 @@ +export interface Playlist { + id?: string; + playlist_id: string; + title: string; + uploader: string; + uploader_id?: string; + url: string; + description?: string; + enabled: boolean; + auto_download_enabled: boolean; + folder_name?: string; + sub_folder?: string | null; + video_quality?: string | null; + min_duration?: number | null; + max_duration?: number | null; + title_filter_regex?: string | null; + audio_format?: string | null; +} + +export interface PlaylistVideo { + youtube_id: string; + title: string; + thumbnail: string; + duration: number | null; + publishedAt: string | null; + playlist_index: number | null; + added: boolean; + media_type: string; + ignored: boolean; +} diff --git a/docker-compose.yml b/docker-compose.yml index 1122b84b..85eb1aff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,9 @@ services: - ./server/images:/app/server/images - ./config:/app/config - ./jobs:/app/jobs + - ./server:/app/server + - ./client/build:/app/client/build + - ./migrations:/app/migrations healthcheck: test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--output", "/dev/null", "http://localhost:3011/api/health"] interval: 30s diff --git a/downloads/README.md b/downloads/README.md deleted file mode 100644 index a7646088..00000000 --- a/downloads/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# This is the default directory where videos where be downloaded -# To override it, set a different path for YOUTUBE_OUTPUT_DIR in your .env file diff --git a/migrations/20260129000000-create-playlists-table.js b/migrations/20260129000000-create-playlists-table.js new file mode 100644 index 00000000..6b6ea23b --- /dev/null +++ b/migrations/20260129000000-create-playlists-table.js @@ -0,0 +1,175 @@ +'use strict'; + +const { addColumnIfMissing } = require('./helpers'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Create playlists table + await queryInterface.createTable('playlists', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + playlist_id: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + title: { + type: Sequelize.STRING, + allowNull: true, + }, + url: { + type: Sequelize.STRING, + allowNull: true, + }, + description: { + type: Sequelize.TEXT, + allowNull: true, + }, + uploader: { + type: Sequelize.STRING, + allowNull: true, + }, + uploader_id: { + type: Sequelize.STRING, + allowNull: true, + }, + folder_name: { + type: Sequelize.STRING(255), + allowNull: true, + defaultValue: null, + }, + lastFetched: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + auto_download_enabled: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + sub_folder: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + video_quality: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + min_duration: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + max_duration: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + title_filter_regex: { + type: Sequelize.TEXT, + allowNull: true, + defaultValue: null, + }, + audio_format: { + type: Sequelize.STRING(20), + allowNull: true, + defaultValue: null, + }, + }); + + // Create playlistvideos join table (similar to channelvideos) + await queryInterface.createTable('playlistvideos', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + youtube_id: { + type: Sequelize.STRING, + allowNull: false, + }, + playlist_id: { + type: Sequelize.STRING, + allowNull: false, + }, + title: { + type: Sequelize.STRING, + allowNull: false, + }, + thumbnail: { + type: Sequelize.STRING, + allowNull: false, + }, + duration: { + type: Sequelize.INTEGER, + allowNull: true, + }, + publishedAt: { + type: Sequelize.STRING, + allowNull: true, + }, + availability: { + type: Sequelize.STRING, + allowNull: true, + defaultValue: null, + }, + media_type: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'video', + }, + youtube_removed: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + youtube_removed_checked_at: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + playlist_index: { + type: Sequelize.INTEGER, + allowNull: true, + defaultValue: null, + }, + ignored: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + ignored_at: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + }); + + // Add indexes for performance + await queryInterface.addIndex('playlistvideos', ['playlist_id']); + await queryInterface.addIndex('playlistvideos', ['youtube_id']); + await queryInterface.addIndex('playlistvideos', ['playlist_id', 'youtube_id'], { + unique: true, + name: 'playlistvideos_unique_playlist_video' + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('playlistvideos'); + await queryInterface.dropTable('playlists'); + } +}; diff --git a/server/models/playlist.js b/server/models/playlist.js new file mode 100644 index 00000000..5905eb20 --- /dev/null +++ b/server/models/playlist.js @@ -0,0 +1,98 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class Playlist extends Model {} + +Playlist.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + playlist_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + title: { + type: DataTypes.STRING, + allowNull: true, + }, + url: { + type: DataTypes.STRING, + allowNull: true, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + uploader: { + type: DataTypes.STRING, + allowNull: true, + }, + uploader_id: { + type: DataTypes.STRING, + allowNull: true, + }, + folder_name: { + type: DataTypes.STRING(255), + allowNull: true, + defaultValue: null, + }, + lastFetched: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + auto_download_enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + sub_folder: { + type: DataTypes.TEXT, + allowNull: true, + defaultValue: null, + }, + video_quality: { + type: DataTypes.TEXT, + allowNull: true, + defaultValue: null, + }, + min_duration: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + }, + max_duration: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + }, + title_filter_regex: { + type: DataTypes.TEXT, + allowNull: true, + defaultValue: null, + }, + audio_format: { + type: DataTypes.STRING(20), + allowNull: true, + defaultValue: null, + }, + }, + { + sequelize, + modelName: 'Playlist', + timestamps: false, + tableName: 'playlists', + } +); + +module.exports = Playlist; diff --git a/server/models/playlistvideo.js b/server/models/playlistvideo.js new file mode 100644 index 00000000..247c16cc --- /dev/null +++ b/server/models/playlistvideo.js @@ -0,0 +1,82 @@ +const { Model, DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +class PlaylistVideo extends Model {} + +PlaylistVideo.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + youtube_id: { + type: DataTypes.STRING, + allowNull: false, + }, + playlist_id: { + type: DataTypes.STRING, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + thumbnail: { + type: DataTypes.STRING, + allowNull: false, + }, + duration: { + type: DataTypes.INTEGER, + allowNull: true, + }, + publishedAt: { + type: DataTypes.STRING, + allowNull: true, + }, + availability: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null, + }, + media_type: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'video', + }, + youtube_removed: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + youtube_removed_checked_at: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + playlist_index: { + type: DataTypes.INTEGER, + allowNull: true, + defaultValue: null, + }, + ignored: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + ignored_at: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + }, + { + sequelize, + modelName: 'PlaylistVideo', + tableName: 'playlistvideos', + timestamps: false, + } +); + +module.exports = PlaylistVideo; diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 9b8a17af..29d9c836 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -500,6 +500,41 @@ class YtdlpCommandBuilder { return args; } + + /** + * Build args for fetching playlist information + * @param {string} playlistUrl - Playlist URL + * @param {string} outputFile - Output JSON file path + * @returns {Array} - yt-dlp arguments + */ + static buildPlaylistInfoArgs(playlistUrl, outputFile) { + const config = configModule.getConfig(); + const args = this.buildCommonArgs(config); + args.push( + '--flat-playlist', + '--dump-single-json', // Get playlist metadata with entries array + '--playlist-end', '1', // Just get first video to verify playlist exists + playlistUrl + ); + return args; + } + + /** + * Build args for fetching playlist video list + * @param {string} playlistUrl - Playlist URL + * @param {string} outputFile - Output JSON file path + * @returns {Array} - yt-dlp arguments + */ + static buildPlaylistVideoListArgs(playlistUrl, outputFile) { + const config = configModule.getConfig(); + const args = this.buildCommonArgs(config); + args.push( + '--flat-playlist', + '--dump-json', + playlistUrl + ); + return args; + } } module.exports = YtdlpCommandBuilder; diff --git a/server/modules/playlistModule.js b/server/modules/playlistModule.js new file mode 100644 index 00000000..ab4c03cf --- /dev/null +++ b/server/modules/playlistModule.js @@ -0,0 +1,977 @@ +const configModule = require('./configModule'); +const downloadModule = require('./downloadModule'); +const tempPathManager = require('./download/tempPathManager'); +const cron = require('node-cron'); +const fs = require('fs-extra'); +const path = require('path'); +const Playlist = require('../models/playlist'); +const PlaylistVideo = require('../models/playlistvideo'); +const Video = require('../models/video'); +const MessageEmitter = require('./messageEmitter.js'); +const { Op, fn, col, where } = require('sequelize'); +const fileCheckModule = require('./fileCheckModule'); +const logger = require('../logger'); +const { sanitizeNameLikeYtDlp } = require('./filesystem'); + +const { spawn } = require('child_process'); + +const SUB_FOLDER_DEFAULT_KEY = '__default__'; +const MAX_LOAD_MORE_VIDEOS = 5000; + +class PlaylistModule { + constructor() { + this.playlistAutoDownload = this.playlistAutoDownload.bind(this); + this.scheduleTask(); + this.subscribe(); + + // Listen for config changes to reschedule task + configModule.onConfigChange(() => { + this.scheduleTask(); + }); + + // Track active fetch operations per playlist to prevent concurrent fetches + this.activeFetches = new Map(); + } + + /** + * Check if a fetch operation is currently in progress for a playlist + * @param {string} playlistId - Playlist ID to check + * @returns {Object} - Object with isFetching boolean and operation details if fetching + */ + isFetchInProgress(playlistId) { + if (this.activeFetches.has(playlistId)) { + const activeOperation = this.activeFetches.get(playlistId); + return { + isFetching: true, + startTime: activeOperation.startTime, + type: activeOperation.type + }; + } + return { isFetching: false }; + } + + /** + * Execute yt-dlp command with promise-based handling + * @param {Array} args - Pre-built arguments for yt-dlp command + * @param {string|null} outputFile - Optional output file path + * @returns {Promise} - Output content if outputFile provided, or stdout + */ + async executeYtDlpCommand(args, outputFile = null) { + return new Promise((resolve, reject) => { + const ytdlp = spawn('yt-dlp', args); + let stdout = ''; + let stderr = ''; + + // Capture stdout (for --dump-json output) + ytdlp.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + ytdlp.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + ytdlp.on('close', async (code) => { + if (code !== 0) { + reject(new Error(`yt-dlp exited with code ${code}: ${stderr}`)); + } else { + if (outputFile) { + try { + // Write stdout to file + await fs.writeFile(outputFile, stdout, 'utf8'); + resolve(stdout); + } catch (writeError) { + reject(new Error(`Failed to write output file: ${writeError.message}`)); + } + } else { + resolve(stdout); + } + } + }); + + ytdlp.on('error', (err) => { + reject(new Error(`Failed to spawn yt-dlp: ${err.message}`)); + }); + }); + } + + /** + * Fetch playlist metadata from YouTube + * @param {string} playlistUrl - Playlist URL + * @returns {Promise} - Playlist metadata + */ + async fetchPlaylistMetadata(playlistUrl) { + const YtdlpCommandBuilder = require('./download/ytdlpCommandBuilder'); + const tempFile = path.join(tempPathManager.getTempBasePath(), `playlist-${Date.now()}.json`); + + try { + const args = YtdlpCommandBuilder.buildPlaylistInfoArgs(playlistUrl, tempFile); + const output = await this.executeYtDlpCommand(args, tempFile); + const jsonOutput = JSON.parse(output); + + return jsonOutput; + } finally { + // Clean up temp file + if (fs.existsSync(tempFile)) { + await fs.unlink(tempFile); + } + } + } + + /** + * Find playlist by URL or ID + * @param {string} playlistUrlOrId - Playlist URL or ID + * @returns {Promise} - Object with foundPlaylist and playlistUrl + */ + async findPlaylistByUrlOrId(playlistUrlOrId) { + let playlistUrl = playlistUrlOrId; + + // If it looks like a playlist ID (starts with PL), construct the URL + if (playlistUrlOrId.startsWith('PL')) { + playlistUrl = `https://www.youtube.com/playlist?list=${playlistUrlOrId}`; + } + + // Try to find existing playlist by URL or ID + const foundPlaylist = await Playlist.findOne({ + where: { + [Op.or]: [ + { url: playlistUrl }, + { playlist_id: playlistUrlOrId } + ] + } + }); + + return { foundPlaylist, playlistUrl }; + } + + /** + * Map playlist database record to response format + * @param {Object} playlist - Playlist database record + * @returns {Object} - Formatted playlist response + */ + mapPlaylistToResponse(playlist) { + return { + id: playlist.playlist_id, + playlist_id: playlist.playlist_id, + uploader: playlist.uploader, + uploader_id: playlist.uploader_id, + title: playlist.title, + description: playlist.description, + url: playlist.url, + enabled: playlist.enabled, + auto_download_enabled: playlist.auto_download_enabled, + sub_folder: playlist.sub_folder, + video_quality: playlist.video_quality, + folder_name: playlist.folder_name, + min_duration: playlist.min_duration, + max_duration: playlist.max_duration, + title_filter_regex: playlist.title_filter_regex, + audio_format: playlist.audio_format, + }; + } + + /** + * Get playlist information from database or fetch from YouTube + * @param {string} playlistUrlOrId - YouTube playlist URL or playlist ID + * @param {boolean} emitMessage - Whether to emit WebSocket update message + * @param {boolean} enablePlaylist - Whether to enable the playlist if it's new + * @returns {Promise} - Playlist information object + */ + async getPlaylistInfo(playlistUrlOrId, emitMessage = true, enablePlaylist = false) { + const { foundPlaylist, playlistUrl } = await this.findPlaylistByUrlOrId(playlistUrlOrId); + + if (foundPlaylist) { + if (emitMessage) { + MessageEmitter.emitMessage( + 'broadcast', + null, + 'playlist', + 'playlistsUpdated', + { text: 'Playlist Updated' } + ); + } + return this.mapPlaylistToResponse(foundPlaylist); + } + + logger.info('Fetching playlist metadata from YouTube'); + const playlistData = await this.fetchPlaylistMetadata(playlistUrl); + logger.info('Playlist metadata fetched successfully'); + + // Reject playlists with no videos + if (!playlistData.entries || playlistData.entries.length === 0) { + const error = new Error('Playlist has no videos'); + error.code = 'PLAYLIST_EMPTY'; + throw error; + } + + const playlistId = playlistData.id; + const folderName = sanitizeNameLikeYtDlp(playlistData.title || playlistData.uploader || playlistId); + + // Create the playlist record + await this.upsertPlaylist({ + id: playlistId, + title: playlistData.title, + description: playlistData.description, + uploader: playlistData.uploader, + uploader_id: playlistData.uploader_id || playlistData.channel_id, + url: playlistUrl, + folder_name: folderName, + }, enablePlaylist); + + if (emitMessage) { + logger.debug('Playlist data fetched, emitting update message'); + MessageEmitter.emitMessage( + 'broadcast', + null, + 'playlist', + 'playlistsUpdated', + { text: 'Playlist Updated' } + ); + } + + return { + id: playlistId, + playlist_id: playlistId, + uploader: playlistData.uploader, + uploader_id: playlistData.uploader_id || playlistData.channel_id, + title: playlistData.title, + description: playlistData.description, + url: playlistUrl, + enabled: enablePlaylist, + auto_download_enabled: false, + sub_folder: null, + video_quality: null, + folder_name: folderName, + }; + } + + /** + * Upsert playlist record in database + * @param {Object} playlistData - Playlist data to upsert + * @param {boolean} enable - Whether to enable the playlist + * @returns {Promise} - Created or updated playlist + */ + async upsertPlaylist(playlistData, enable = false) { + const [playlist, created] = await Playlist.findOrCreate({ + where: { playlist_id: playlistData.id }, + defaults: { + playlist_id: playlistData.id, + title: playlistData.title, + description: playlistData.description, + uploader: playlistData.uploader, + uploader_id: playlistData.uploader_id, + url: playlistData.url, + folder_name: playlistData.folder_name, + enabled: enable, + auto_download_enabled: false, + }, + }); + + if (!created) { + // Update existing playlist + await playlist.update({ + title: playlistData.title, + description: playlistData.description, + uploader: playlistData.uploader, + uploader_id: playlistData.uploader_id, + url: playlistData.url, + folder_name: playlistData.folder_name, + }); + } + + return playlist; + } + + /** + * Get paginated list of playlists + * @param {Object} options - Query options + * @returns {Promise} - Paginated playlists response + */ + async getPlaylistsPaginated({ page = 1, pageSize = 20, searchTerm = '', sortBy = 'uploader', sortOrder = 'ASC', subFolder = null }) { + const offset = (page - 1) * pageSize; + const limit = parseInt(pageSize); + + const whereClause = {}; + if (searchTerm) { + whereClause[Op.or] = [ + { title: { [Op.like]: `%${searchTerm}%` } }, + { uploader: { [Op.like]: `%${searchTerm}%` } }, + ]; + } + + if (subFolder !== null && subFolder !== undefined) { + const normalizedSubFolder = subFolder === SUB_FOLDER_DEFAULT_KEY ? null : subFolder; + whereClause.sub_folder = normalizedSubFolder; + } + + const order = [[sortBy, sortOrder]]; + + const { count, rows } = await Playlist.findAndCountAll({ + where: whereClause, + order, + limit, + offset, + }); + + // Get unique subfolders for filter + const subFolders = await Playlist.findAll({ + attributes: [[fn('DISTINCT', col('sub_folder')), 'sub_folder']], + raw: true, + }); + + return { + playlists: rows.map(p => this.mapPlaylistToResponse(p)), + total: count, + page: parseInt(page), + totalPages: Math.ceil(count / limit), + subFolders: subFolders.map(sf => sf.sub_folder), + }; + } + + /** + * Update playlist settings + * @param {string} playlistId - Playlist ID + * @param {Object} updates - Settings to update + * @returns {Promise} - Updated playlist + */ + async updatePlaylistSettings(playlistId, updates) { + const playlist = await Playlist.findOne({ + where: { playlist_id: playlistId } + }); + + if (!playlist) { + throw new Error('Playlist not found'); + } + + await playlist.update(updates); + + MessageEmitter.emitMessage( + 'broadcast', + null, + 'playlist', + 'playlistsUpdated', + { text: 'Playlist settings updated' } + ); + + return this.mapPlaylistToResponse(playlist); + } + + /** + * Delete playlist and associated videos + * @param {string} playlistId - Playlist ID + * @returns {Promise} + */ + async deletePlaylist(playlistId) { + await PlaylistVideo.destroy({ + where: { playlist_id: playlistId } + }); + + await Playlist.destroy({ + where: { playlist_id: playlistId } + }); + + MessageEmitter.emitMessage( + 'broadcast', + null, + 'playlist', + 'playlistsUpdated', + { text: 'Playlist deleted' } + ); + } + + /** + * Fetch playlist videos from YouTube + * @param {string} playlistId - Playlist ID + * @param {Date|null} mostRecentVideoDate - Date of most recent video we have + * @returns {Promise} - Object with videos array + */ + async fetchPlaylistVideosViaYtDlp(playlistId, mostRecentVideoDate = null) { + const playlist = await Playlist.findOne({ + where: { playlist_id: playlistId } + }); + + if (!playlist) { + throw new Error('Playlist not found in database'); + } + + const YtdlpCommandBuilder = require('./download/ytdlpCommandBuilder'); + const tempFile = path.join(tempPathManager.getTempBasePath(), `playlist-videos-${Date.now()}.json`); + + try { + const playlistUrl = playlist.url || `https://www.youtube.com/playlist?list=${playlistId}`; + const args = YtdlpCommandBuilder.buildPlaylistVideoListArgs(playlistUrl, tempFile); + + const output = await this.executeYtDlpCommand(args, tempFile); + + // Parse multiple JSON objects (one per line) + const videos = output + .split('\n') + .filter(line => line.trim()) + .map((line, index) => { + try { + const entry = JSON.parse(line); + return { + youtube_id: entry.id, + title: entry.title, + thumbnail: entry.thumbnail || entry.thumbnails?.[0]?.url, + duration: entry.duration, + publishedAt: entry.timestamp ? new Date(entry.timestamp * 1000).toISOString() : null, + playlist_index: entry.playlist_index || index + 1, + availability: entry.availability, + }; + } catch (err) { + logger.error({ err, line }, 'Failed to parse video entry'); + return null; + } + }) + .filter(v => v !== null); + + return { videos }; + } finally { + if (fs.existsSync(tempFile)) { + await fs.unlink(tempFile); + } + } + } + + /** + * Fetch all videos for a playlist and store in database + * @param {string} playlistId - Playlist ID + * @param {number} requestedPage - Page number for pagination + * @param {number} requestedPageSize - Page size for pagination + * @param {boolean} hideDownloaded - Whether to hide downloaded videos + * @returns {Promise} - Response with videos and metadata + */ + async fetchAllPlaylistVideos(playlistId, requestedPage = 1, requestedPageSize = 50, hideDownloaded = false) { + const fetchKey = playlistId; + const startTime = Date.now(); + + // Check if fetch is already in progress + if (this.activeFetches.has(fetchKey)) { + throw new Error(`Fetch operation is already in progress for playlist ${playlistId}`); + } + + // Mark fetch as active + this.activeFetches.set(fetchKey, { + startTime: new Date(), + type: 'full_fetch' + }); + + try { + const playlist = await Playlist.findOne({ + where: { playlist_id: playlistId } + }); + + if (!playlist) { + throw new Error('Playlist not found'); + } + + // Get most recent video date + const mostRecentVideo = await PlaylistVideo.findOne({ + where: { playlist_id: playlistId }, + order: [['publishedAt', 'DESC']], + }); + + const mostRecentVideoDate = mostRecentVideo?.publishedAt + ? new Date(mostRecentVideo.publishedAt) + : null; + + // Fetch videos from YouTube + const { videos } = await this.fetchPlaylistVideosViaYtDlp(playlistId, mostRecentVideoDate); + + // Store videos in database + for (const video of videos) { + await PlaylistVideo.findOrCreate({ + where: { + playlist_id: playlistId, + youtube_id: video.youtube_id, + }, + defaults: { + ...video, + playlist_id: playlistId, + media_type: 'video', + }, + }); + } + + // Update lastFetched timestamp + await playlist.update({ lastFetched: new Date() }); + + // Get paginated results + const result = await this.getPlaylistVideos( + playlistId, + requestedPage, + requestedPageSize, + hideDownloaded + ); + + const elapsedSeconds = (Date.now() - startTime) / 1000; + logger.info({ + playlistId, + elapsedSeconds, + videosFound: videos.length + }, 'Full playlist fetch completed'); + + return { + success: true, + videosFound: videos.length, + elapsedSeconds: elapsedSeconds, + ...result + }; + + } catch (error) { + logger.error({ err: error, playlistId }, 'Error fetching playlist videos'); + throw error; + } finally { + // Always clear the active fetch record + this.activeFetches.delete(fetchKey); + } + } + + /** * Get a single playlist by ID + * @param {string} playlistId - Playlist ID + * @returns {Promise} - Playlist data or null if not found + */ + async getPlaylist(playlistId) { + try { + const playlist = await Playlist.findOne({ + where: { playlist_id: playlistId }, + }); + + if (!playlist) { + return null; + } + + return playlist.dataValues; + } catch (error) { + logger.error({ err: error, playlistId }, 'Error fetching playlist'); + throw error; + } + } + + /** * Get paginated playlist videos + * @param {string} playlistId - Playlist ID + * @param {number} page - Page number + * @param {number} pageSize - Page size + * @param {boolean} hideDownloaded - Whether to hide downloaded videos + * @param {string} searchQuery - Search query + * @param {string} sortBy - Sort field + * @param {string} sortOrder - Sort order + * @returns {Promise} - Paginated videos response + */ + async getPlaylistVideos(playlistId, page = 1, pageSize = 50, hideDownloaded = false, searchQuery = '', sortBy = 'playlist_index', sortOrder = 'asc') { + const offset = (page - 1) * pageSize; + + const whereClause = { playlist_id: playlistId }; + + if (searchQuery) { + whereClause.title = { [Op.like]: `%${searchQuery}%` }; + } + + if (hideDownloaded) { + // Check against videos table + const downloadedVideoIds = await Video.findAll({ + attributes: ['youtubeId'], + where: { removed: false }, + raw: true, + }); + + const downloadedIds = downloadedVideoIds.map(v => v.youtubeId); + if (downloadedIds.length > 0) { + whereClause.youtube_id = { [Op.notIn]: downloadedIds }; + } + } + + // Map sort fields + const sortField = sortBy === 'date' ? 'publishedAt' : sortBy; + const order = [[sortField, sortOrder.toUpperCase()]]; + + const { count, rows } = await PlaylistVideo.findAndCountAll({ + where: whereClause, + order, + limit: pageSize, + offset, + }); + + // Check which videos are already downloaded + const youtubeIds = rows.map(v => v.youtube_id); + const downloadedVideos = await Video.findAll({ + where: { youtubeId: { [Op.in]: youtubeIds } }, + attributes: ['youtubeId'], + raw: true, + }); + + const downloadedSet = new Set(downloadedVideos.map(v => v.youtubeId)); + + const videosWithDownloadStatus = rows.map(v => ({ + youtube_id: v.youtube_id, + title: v.title, + thumbnail: v.thumbnail, + duration: v.duration, + publishedAt: v.publishedAt, + playlist_index: v.playlist_index, + added: downloadedSet.has(v.youtube_id), + media_type: v.media_type, + ignored: v.ignored, + })); + + return { + videos: videosWithDownloadStatus, + totalCount: count, + page: parseInt(page), + totalPages: Math.ceil(count / pageSize), + }; + } + + /** + * Subscribe to configuration changes + */ + subscribe() { + configModule.onConfigChange(this.scheduleTask.bind(this)); + } + + /** + * Schedule automatic playlist download task + */ + scheduleTask() { + if (this.task) { + this.task.stop(); + this.task = null; + } + + const config = configModule.getConfig(); + const isAutoDownloadEnabled = config.playlistAutoDownload; + const cronSchedule = config.playlistDownloadFrequency || '0 */6 * * *'; // Default: every 6 hours + + if (isAutoDownloadEnabled) { + logger.info({ cronSchedule }, 'Scheduling playlist auto-download task'); + this.task = cron.schedule(cronSchedule, this.playlistAutoDownload, { + scheduled: true, + }); + } else { + logger.info('Playlist auto-download is disabled'); + } + } + + /** + * Auto-download videos from enabled playlists + */ + async playlistAutoDownload() { + logger.info('Starting automatic playlist download'); + + try { + const enabledPlaylists = await Playlist.findAll({ + where: { + enabled: true, + auto_download_enabled: true, + }, + }); + + logger.info({ count: enabledPlaylists.length }, 'Found enabled playlists for auto-download'); + + for (const playlist of enabledPlaylists) { + try { + // Fetch latest videos + await this.fetchAllPlaylistVideos(playlist.playlist_id, 1, 50, false); + + // Get videos that aren't downloaded yet + const { videos } = await this.getPlaylistVideos( + playlist.playlist_id, + 1, + 1000, // Get many videos to check + true // hideDownloaded + ); + + // Apply filters if configured + let filteredVideos = videos.filter(v => !v.ignored); + + if (playlist.min_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration >= playlist.min_duration); + } + + if (playlist.max_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration <= playlist.max_duration); + } + + if (playlist.title_filter_regex) { + const regex = new RegExp(playlist.title_filter_regex, 'i'); + filteredVideos = filteredVideos.filter(v => regex.test(v.title)); + } + + // Download videos using the same method as manual downloads + if (filteredVideos.length > 0) { + const urls = filteredVideos.map(video => + `https://www.youtube.com/watch?v=${video.youtube_id}` + ); + + const overrideSettings = {}; + if (playlist.video_quality) { + overrideSettings.resolution = playlist.video_quality; + } + if (playlist.sub_folder) { + overrideSettings.subfolder = playlist.sub_folder; + } + + await downloadModule.doSpecificDownloads({ + body: { + urls: urls, + overrideSettings: Object.keys(overrideSettings).length > 0 ? overrideSettings : undefined, + initiatedBy: { + type: 'playlist_auto', + name: playlist.title || `Playlist ${playlist.playlist_id}` + } + } + }); + } + + logger.info({ + playlistId: playlist.playlist_id, + videosQueued: filteredVideos.length + }, 'Queued playlist videos for download'); + + } catch (error) { + logger.error({ err: error, playlistId: playlist.playlist_id }, 'Error auto-downloading from playlist'); + } + } + + logger.info('Automatic playlist download completed'); + } catch (error) { + logger.error({ err: error }, 'Error in playlist auto-download'); + } + } + + /** + * Toggle ignore status for a playlist video + * @param {string} playlistId - Playlist ID + * @param {string} youtubeId - YouTube video ID + * @param {boolean} ignored - Whether to ignore the video + * @returns {Promise} + */ + async togglePlaylistVideoIgnore(playlistId, youtubeId, ignored) { + const playlistVideo = await PlaylistVideo.findOne({ + where: { playlist_id: playlistId, youtube_id: youtubeId } + }); + + if (!playlistVideo) { + throw new Error('Playlist video not found'); + } + + await playlistVideo.update({ + ignored, + ignored_at: ignored ? new Date() : null, + }); + } + /** + * Queue all non-ignored videos from a playlist for download + * @param {string} playlistId - Playlist ID + * @returns {Promise} - Result with jobId and videoCount + */ + async queuePlaylistDownload(playlistId) { + // Get playlist + const playlist = await Playlist.findOne({ + where: { playlist_id: playlistId } + }); + + if (!playlist) { + const error = new Error('Playlist not found'); + error.code = 'PLAYLIST_NOT_FOUND'; + throw error; + } + + // Get all non-ignored videos + const videos = await PlaylistVideo.findAll({ + where: { + playlist_id: playlistId, + ignored: false + }, + order: [['playlist_index', 'ASC']] + }); + + if (videos.length === 0) { + return { + message: 'No videos to download', + videoCount: 0, + jobId: null + }; + } + + // Apply playlist filters + let filteredVideos = [...videos]; + + if (playlist.min_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration >= playlist.min_duration); + } + + if (playlist.max_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration <= playlist.max_duration); + } + + if (playlist.title_filter_regex) { + const regex = new RegExp(playlist.title_filter_regex, 'i'); + filteredVideos = filteredVideos.filter(v => regex.test(v.title)); + } + + logger.info({ + playlistId, + totalVideos: videos.length, + filteredVideos: filteredVideos.length + }, 'Queueing playlist videos for download'); + + // Build URL array for download + const urls = filteredVideos.map(video => + `https://www.youtube.com/watch?v=${video.youtube_id}` + ); + + if (urls.length === 0) { + return { + message: 'No videos to download after applying filters', + videoCount: 0, + jobId: null + }; + } + + // Build override settings for quality and subfolder + const overrideSettings = {}; + if (playlist.video_quality) { + overrideSettings.resolution = playlist.video_quality; + } + if (playlist.sub_folder) { + overrideSettings.subfolder = playlist.sub_folder; + } + + // Queue all videos as a single download job + await downloadModule.doSpecificDownloads({ + body: { + urls: urls, + overrideSettings: Object.keys(overrideSettings).length > 0 ? overrideSettings : undefined, + initiatedBy: { + type: 'playlist', + name: playlist.title || `Playlist ${playlistId}` + } + } + }); + + return { + message: `Queued ${filteredVideos.length} videos for download`, + videoCount: filteredVideos.length + }; + } + + /** + * Manually download new videos from all enabled playlists + * Similar to playlistAutoDownload but can be triggered manually + * @param {Object} overrideSettings - Optional override settings for resolution, videoCount, etc. + * @returns {Promise} - Summary of downloads queued + */ + async downloadAllPlaylists(overrideSettings = {}) { + logger.info('Starting manual download from all playlists'); + + try { + const enabledPlaylists = await Playlist.findAll({ + where: { + enabled: true, + auto_download_enabled: true, + }, + }); + + logger.info({ count: enabledPlaylists.length }, 'Found enabled playlists for manual download'); + + let totalVideosQueued = 0; + const results = []; + + for (const playlist of enabledPlaylists) { + try { + // Fetch latest videos + await this.fetchAllPlaylistVideos(playlist.playlist_id, 1, 50, false); + + // Get videos that aren't downloaded yet + const { videos } = await this.getPlaylistVideos( + playlist.playlist_id, + 1, + 1000, // Get many videos to check + true // hideDownloaded + ); + + // Apply filters if configured + let filteredVideos = videos.filter(v => !v.ignored); + + if (playlist.min_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration >= playlist.min_duration); + } + + if (playlist.max_duration) { + filteredVideos = filteredVideos.filter(v => !v.duration || v.duration <= playlist.max_duration); + } + + if (playlist.title_filter_regex) { + const regex = new RegExp(playlist.title_filter_regex, 'i'); + filteredVideos = filteredVideos.filter(v => regex.test(v.title)); + } + + // Download videos using the same method as manual downloads + if (filteredVideos.length > 0) { + const urls = filteredVideos.map(video => + `https://www.youtube.com/watch?v=${video.youtube_id}` + ); + + const playlistOverrideSettings = {}; + + // Use override settings first, then playlist settings + if (overrideSettings.resolution || playlist.video_quality) { + playlistOverrideSettings.resolution = overrideSettings.resolution || playlist.video_quality; + } + if (playlist.sub_folder) { + playlistOverrideSettings.subfolder = playlist.sub_folder; + } + + await downloadModule.doSpecificDownloads({ + body: { + urls: urls, + overrideSettings: Object.keys(playlistOverrideSettings).length > 0 ? playlistOverrideSettings : undefined, + initiatedBy: { + type: 'playlist_manual', + name: playlist.title || `Playlist ${playlist.playlist_id}` + } + } + }); + + totalVideosQueued += filteredVideos.length; + results.push({ + playlistId: playlist.playlist_id, + playlistTitle: playlist.title, + videosQueued: filteredVideos.length + }); + } + + logger.info({ + playlistId: playlist.playlist_id, + videosQueued: filteredVideos.length + }, 'Queued playlist videos for manual download'); + + } catch (error) { + logger.error({ err: error, playlistId: playlist.playlist_id }, 'Error manually downloading from playlist'); + results.push({ + playlistId: playlist.playlist_id, + playlistTitle: playlist.title, + error: error.message + }); + } + } + + logger.info({ totalVideosQueued }, 'Manual download from all playlists completed'); + + return { + success: true, + message: `Queued ${totalVideosQueued} videos from ${enabledPlaylists.length} playlist(s)`, + totalVideos: totalVideosQueued, + playlistCount: enabledPlaylists.length, + results + }; + } catch (error) { + logger.error({ err: error }, 'Error in manual playlist downloads'); + throw error; + } + } +} + +module.exports = new PlaylistModule(); diff --git a/server/routes/index.js b/server/routes/index.js index 702db362..17713ec6 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -3,6 +3,7 @@ const createAuthRoutes = require('./auth'); const createSetupRoutes = require('./setup'); const createConfigRoutes = require('./config'); const createChannelRoutes = require('./channels'); +const createPlaylistRoutes = require('./playlists'); const createVideoRoutes = require('./videos'); const createJobRoutes = require('./jobs'); const createPlexRoutes = require('./plex'); @@ -19,6 +20,7 @@ function registerRoutes(app, deps) { loginLimiter, configModule, channelModule, + playlistModule, plexModule, downloadModule, jobModule, @@ -45,6 +47,9 @@ function registerRoutes(app, deps) { // Channel routes app.use(createChannelRoutes({ verifyToken, channelModule, archiveModule })); + // Playlist routes + app.use(createPlaylistRoutes({ verifyToken, playlistModule })); + // Video routes app.use(createVideoRoutes({ verifyToken, videosModule, downloadModule })); diff --git a/server/routes/playlists.js b/server/routes/playlists.js new file mode 100644 index 00000000..5a653684 --- /dev/null +++ b/server/routes/playlists.js @@ -0,0 +1,757 @@ +const express = require('express'); +const router = express.Router(); + +/** + * Creates playlist routes + * @param {Object} deps - Dependencies + * @param {Function} deps.verifyToken - Token verification middleware + * @param {Object} deps.playlistModule - Playlist module + * @returns {express.Router} + */ +module.exports = function createPlaylistRoutes({ verifyToken, playlistModule }) { + const PlaylistVideo = require('../models/playlistvideo'); + + /** + * @swagger + * /getplaylists: + * get: + * summary: Get playlists list + * description: Retrieve a paginated list of YouTube playlists. + * tags: [Playlists] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: Page number + * - in: query + * name: pageSize + * schema: + * type: integer + * default: 20 + * description: Items per page + * - in: query + * name: search + * schema: + * type: string + * description: Search term for filtering playlists + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [uploader, title] + * default: uploader + * description: Field to sort by + * - in: query + * name: sortOrder + * schema: + * type: string + * enum: [ASC, DESC] + * default: ASC + * description: Sort order + * - in: query + * name: subFolder + * schema: + * type: string + * description: Filter by subfolder + * responses: + * 200: + * description: Paginated list of playlists + * 500: + * description: Failed to fetch playlists + */ + router.get('/getplaylists', verifyToken, async (req, res) => { + try { + const result = await playlistModule.getPlaylistsPaginated({ + page: req.query.page, + pageSize: req.query.pageSize, + searchTerm: req.query.search, + sortBy: req.query.sortBy, + sortOrder: req.query.sortOrder, + subFolder: req.query.subFolder, + }); + res.json(result); + } catch (error) { + req.log.error({ err: error }, 'Failed to fetch playlists'); + res.status(500).json({ error: 'Failed to fetch playlists' }); + } + }); + + /** + * @swagger + * /updateplaylists: + * post: + * summary: Update playlists + * description: Add, remove, or update YouTube playlists. + * tags: [Playlists] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * oneOf: + * - type: array + * items: + * type: object + * properties: + * url: + * type: string + * enabled: + * type: boolean + * - type: object + * properties: + * add: + * type: array + * items: + * type: string + * remove: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Playlists updated successfully + * 500: + * description: Failed to update playlists + */ + router.post('/updateplaylists', verifyToken, async (req, res) => { + try { + let playlistsToAdd = []; + let playlistsToRemove = []; + + // Support both array format and delta format + if (Array.isArray(req.body)) { + // Legacy array format (for backward compatibility if needed) + playlistsToAdd = req.body.filter(p => p.enabled); + } else if (req.body.add || req.body.remove) { + // Delta format + playlistsToAdd = req.body.add || []; + playlistsToRemove = req.body.remove || []; + } + + // Remove playlists + for (const playlistId of playlistsToRemove) { + await playlistModule.deletePlaylist(playlistId); + } + + // Add playlists + const addedPlaylists = []; + for (const playlistUrl of playlistsToAdd) { + try { + const playlistInfo = await playlistModule.getPlaylistInfo(playlistUrl, false, true); + addedPlaylists.push(playlistInfo); + } catch (error) { + req.log.error({ err: error, playlistUrl }, 'Failed to add playlist'); + } + } + + res.json({ + success: true, + added: addedPlaylists.length, + removed: playlistsToRemove.length, + }); + } catch (error) { + req.log.error({ err: error }, 'Failed to update playlists'); + res.status(500).json({ error: 'Failed to update playlists' }); + } + }); + + /** + * @swagger + * /addplaylistinfo: + * post: + * summary: Add playlist info + * description: Fetch and add information about a YouTube playlist by URL. + * tags: [Playlists] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - url + * properties: + * url: + * type: string + * description: YouTube playlist URL + * responses: + * 200: + * description: Playlist info retrieved successfully + * 400: + * description: URL is missing + * 500: + * description: Failed to get playlist info + */ + router.post('/addplaylistinfo', verifyToken, async (req, res) => { + const logger = require('../logger'); + logger.info('addplaylistinfo endpoint start'); + const url = req.body.url; + + if (!url) { + return res.status(400).json({ + status: 'error', + message: 'URL is missing in the request' + }); + } + + try { + req.log.info({ url }, 'Adding playlist info'); + let playlistInfo = await playlistModule.getPlaylistInfo(url, false); + logger.info('addplaylistinfo returning result'); + res.json({ status: 'success', playlistInfo: playlistInfo }); + } catch (error) { + req.log.error({ err: error, url }, 'Failed to get playlist info'); + + if (error.code === 'PLAYLIST_EMPTY') { + return res.status(404).json({ + status: 'error', + message: 'Playlist has no videos or could not be found' + }); + } + + res.status(500).json({ + status: 'error', + message: error.message || 'Failed to fetch playlist information' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}/settings: + * put: + * summary: Update playlist settings + * description: Update settings for a specific playlist. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * auto_download_enabled: + * type: boolean + * sub_folder: + * type: string + * video_quality: + * type: string + * min_duration: + * type: integer + * max_duration: + * type: integer + * title_filter_regex: + * type: string + * audio_format: + * type: string + * responses: + * 200: + * description: Settings updated successfully + * 404: + * description: Playlist not found + * 500: + * description: Failed to update settings + */ + router.put('/api/playlists/:playlistId/settings', verifyToken, async (req, res) => { + const { playlistId } = req.params; + + try { + const playlist = await playlistModule.updatePlaylistSettings(playlistId, req.body); + res.json({ success: true, playlist }); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to update playlist settings'); + + if (error.message === 'Playlist not found') { + return res.status(404).json({ error: 'Playlist not found' }); + } + + res.status(500).json({ error: 'Failed to update playlist settings' }); + } + }); + + /** + * @swagger + * /getplaylistvideos/{playlistId}: + * get: + * summary: Get playlist videos + * description: Retrieve a paginated list of videos for a specific playlist. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: pageSize + * schema: + * type: integer + * default: 50 + * - in: query + * name: hideDownloaded + * schema: + * type: boolean + * default: false + * - in: query + * name: searchQuery + * schema: + * type: string + * - in: query + * name: sortBy + * schema: + * type: string + * default: playlist_index + * - in: query + * name: sortOrder + * schema: + * type: string + * enum: [asc, desc] + * default: asc + * responses: + * 200: + * description: List of playlist videos + * 500: + * description: Failed to get playlist videos + */ + router.get('/getplaylistvideos/:playlistId', verifyToken, async (req, res) => { + req.log.info({ playlistId: req.params.playlistId }, 'Getting playlist videos'); + const playlistId = req.params.playlistId; + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 50; + const hideDownloaded = req.query.hideDownloaded === 'true'; + const searchQuery = req.query.searchQuery || ''; + const sortBy = req.query.sortBy || 'playlist_index'; + const sortOrder = req.query.sortOrder || 'asc'; + + try { + const result = await playlistModule.getPlaylistVideos( + playlistId, + page, + pageSize, + hideDownloaded, + searchQuery, + sortBy, + sortOrder + ); + + res.status(200).json(result); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to get playlist videos'); + res.status(500).json({ error: 'Failed to get playlist videos' }); + } + }); + + /** + * @swagger + * /fetchallplaylistvideos/{playlistId}: + * post: + * summary: Fetch all playlist videos + * description: Trigger a full fetch of all videos from a playlist. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * - in: query + * name: pageSize + * schema: + * type: integer + * default: 50 + * - in: query + * name: hideDownloaded + * schema: + * type: boolean + * default: false + * responses: + * 200: + * description: Fetch completed + * 409: + * description: Fetch operation already in progress + * 500: + * description: Failed to fetch videos + */ + router.post('/fetchallplaylistvideos/:playlistId', verifyToken, async (req, res) => { + req.log.info({ playlistId: req.params.playlistId }, 'Fetching all videos for playlist'); + const playlistId = req.params.playlistId; + const page = parseInt(req.query.page) || 1; + const pageSize = parseInt(req.query.pageSize) || 50; + const hideDownloaded = req.query.hideDownloaded === 'true'; + + try { + const result = await playlistModule.fetchAllPlaylistVideos(playlistId, page, pageSize, hideDownloaded); + res.status(200).json(result); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to fetch all playlist videos'); + + const isConcurrencyError = error.message.includes('fetch operation is already in progress'); + const statusCode = isConcurrencyError ? 409 : 500; + + res.status(statusCode).json({ + success: false, + error: isConcurrencyError ? 'FETCH_IN_PROGRESS' : 'Failed to fetch all playlist videos', + message: error.message + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}/fetch-status: + * get: + * summary: Get playlist fetch status + * description: Check if a fetch operation is in progress for a playlist. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Fetch status + * 500: + * description: Failed to get fetch status + */ + router.get('/api/playlists/:playlistId/fetch-status', verifyToken, async (req, res) => { + const { playlistId } = req.params; + + try { + const status = playlistModule.isFetchInProgress(playlistId); + res.status(200).json(status); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to get fetch status'); + res.status(500).json({ + isFetching: false, + error: 'Failed to get fetch status' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}/videos/{youtubeId}/ignore: + * post: + * summary: Ignore a video + * description: Mark a playlist video as ignored so it won't be downloaded. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * - in: path + * name: youtubeId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Video ignored successfully + * 404: + * description: Playlist video not found + * 500: + * description: Failed to ignore video + */ + router.post('/api/playlists/:playlistId/videos/:youtubeId/ignore', verifyToken, async (req, res) => { + const { playlistId, youtubeId } = req.params; + req.log.info({ playlistId, youtubeId }, 'Ignoring playlist video'); + + try { + await playlistModule.togglePlaylistVideoIgnore(playlistId, youtubeId, true); + res.status(200).json({ success: true }); + } catch (error) { + req.log.error({ err: error, playlistId, youtubeId }, 'Failed to ignore video'); + + if (error.message === 'Playlist video not found') { + return res.status(404).json({ + success: false, + error: 'Playlist video not found' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to ignore video' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}/videos/{youtubeId}/unignore: + * post: + * summary: Unignore a video + * description: Remove the ignore flag from a playlist video. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * - in: path + * name: youtubeId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Video unignored successfully + * 404: + * description: Playlist video not found + * 500: + * description: Failed to unignore video + */ + router.post('/api/playlists/:playlistId/videos/:youtubeId/unignore', verifyToken, async (req, res) => { + const { playlistId, youtubeId } = req.params; + req.log.info({ playlistId, youtubeId }, 'Unignoring playlist video'); + + try { + await playlistModule.togglePlaylistVideoIgnore(playlistId, youtubeId, false); + res.status(200).json({ success: true }); + } catch (error) { + req.log.error({ err: error, playlistId, youtubeId }, 'Failed to unignore video'); + + if (error.message === 'Playlist video not found') { + return res.status(404).json({ + success: false, + error: 'Playlist video not found' + }); + } + + res.status(500).json({ + success: false, + error: 'Failed to unignore video' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}/download: + * post: + * summary: Queue playlist videos for download + * description: Queue all videos from a playlist for download. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Videos queued successfully + * 404: + * description: Playlist not found + * 500: + * description: Failed to queue downloads + */ + router.post('/api/playlists/:playlistId/download', verifyToken, async (req, res) => { + const { playlistId } = req.params; + req.log.info({ playlistId }, 'Queueing playlist videos for download'); + + try { + const result = await playlistModule.queuePlaylistDownload(playlistId); + res.status(200).json({ + success: true, + message: result.message, + jobId: result.jobId, + videoCount: result.videoCount + }); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to queue playlist download'); + + if (error.code === 'PLAYLIST_NOT_FOUND') { + return res.status(404).json({ + success: false, + error: 'Playlist not found' + }); + } + + res.status(500).json({ + success: false, + error: error.message || 'Failed to queue playlist download' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}: + * delete: + * summary: Delete playlist + * description: Delete a playlist and all its associated videos. + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Playlist deleted successfully + * 500: + * description: Failed to delete playlist + */ + /** + * @swagger + * /api/playlists/{playlistId}: + * get: + * summary: Get a single playlist by ID + * tags: [Playlists] + * parameters: + * - in: path + * name: playlistId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Playlist details + * 404: + * description: Playlist not found + */ + router.get('/api/playlists/:playlistId', verifyToken, async (req, res) => { + const { playlistId } = req.params; + + try { + const playlist = await playlistModule.getPlaylist(playlistId); + + if (!playlist) { + return res.status(404).json({ + success: false, + error: 'Playlist not found' + }); + } + + res.status(200).json(playlist); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to fetch playlist'); + res.status(500).json({ + success: false, + error: 'Failed to fetch playlist' + }); + } + }); + + /** + * @swagger + * /api/playlists/download-all: + * post: + * summary: Download new videos from all enabled playlists + * description: Manually trigger download of new videos from all playlists that have auto-download enabled + * tags: [Playlists] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * overrideSettings: + * type: object + * properties: + * resolution: + * type: string + * enum: ['360', '480', '720', '1080', '1440', '2160'] + * description: Override download resolution for all playlists + * responses: + * 200: + * description: Playlist downloads initiated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * totalVideos: + * type: integer + * playlistCount: + * type: integer + * 400: + * description: Invalid settings provided + * 500: + * description: Failed to initiate downloads + */ + router.post('/api/playlists/download-all', verifyToken, async (req, res) => { + req.log.info('Triggering manual download from all playlists'); + + try { + const { overrideSettings } = req.body; + + // Validate override settings if provided + if (overrideSettings) { + if (overrideSettings.resolution) { + const validResolutions = ['360', '480', '720', '1080', '1440', '2160']; + if (!validResolutions.includes(overrideSettings.resolution)) { + return res.status(400).json({ + error: 'Invalid resolution. Valid values: 360, 480, 720, 1080, 1440, 2160' + }); + } + } + } + + const result = await playlistModule.downloadAllPlaylists(overrideSettings || {}); + res.status(200).json(result); + } catch (error) { + req.log.error({ err: error }, 'Failed to trigger playlist downloads'); + res.status(500).json({ + success: false, + error: 'Failed to initiate playlist downloads' + }); + } + }); + + /** + * @swagger + * /api/playlists/{playlistId}: + * delete: + * summary: Delete a playlist + * tags: [Playlists] + */ + router.delete('/api/playlists/:playlistId', verifyToken, async (req, res) => { + const { playlistId } = req.params; + req.log.info({ playlistId }, 'Deleting playlist'); + + try { + await playlistModule.deletePlaylist(playlistId); + res.status(200).json({ success: true }); + } catch (error) { + req.log.error({ err: error, playlistId }, 'Failed to delete playlist'); + res.status(500).json({ + success: false, + error: 'Failed to delete playlist' + }); + } + }); + + return router; +}; diff --git a/server/server.js b/server/server.js index 40b689ed..07d3e51a 100644 --- a/server/server.js +++ b/server/server.js @@ -188,6 +188,7 @@ const initialize = async () => { const configModule = require('./modules/configModule'); const channelModule = require('./modules/channelModule'); + const playlistModule = require('./modules/playlistModule'); const plexModule = require('./modules/plexModule'); const downloadModule = require('./modules/downloadModule'); const jobModule = require('./modules/jobModule'); @@ -466,6 +467,7 @@ const initialize = async () => { loginLimiter, configModule, channelModule, + playlistModule, plexModule, downloadModule, jobModule, From 6917e9932f9228437e3eb279f3a20a96b8d2545f Mon Sep 17 00:00:00 2001 From: Karam Ajaj <37446151+karam-ajaj@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:59:52 +0000 Subject: [PATCH 2/5] chore: Remove development volume mounts and unnecessary Dockerfile COPY commands --- Dockerfile | 4 ---- docker-compose.yml | 3 --- 2 files changed, 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 88ce1575..10750379 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,10 +59,6 @@ COPY --from=build /app/client/build ./client/build COPY --from=build /app/migrations ./migrations COPY --from=build /app/package.json ./package.json -# Copy Sequelize configuration -COPY .sequelizerc ./.sequelizerc -COPY config/dbconfig.js ./config/dbconfig.js - # Copy config.example.json to server directory (guaranteed to exist and accessible) COPY config/config.example.json /app/server/config.example.json diff --git a/docker-compose.yml b/docker-compose.yml index 85eb1aff..1122b84b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,9 +85,6 @@ services: - ./server/images:/app/server/images - ./config:/app/config - ./jobs:/app/jobs - - ./server:/app/server - - ./client/build:/app/client/build - - ./migrations:/app/migrations healthcheck: test: ["CMD", "curl", "--fail", "--silent", "--show-error", "--output", "/dev/null", "http://localhost:3011/api/health"] interval: 30s From b25bd5d4af660681bc809717f74ac081ec160f92 Mon Sep 17 00:00:00 2001 From: Karam Ajaj <37446151+karam-ajaj@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:25:08 +0000 Subject: [PATCH 3/5] fix: Add user-agent to prevent YouTube 403 Forbidden errors --- server/modules/download/ytdlpCommandBuilder.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 29d9c836..8d47d311 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -150,7 +150,7 @@ class YtdlpCommandBuilder { /** * Build arguments that ALWAYS apply to any yt-dlp invocation - * Includes: IPv4 enforcement, proxy, sleep-requests, and cookies + * Includes: IPv4 enforcement, proxy, sleep-requests, cookies, and user-agent * @param {Object} config - Configuration object * @param {Object} options - Options for building args * @param {boolean} options.skipSleepRequests - Skip adding --sleep-requests (for single metadata fetches) @@ -164,6 +164,9 @@ class YtdlpCommandBuilder { // Note, I have found that this greatly improves reliability downloading from YouTube args.push('-4'); + // Add user-agent to avoid 403 Forbidden errors from YouTube + args.push('--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'); + // Add proxy if configured if (config.proxy && config.proxy.trim()) { args.push('--proxy', config.proxy.trim()); From 208af17baebd1657f8e1c97a3fdd3367b7e978bd Mon Sep 17 00:00:00 2001 From: Karam Ajaj <37446151+karam-ajaj@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:31:52 +0000 Subject: [PATCH 4/5] fix: Force iOS client to avoid YouTube SABR streaming issues --- server/modules/download/ytdlpCommandBuilder.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/modules/download/ytdlpCommandBuilder.js b/server/modules/download/ytdlpCommandBuilder.js index 8d47d311..74117213 100644 --- a/server/modules/download/ytdlpCommandBuilder.js +++ b/server/modules/download/ytdlpCommandBuilder.js @@ -167,6 +167,10 @@ class YtdlpCommandBuilder { // Add user-agent to avoid 403 Forbidden errors from YouTube args.push('--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'); + // Force iOS client to avoid SABR streaming issues + // See: https://github.com/yt-dlp/yt-dlp/issues/12482 + args.push('--extractor-args', 'youtube:player_client=ios,web'); + // Add proxy if configured if (config.proxy && config.proxy.trim()) { args.push('--proxy', config.proxy.trim()); From d243f3a1b3a8ab5f379031717633e8dc31013b94 Mon Sep 17 00:00:00 2001 From: Karam Ajaj <37446151+karam-ajaj@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:50:22 +0000 Subject: [PATCH 5/5] feat: Add MP3 download type option to playlist settings --- .../PlaylistPage/PlaylistSettingsDialog.tsx | 17 +++++++++++++++++ server/modules/playlistModule.js | 9 +++++++++ 2 files changed, 26 insertions(+) diff --git a/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx b/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx index 00357c17..940945d1 100644 --- a/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx +++ b/client/src/components/PlaylistPage/PlaylistSettingsDialog.tsx @@ -342,6 +342,23 @@ function PlaylistSettingsDialog({ + {/* Download Type */} + + Download Type + + + Choose whether to download video only, video with MP3, or audio-only MP3 + + + Download Filters diff --git a/server/modules/playlistModule.js b/server/modules/playlistModule.js index ab4c03cf..049ea01f 100644 --- a/server/modules/playlistModule.js +++ b/server/modules/playlistModule.js @@ -711,6 +711,9 @@ class PlaylistModule { if (playlist.sub_folder) { overrideSettings.subfolder = playlist.sub_folder; } + if (playlist.audio_format) { + overrideSettings.audioFormat = playlist.audio_format; + } await downloadModule.doSpecificDownloads({ body: { @@ -838,6 +841,9 @@ class PlaylistModule { if (playlist.sub_folder) { overrideSettings.subfolder = playlist.sub_folder; } + if (playlist.audio_format) { + overrideSettings.audioFormat = playlist.audio_format; + } // Queue all videos as a single download job await downloadModule.doSpecificDownloads({ @@ -923,6 +929,9 @@ class PlaylistModule { if (playlist.sub_folder) { playlistOverrideSettings.subfolder = playlist.sub_folder; } + if (playlist.audio_format) { + playlistOverrideSettings.audioFormat = playlist.audio_format; + } await downloadModule.doSpecificDownloads({ body: {