node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_PLATFORM="desktop"
NEXT_PUBLIC_DESKTOP="electron"

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_PLATFORM="desktop"
NEXT_PUBLIC_DESKTOP="tauri"

View File

@@ -0,0 +1,2 @@
NEXT_PUBLIC_PLATFORM="desktop"
NEXT_PUBLIC_DEVBUILD="true"

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_PLATFORM="mobile"

View File

@@ -0,0 +1 @@
NEXT_PUBLIC_PLATFORM="web"

43
seanime-2.9.10/seanime-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.idea
snapshot
logs
analyze
TODO-priv.md
CHANGELOG-priv.md

View File

@@ -0,0 +1,21 @@
build-web-only:
rm -rf ../web
npm run build
cp -r out ../web
build-web:
rm -rf ../web
rm -rf ../web-desktop
npm run build
cp -r out ../web
npm run build:desktop
cp -r out-desktop ../web-desktop
build-denshi:
rm -rf ../web-denshi
npm run build:denshi
cp -r out-denshi ../web-denshi
rm -rf ../seanime-denshi/web-denshi
cp -r ../web-denshi ../seanime-denshi/web-denshi
.PHONY: build-web build-denshi

View File

@@ -0,0 +1,45 @@
<p align="center">
<img src="public/logo_2.png" alt="preview" width="150px"/>
</p>
<h2 align="center"><b>Seanime Web</b></h2>
<h4 align="center">Web interface</h4>
```txt
📁 api
├── 📁 client
├── 📁 generated
└── 📁 hooks
📁 app/(main)
├── 📁 _atoms
├── 📁 _features
├── 📁 _hooks
├── 📁 _listeners
└── 📁 {route}
├── 📁 _containers
├── 📁 _components
├── 📁 _lib
├── 📄 layout.tsx
└── 📄 page.tsx
📁 components
```
- `api`: API related code.
- `client`: React-Query and Axios related code.
- `generated`: Generated types and endpoints.
- `hooks`: Data-fetching hooks.
- `app`
- `_atoms`: Global Jotai atoms
- `_hooks`: Top-level queries (loaders) and global state hooks.
- `_features`: Specialized components that are used across multiple pages.
- `_listeners`: Websocket listeners.
- `{route}`: Route directory.
- `_components`: Route-specific components that only depend on props.
- `_containers`: Route-specific components that interact with global state and API.
- `_lib`: Route-specific utility functions, hooks, constants, and data-related functions.
- `components`: Primitive components, not tied to any feature or route.

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,25 @@
const isProd = process.env.NODE_ENV === 'production';
const isDesktop = process.env.NEXT_PUBLIC_PLATFORM === 'desktop';
const isTauriDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'tauri';
const isElectronDesktop = process.env.NEXT_PUBLIC_DESKTOP === 'electron';
const internalHost = process.env.TAURI_DEV_HOST || '127.0.0.1';
/** @type {import('next').NextConfig} */
const nextConfig = {
...(isProd && {output: "export"}),
distDir: isDesktop ? (isElectronDesktop ? "out-denshi" : "out-desktop") : undefined,
cleanDistDir: true,
reactStrictMode: false,
images: {
unoptimized: true,
},
transpilePackages: ["@uiw/react-textarea-code-editor", "@replit/codemirror-vscode-keymap", "media-chrome", "anime4k-webgpu"],
assetPrefix: isProd ? undefined : (isDesktop ? `http://${internalHost}:43210` : undefined),
experimental: {
reactCompiler: true,
},
devIndicators: false,
}
module.exports = nextConfig

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
{
"name": "seanime-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "env-cmd -f .env.web next dev --hostname=0.0.0.0 --port=43210 --turbo",
"dev:mobile": "env-cmd -f .env.mobile next dev --hostname=0.0.0.0 --port=43210 --turbo",
"dev:desktop": "env-cmd -f .env.desktop next dev --hostname=0.0.0.0 --port=43210 --turbo",
"dev:denshi": "env-cmd -f .env.denshi next dev --hostname=0.0.0.0 --port=43210 --turbo",
"build": "next build",
"build:desktop": "env-cmd -f .env.desktop next build",
"build:denshi": "env-cmd -f .env.denshi next build",
"build:development:desktop": "env-cmd -f .env.development.desktop next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.1",
"@codemirror/legacy-modes": "^6.5.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@headlessui/react": "1.7.18",
"@headlessui/tailwindcss": "0.2.1",
"@hookform/resolvers": "^5.1.1",
"@next/bundle-analyzer": "^15.3.4",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query-devtools": "^5.81.5",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.4.1",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.0",
"@total-typescript/ts-reset": "^0.6.1",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/codemirror-theme-vscode": "^4.23.14",
"@uiw/react-codemirror": "^4.23.14",
"@vidstack/react": "^1.10.9",
"@zag-js/number-input": "^1.15.7",
"@zag-js/react": "^1.15.7",
"anime4k-webgpu": "^1.0.0",
"artplayer": "^5.2.3",
"autoprefixer": "^10",
"axios": "^1.11.0",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250405",
"bottleneck": "^2.19.5",
"bytes-iec": "^3.1.1",
"chalk": "^5.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"collect.js": "^4.36.1",
"colord": "^2.9.3",
"concurrently": "^8.2.2",
"copy-to-clipboard": "^3.3.3",
"crypto-js": "^4.2.0",
"date-fns": "^3.6.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"embla-carousel-auto-scroll": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"env-cmd": "^10.1.0",
"execa": "^8.0.1",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.3.0",
"hls.js": "1.5.20",
"inflection": "^3.0.2",
"install": "^0.13.0",
"jassub": "^1.8.6",
"jotai": "^2.13.0",
"jotai-derive": "^0.1.3",
"jotai-immer": "^0.4.1",
"jotai-optics": "^0.4.0",
"jotai-scope": "^0.9.3",
"js-cookies": "^1.0.4",
"js-levenshtein": "^1.1.6",
"js-sha256": "^0.11.1",
"json2toml": "^6.1.1",
"libphonenumber-js": "^1.12.9",
"media-icons": "^0.10.0",
"memory-cache": "^0.2.0",
"motion": "^12.23.12",
"mousetrap": "^1.6.5",
"needle": "^3.3.1",
"next": "15.3.0",
"next-themes": "^0.4.6",
"normalize-path": "^3.0.0",
"ora": "^8.2.0",
"path-browserify": "^1.0.1",
"postcss": "^8",
"react": "^18",
"react-colorful": "^5.6.1",
"react-cookie": "^8.0.1",
"react-currency-input-field": "^3.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.3.8",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.62.0",
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.12",
"react-remove-scroll-bar": "^2.3.8",
"react-resizable-panels": "^2.1.7",
"react-use": "^17.6.0",
"react-virtuoso": "^4.13.0",
"recharts": "^2.15.3",
"rehype-prism-plus": "^2.0.1",
"sonner": "2.0.5",
"tailwind-merge": "~2.6.0",
"tailwind-scrollbar-hide": "~1.1.7",
"tailwindcss-animate": "~1.0.7",
"tsx": "^4.20.3",
"typescript": "^5",
"upath": "^2.0.1",
"use-debounce": "^10.0.5",
"vaul": "^1.1.2",
"vitest": "^3.2.4",
"zod": "^3.25.67"
},
"devDependencies": {
"@graphql-codegen/client-preset": "4.8.2",
"@types/crypto-js": "^4.2.2",
"@types/fs-extra": "^11.0.4",
"@types/google.maps": "^3.58.1",
"@types/js-levenshtein": "^1.1.3",
"@types/lodash": "^4.17.18",
"@types/memory-cache": "^0.2.6",
"@types/mousetrap": "^1.6.15",
"@types/needle": "^3.3.0",
"@types/node": "^22.15.32",
"@types/path-browserify": "^1.0.3",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@types/react-window": "^1.8.8",
"autoprefixer": "^10",
"encoding": "^0.1.13",
"postcss": "^8",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,28 @@
<svg width="273" height="568" viewBox="0 0 273 568" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" stroke="#fff" stroke-width=".5" stroke-miterlimit="10" style="mix-blend-mode:overlay">
<path d="M269.428.185c11.813 41.392 45.921 68.306 61.736 106.356 12.766 30.44 4.382 65.336-10.671 99.488-9.908 22.459-20.007 43.062-24.199 62.18-4.192 18.562-5.526 36.937-3.239 56.612 4.573 37.308-2.668 73.502-11.242 109.511-11.242 47.517-31.44 93.92-12.957 142.922"/>
<path d="M254.565.185c11.814 41.206 47.636 67.563 64.594 105.242 13.719 30.441 5.145 65.336-9.908 99.674-9.908 22.459-20.007 43.248-24.58 62.18-4.382 18.561-5.907 37.123-4.001 56.798 3.81 37.308-3.43 73.688-12.005 109.882-11.242 47.702-31.82 94.105-14.862 143.293"/>
<path d="M239.512.186c11.814 41.205 49.16 66.634 67.262 104.128 14.481 30.255 6.097 65.335-9.146 99.674-9.908 22.459-20.198 43.433-24.771 62.365-4.573 18.562-6.288 37.308-4.763 56.798 3.049 37.308-4.192 73.688-12.767 110.068-11.242 47.702-32.392 94.476-16.577 143.664"/>
<path d="M224.65 0c12.004 41.206 50.875 65.707 69.929 103.015 15.434 30.254 6.86 65.521-8.193 99.859-9.908 22.459-20.388 43.433-24.961 62.366-4.573 18.747-6.669 37.308-5.526 56.983 2.287 37.493-4.954 73.873-13.528 110.439-11.242 47.888-32.774 94.848-18.292 144.221"/>
<path d="M209.788 0c12.004 41.02 52.589 64.778 72.787 101.901 16.387 30.069 7.812 65.521-7.431 100.045-9.908 22.645-20.388 43.619-25.342 62.551-4.764 18.747-7.05 37.494-6.288 57.169 1.524 37.493-5.526 74.059-14.291 110.625-11.433 48.073-33.345 95.033-20.198 144.592"/>
<path d="M194.735 0c12.004 41.02 54.305 63.85 75.455 100.787 17.149 30.069 8.574 65.707-6.669 100.231-9.908 22.645-20.579 43.804-25.533 62.551-4.954 18.747-7.431 37.679-7.05 57.354.762 37.494-6.288 74.245-15.053 110.996-11.432 48.26-33.726 95.405-21.912 144.964"/>
<path d="M179.872 0c12.195 41.02 56.02 63.108 78.123 99.674 18.102 29.884 9.337 65.707-5.907 100.416-9.908 22.645-20.769 43.805-25.723 62.737-5.145 18.933-8.003 37.865-7.812 57.354.19 37.494-7.051 74.431-15.815 111.182-11.433 48.445-34.298 95.776-23.628 145.334"/>
<path d="M164.82 0c12.194 40.835 57.734 62.18 80.98 98.746 19.055 29.883 10.29 65.706-4.954 100.601-9.908 22.645-20.769 43.99-25.914 62.737-5.144 18.933-8.193 37.865-8.574 57.54-.762 37.679-7.812 74.616-16.577 111.553-11.433 48.63-34.679 96.147-25.343 145.706"/>
<path d="M149.957 0c12.195 40.835 59.259 61.252 83.649 97.632 19.816 29.698 11.051 65.892-4.192 100.787-9.909 22.645-20.96 44.176-26.295 62.923-5.335 19.118-8.575 38.05-9.337 57.725-1.524 37.68-8.574 74.616-17.339 111.739-11.433 48.816-35.06 96.333-27.248 146.262"/>
<path d="M134.904 0c12.195 40.835 60.974 60.324 86.316 96.518 20.769 29.698 12.004 65.893-3.43 100.973-9.908 22.645-21.15 44.176-26.485 62.923-5.526 19.118-8.955 38.236-10.099 57.725-2.286 37.679-9.336 74.802-18.101 112.11-11.433 49.002-35.632 96.704-28.963 146.634"/>
<path d="M120.042 0c12.385 40.649 62.689 59.396 89.174 95.404 21.722 29.513 12.767 65.893-2.477 101.159-9.908 22.83-21.15 44.361-26.676 63.108-5.716 19.118-9.336 38.236-10.861 57.911-3.048 37.68-10.099 74.988-18.864 112.296-11.432 49.187-36.012 97.075-30.677 147.005"/>
<path d="M105.179 0c12.386 40.649 64.404 58.468 91.842 94.29 22.484 29.328 13.529 66.079-1.715 101.345-9.908 22.83-21.341 44.547-26.866 63.108-5.717 19.304-9.718 38.422-11.623 58.097-3.811 37.865-10.861 75.173-19.626 112.667-11.623 49.372-36.584 97.26-32.583 147.376"/>
<path d="M90.127 0c12.385 40.649 66.118 57.725 94.509 93.177 23.437 29.327 14.481 66.078-.953 101.53-9.908 22.83-21.531 44.547-27.247 63.294-5.907 19.304-10.099 38.607-12.386 58.282-4.573 37.865-11.623 75.359-20.388 112.852-11.623 49.559-36.965 97.632-34.297 147.933"/>
<path d="M75.265 0c12.575 40.464 67.833 56.798 97.367 92.064 24.389 29.141 15.243 66.078-.191 101.53-9.908 22.83-21.531 44.732-27.438 63.294-6.097 19.303-10.48 38.792-13.147 58.282-5.526 37.865-12.195 75.358-21.15 113.223-11.624 49.559-37.537 98.003-36.013 148.304"/>
<path d="M60.402 0c12.576 40.464 69.548 55.87 100.035 90.95 25.152 29.141 16.006 66.264.762 101.716-9.908 22.83-21.722 44.732-27.628 63.479-6.288 19.489-10.861 38.793-13.91 58.468-6.288 37.865-12.957 75.544-21.912 113.409-11.624 49.744-37.919 98.189-37.919 148.675"/>
<path d="M45.35 0c12.575 40.463 71.262 54.941 102.702 90.022 26.104 28.955 16.958 66.263 1.524 101.901-9.908 23.016-21.912 44.918-28.01 63.479-6.478 19.49-11.242 38.979-14.671 58.654-7.05 38.05-13.72 75.729-22.675 113.78-11.623 49.93-38.49 98.56-39.633 149.047"/>
<path d="M30.487 0c12.576 40.278 72.978 54.013 105.561 88.908 27.057 28.77 17.72 66.449 2.286 102.087-9.908 23.016-21.912 45.104-28.2 63.665-6.479 19.489-11.433 39.164-15.434 58.653-7.812 38.051-14.481 75.916-23.437 113.966-11.623 50.115-38.87 98.931-41.348 149.604"/>
<path d="M15.434 0C28.2 40.278 90.127 53.085 123.662 87.795c28.01 28.77 18.674 66.449 3.24 102.272-9.909 23.016-22.103 45.104-28.391 63.851-6.67 19.674-11.814 39.164-16.197 58.839-8.574 38.05-15.243 76.101-24.198 114.337-11.623 50.301-39.443 99.117-43.254 149.974"/>
<path d="M.572 0c12.766 40.278 76.217 52.157 110.896 86.68 47.254 46.961-8.194 118.607-24.58 166.309C66.88 310.9 58.497 368.44 44.968 426.537 33.345 477.023 5.145 526.025 0 576.883"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" transform="matrix(0 -1 -1 0 552.032 577.439)" d="M0 0h577.44v552.032H0z"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns='http://www.w3.org/2000/svg' width='100' height='18' viewBox='0 0 100 18'>
<path fill='#a9aed6' fill-opacity='0.3'
d='M61.82 18c3.47-1.45 6.86-3.78 11.3-7.34C78 6.76 80.34 5.1 83.87 3.42 88.56 1.16 93.75 0 100 0v6.16C98.76 6.05 97.43 6 96 6c-9.59 0-14.23 2.23-23.13 9.34-1.28 1.03-2.39 1.9-3.4 2.66h-7.65zm-23.64 0H22.52c-1-.76-2.1-1.63-3.4-2.66C11.57 9.3 7.08 6.78 0 6.16V0c6.25 0 11.44 1.16 16.14 3.42 3.53 1.7 5.87 3.35 10.73 7.24 4.45 3.56 7.84 5.9 11.31 7.34zM61.82 0h7.66a39.57 39.57 0 0 1-7.34 4.58C57.44 6.84 52.25 8 46 8S34.56 6.84 29.86 4.58A39.57 39.57 0 0 1 22.52 0h15.66C41.65 1.44 45.21 2 50 2c4.8 0 8.35-.56 11.82-2z'></path>
</svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@@ -0,0 +1,181 @@
"use client"
import { getServerBaseUrl } from "@/api/client/server-url"
import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms"
import { useMutation, UseMutationOptions, useQuery, UseQueryOptions } from "@tanstack/react-query"
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { useAtomValue } from "jotai"
import { useAtom } from "jotai/react"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { toast } from "sonner"
type SeaError = AxiosError<{ error: string }>
type SeaQuery<D> = {
endpoint: string
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
data?: D
params?: D
password?: string
}
/**
* Create axios query to the server
* - First generic: Return type
* - Second generic: Params/Data type
*/
export async function buildSeaQuery<T, D extends any = any>(
{
endpoint,
method,
data,
params,
password,
}: SeaQuery<D>): Promise<T | undefined> {
axios.interceptors.request.use((request: InternalAxiosRequestConfig) => {
if (password) {
request.headers.set("X-Seanime-Token", password)
}
return request
},
)
const res = await axios<T>({
url: getServerBaseUrl() + endpoint,
method,
data,
params,
})
const response = _handleSeaResponse<T>(res.data)
return response.data
}
type ServerMutationProps<R, V = void> = UseMutationOptions<R | undefined, SeaError, V, unknown> & {
endpoint: string
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
}
/**
* Create mutation hook to the server
* - First generic: Return type
* - Second generic: Params/Data type
*/
export function useServerMutation<R = void, V = void>(
{
endpoint,
method,
...options
}: ServerMutationProps<R, V>) {
const password = useAtomValue(serverAuthTokenAtom)
return useMutation<R | undefined, SeaError, V>({
onError: error => {
console.log("Mutation error", error)
toast.error(_handleSeaError(error.response?.data))
},
mutationFn: async (variables) => {
return buildSeaQuery<R, V>({
endpoint: endpoint,
method: method,
data: variables,
password: password,
})
},
...options,
})
}
type ServerQueryProps<R, V> = UseQueryOptions<R | undefined, SeaError, R | undefined> & {
endpoint: string
method: "POST" | "GET" | "PATCH" | "DELETE" | "PUT"
params?: V
data?: V
muteError?: boolean
}
/**
* Create query hook to the server
* - First generic: Return type
* - Second generic: Params/Data type
*/
export function useServerQuery<R, V = any>(
{
endpoint,
method,
params,
data,
muteError,
...options
}: ServerQueryProps<R | undefined, V>) {
const pathname = usePathname()
const [password, setPassword] = useAtom(serverAuthTokenAtom)
const props = useQuery<R | undefined, SeaError>({
queryFn: async () => {
return buildSeaQuery<R, V>({
endpoint: endpoint,
method: method,
params: params,
data: data,
password: password,
})
},
...options,
})
useEffect(() => {
if (!muteError && props.isError) {
if (props.error?.response?.data?.error === "UNAUTHENTICATED" && pathname !== "/public/auth") {
setPassword(undefined)
window.location.href = "/public/auth"
return
}
console.log("Server error", props.error)
toast.error(_handleSeaError(props.error?.response?.data))
}
}, [props.error, props.isError, muteError])
return props
}
//----------------------------------------------------------------------------------------------------------------------
function _handleSeaError(data: any): string {
if (typeof data === "string") return "Server Error: " + data
const err = data?.error as string
if (!err) return "Unknown error"
if (err.includes("Too many requests"))
return "AniList: Too many requests, please wait a moment and try again."
try {
const graphqlErr = JSON.parse(err) as any
console.log("AniList error", graphqlErr)
if (graphqlErr.graphqlErrors && graphqlErr.graphqlErrors.length > 0 && !!graphqlErr.graphqlErrors[0]?.message) {
return "AniList error: " + graphqlErr.graphqlErrors[0]?.message
}
return "AniList error"
}
catch (e) {
return "Error: " + err
}
}
function _handleSeaResponse<T>(res: unknown): { data: T | undefined, error: string | undefined } {
if (typeof res === "object" && !!res && "error" in res && typeof res.error === "string") {
return { data: undefined, error: res.error }
}
if (typeof res === "object" && !!res && "data" in res) {
return { data: res.data as T, error: undefined }
}
return { data: undefined, error: "No response from the server" }
}

View File

@@ -0,0 +1,42 @@
import { __DEV_SERVER_PORT } from "@/lib/server/config"
import { __isDesktop__ } from "@/types/constants"
function devOrProd(dev: string, prod: string): string {
return process.env.NODE_ENV === "development" ? dev : prod
}
export function getServerBaseUrl(removeProtocol: boolean = false): string {
if (__isDesktop__) {
let ret = devOrProd(`http://127.0.0.1:${__DEV_SERVER_PORT}`, "http://127.0.0.1:43211")
if (removeProtocol) {
ret = ret.replace("http://", "").replace("https://", "")
}
return ret
}
// // DEV ONLY: Hack to allow multiple development servers for the same web server
// // localhost:43210 -> 127.0.0.1:43001
// // 192.168.1.100:43210 -> 127.0.0.1:43002
// if (process.env.NODE_ENV === "development" && window.location.host.includes("localhost")) {
// let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT2}`
// if (removeProtocol) {
// ret = ret.replace("http://", "").replace("https://", "")
// }
// return ret
// }
// if (process.env.NODE_ENV === "development" && window.location.host.startsWith("192.168")) {
// let ret = `http://127.0.0.1:${TESTONLY__DEV_SERVER_PORT3}`
// if (removeProtocol) {
// ret = ret.replace("http://", "").replace("https://", "")
// }
// return ret
// }
let ret = typeof window !== "undefined"
? (`${window?.location?.protocol}//` + devOrProd(`${window?.location?.hostname}:${__DEV_SERVER_PORT}`, window?.location?.host))
: ""
if (removeProtocol) {
ret = ret.replace("http://", "").replace("https://", "")
}
return ret
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { useQueryClient } from "@tanstack/react-query"
type QueryHookProps = {
key: any[]
}
export function useTemplateQuery(props: QueryHookProps) {
return useServerQuery({
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
queryKey: [API_ENDPOINTS.DOCS.GetDocs.key, ...props.key],
})
}
// type MutationHookProps = {
// onSuccess
// }
export function useTemplateMutation() {
const queryClient = useQueryClient()
return useServerMutation({
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
mutationKey: [API_ENDPOINTS.DOCS.GetDocs.key],
onSuccess: async () => {
},
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
AnilistListAnime_Variables,
AnilistListRecentAiringAnime_Variables,
DeleteAnilistListEntry_Variables,
EditAnilistListEntry_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import {
AL_AnimeCollection,
AL_AnimeDetailsById_Media,
AL_BaseAnime,
AL_ListAnime,
AL_ListRecentAnime,
AL_Stats,
AL_StudioDetails,
Nullish,
} from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetAnimeCollection() {
return useServerQuery<AL_AnimeCollection>({
endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint,
method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key],
enabled: true,
})
}
export function useGetRawAnimeCollection() {
return useServerQuery<AL_AnimeCollection>({
endpoint: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.endpoint,
method: API_ENDPOINTS.ANILIST.GetRawAnimeCollection.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key],
enabled: true,
})
}
export function useRefreshAnimeCollection() {
const queryClient = useQueryClient()
return useServerMutation<AL_AnimeCollection>({
endpoint: API_ENDPOINTS.ANILIST.GetAnimeCollection.endpoint,
method: API_ENDPOINTS.ANILIST.GetAnimeCollection.methods[1],
mutationKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key],
onSuccess: async () => {
toast.success("AniList is up-to-date")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key] })
},
})
}
export function useEditAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga") {
const queryClient = useQueryClient()
return useServerMutation<boolean, EditAnilistListEntry_Variables>({
endpoint: API_ENDPOINTS.ANILIST.EditAnilistListEntry.endpoint,
method: API_ENDPOINTS.ANILIST.EditAnilistListEntry.methods[0],
mutationKey: [API_ENDPOINTS.ANILIST.EditAnilistListEntry.key, String(id)],
onSuccess: async () => {
toast.success("Entry updated")
if (type === "anime") {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
} else if (type === "manga") {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
}
},
})
}
export function useGetAnilistAnimeDetails(id: Nullish<number | string>) {
return useServerQuery<AL_AnimeDetailsById_Media>({
endpoint: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.GetAnilistAnimeDetails.key, String(id)],
enabled: !!id,
})
}
export function useDeleteAnilistListEntry(id: Nullish<string | number>, type: "anime" | "manga", onSuccess: () => void) {
const queryClient = useQueryClient()
return useServerMutation<boolean, DeleteAnilistListEntry_Variables>({
endpoint: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.endpoint,
method: API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.methods[0],
mutationKey: [API_ENDPOINTS.ANILIST.DeleteAnilistListEntry.key],
onSuccess: async () => {
toast.success("Entry deleted")
if (type === "anime") {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
} else if (type === "manga") {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
}
onSuccess()
},
})
}
export function useAnilistListAnime(variables: AnilistListAnime_Variables, enabled: boolean) {
return useServerQuery<AL_ListAnime, AnilistListAnime_Variables>({
endpoint: API_ENDPOINTS.ANILIST.AnilistListAnime.endpoint,
method: API_ENDPOINTS.ANILIST.AnilistListAnime.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.AnilistListAnime.key, variables],
data: variables,
enabled: enabled ?? true,
})
}
export function useAnilistListRecentAiringAnime(variables: AnilistListRecentAiringAnime_Variables, enabled: boolean = true) {
return useServerQuery<AL_ListRecentAnime, AnilistListRecentAiringAnime_Variables>({
endpoint: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.endpoint,
method: API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.AnilistListRecentAiringAnime.key, JSON.stringify(variables)],
data: variables,
enabled: enabled,
})
}
export function useGetAnilistStudioDetails(id: number) {
return useServerQuery<AL_StudioDetails>({
endpoint: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.GetAnilistStudioDetails.key, String(id)],
enabled: true,
})
}
export function useGetAniListStats(enabled: boolean = true) {
return useServerQuery<AL_Stats>({
endpoint: API_ENDPOINTS.ANILIST.GetAniListStats.endpoint,
method: API_ENDPOINTS.ANILIST.GetAniListStats.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.GetAniListStats.key],
enabled: enabled,
})
}
export function useAnilistListMissedSequels(enabled: boolean) {
return useServerQuery<Array<AL_BaseAnime>>({
endpoint: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.endpoint,
method: API_ENDPOINTS.ANILIST.AnilistListMissedSequels.methods[0],
queryKey: [API_ENDPOINTS.ANILIST.AnilistListMissedSequels.key],
enabled: enabled,
})
}

View File

@@ -0,0 +1,12 @@
import { useServerQuery } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_EpisodeCollection } from "@/api/generated/types"
export function useGetAnimeEpisodeCollection(id: number) {
return useServerQuery<Anime_EpisodeCollection>({
endpoint: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.methods[0],
queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key, String(id)],
enabled: true,
})
}

View File

@@ -0,0 +1,40 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { AddUnknownMedia_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { AL_AnimeCollection, Anime_LibraryCollection, Anime_ScheduleItem } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetLibraryCollection() {
return useServerQuery<Anime_LibraryCollection>({
endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.endpoint,
method: API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.methods[0],
queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key],
enabled: true,
})
}
export function useAddUnknownMedia() {
const queryClient = useQueryClient()
return useServerMutation<AL_AnimeCollection, AddUnknownMedia_Variables>({
endpoint: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.endpoint,
method: API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_COLLECTION.AddUnknownMedia.key],
onSuccess: async () => {
toast.success("Media added successfully")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
},
})
}
export function useGetAnimeCollectionSchedule() {
return useServerQuery<Array<Anime_ScheduleItem>>({
endpoint: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.endpoint,
method: API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.methods[0],
queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetAnimeCollectionSchedule.key],
enabled: true,
})
}

View File

@@ -0,0 +1,143 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
AnimeEntryBulkAction_Variables,
AnimeEntryManualMatch_Variables,
FetchAnimeEntrySuggestions_Variables,
OpenAnimeEntryInExplorer_Variables,
ToggleAnimeEntrySilenceStatus_Variables,
UpdateAnimeEntryProgress_Variables,
UpdateAnimeEntryRepeat_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { AL_BaseAnime, Anime_Entry, Anime_LocalFile, Anime_MissingEpisodes, Nullish } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetAnimeEntry(id: Nullish<string | number>) {
return useServerQuery<Anime_Entry>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.methods[0],
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)],
enabled: !!id,
})
}
export function useAnimeEntryBulkAction(id?: Nullish<number>, onSuccess?: () => void) {
const queryClient = useQueryClient()
return useServerMutation<Array<Anime_LocalFile>, AnimeEntryBulkAction_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryBulkAction.key, String(id)],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
onSuccess?.()
},
})
}
export function useOpenAnimeEntryInExplorer() {
return useServerMutation<boolean, OpenAnimeEntryInExplorer_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.OpenAnimeEntryInExplorer.key],
onSuccess: async () => {
},
})
}
export function useFetchAnimeEntrySuggestions() {
return useServerMutation<Array<AL_BaseAnime>, FetchAnimeEntrySuggestions_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.FetchAnimeEntrySuggestions.key],
onSuccess: async () => {
},
})
}
export function useAnimeEntryManualMatch() {
const queryClient = useQueryClient()
return useServerMutation<Array<Anime_LocalFile>, AnimeEntryManualMatch_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.AnimeEntryManualMatch.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
toast.success("Files matched")
},
})
}
export function useGetMissingEpisodes(enabled?: boolean) {
return useServerQuery<Anime_MissingEpisodes>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.methods[0],
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key],
enabled: enabled ?? true, // Default to true if not provided
})
}
export function useGetAnimeEntrySilenceStatus(id: Nullish<string | number>) {
const { data, ...rest } = useServerQuery({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.methods[0],
queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key],
enabled: !!id,
})
return { isSilenced: !!data, ...rest }
}
export function useToggleAnimeEntrySilenceStatus() {
const queryClient = useQueryClient()
return useServerMutation<boolean, ToggleAnimeEntrySilenceStatus_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.ToggleAnimeEntrySilenceStatus.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntrySilenceStatus.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
},
})
}
export function useUpdateAnimeEntryProgress(id: Nullish<string | number>, episodeNumber: number) {
const queryClient = useQueryClient()
return useServerMutation<boolean, UpdateAnimeEntryProgress_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryProgress.key, id, episodeNumber],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
if (id) {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
}
toast.success("Progress updated successfully")
},
})
}
export function useUpdateAnimeEntryRepeat(id: Nullish<string | number>) {
const queryClient = useQueryClient()
return useServerMutation<boolean, UpdateAnimeEntryRepeat_Variables>({
endpoint: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.endpoint,
method: API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.methods[0],
mutationKey: [API_ENDPOINTS.ANIME_ENTRIES.UpdateAnimeEntryRepeat.key, id],
onSuccess: async () => {
// if (id) {
// await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
// }
// toast.success("Updated successfully")
},
})
}

View File

@@ -0,0 +1,61 @@
import { useServerMutation } from "@/api/client/requests"
import { Login_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Status } from "@/api/generated/types"
import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { useQueryClient } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
export function useLogin() {
const queryClient = useQueryClient()
const router = useRouter()
const setServerStatus = useSetServerStatus()
return useServerMutation<Status, Login_Variables>({
endpoint: API_ENDPOINTS.AUTH.Login.endpoint,
method: API_ENDPOINTS.AUTH.Login.methods[0],
mutationKey: [API_ENDPOINTS.AUTH.Login.key],
onSuccess: async data => {
if (data) {
toast.success("Successfully authenticated")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
setServerStatus(data)
router.push("/")
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
}
},
onError: async error => {
toast.error(error.message)
router.push("/")
},
})
}
export function useLogout() {
const queryClient = useQueryClient()
const router = useRouter()
const setServerStatus = useSetServerStatus()
return useServerMutation<Status>({
endpoint: API_ENDPOINTS.AUTH.Logout.endpoint,
method: API_ENDPOINTS.AUTH.Logout.methods[0],
mutationKey: [API_ENDPOINTS.AUTH.Logout.key],
onSuccess: async () => {
toast.success("Successfully logged out")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
router.push("/")
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
},
})
}

View File

@@ -0,0 +1,122 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
CreateAutoDownloaderRule_Variables,
DeleteAutoDownloaderItem_Variables,
UpdateAutoDownloaderRule_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_AutoDownloaderRule, Models_AutoDownloaderItem, Nullish } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useRunAutoDownloader() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.methods[0],
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.RunAutoDownloader.key],
onSuccess: async () => {
toast.success("Auto downloader started")
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
}, 1000)
},
})
}
export function useGetAutoDownloaderRule(id: number) {
return useServerQuery<Anime_AutoDownloaderRule>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.methods[0],
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRule.key],
enabled: true,
})
}
export function useGetAutoDownloaderRules() {
return useServerQuery<Array<Anime_AutoDownloaderRule>>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.methods[0],
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key],
enabled: true,
})
}
export function useCreateAutoDownloaderRule() {
const queryClient = useQueryClient()
return useServerMutation<Anime_AutoDownloaderRule, CreateAutoDownloaderRule_Variables>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.methods[0],
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.CreateAutoDownloaderRule.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
toast.success("Rule created")
},
})
}
export function useUpdateAutoDownloaderRule() {
const queryClient = useQueryClient()
return useServerMutation<Anime_AutoDownloaderRule, UpdateAutoDownloaderRule_Variables>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.methods[0],
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.UpdateAutoDownloaderRule.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
toast.success("Rule updated")
},
})
}
export function useDeleteAutoDownloaderRule(id: Nullish<number>) {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.methods[0],
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderRule.key, String(id)],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRules.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
toast.success("Rule deleted")
},
})
}
export function useGetAutoDownloaderItems(enabled: boolean = true) {
return useServerQuery<Array<Models_AutoDownloaderItem>>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.methods[0],
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key],
enabled: enabled,
})
}
export function useDeleteAutoDownloaderItem() {
const queryClient = useQueryClient()
return useServerMutation<boolean, DeleteAutoDownloaderItem_Variables>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.endpoint,
method: API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.methods[0],
mutationKey: [API_ENDPOINTS.AUTO_DOWNLOADER.DeleteAutoDownloaderItem.key],
onSuccess: async () => {
toast.success("Item deleted")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key] })
},
})
}
export function useGetAutoDownloaderRulesByAnime(id: number, enabled: boolean) {
return useServerQuery<Array<Anime_AutoDownloaderRule>>({
endpoint: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.methods[0],
queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderRulesByAnime.key, String(id)],
enabled: enabled,
})
}

View File

@@ -0,0 +1,125 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { GetContinuityWatchHistoryItem_Variables, UpdateContinuityWatchHistoryItem_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Continuity_WatchHistory, Continuity_WatchHistoryItemResponse, Nullish } from "@/api/generated/types"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { logger } from "@/lib/helpers/debug"
import { useQueryClient } from "@tanstack/react-query"
import { MediaPlayerInstance } from "@vidstack/react"
import React from "react"
export function useUpdateContinuityWatchHistoryItem() {
const qc = useQueryClient()
return useServerMutation<boolean, UpdateContinuityWatchHistoryItem_Variables>({
endpoint: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.endpoint,
method: API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.methods[0],
mutationKey: [API_ENDPOINTS.CONTINUITY.UpdateContinuityWatchHistoryItem.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] })
},
})
}
export function useGetContinuityWatchHistoryItem(mediaId: Nullish<number | string>) {
const serverStatus = useServerStatus()
return useServerQuery<Continuity_WatchHistoryItemResponse, GetContinuityWatchHistoryItem_Variables>({
endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.endpoint.replace("{id}", String(mediaId)),
method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.methods[0],
queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key, String(mediaId)],
enabled: serverStatus?.settings?.library?.enableWatchContinuity && !!mediaId,
})
}
export function useGetContinuityWatchHistory() {
return useServerQuery<Continuity_WatchHistory>({
endpoint: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.endpoint,
method: API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.methods[0],
queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key],
enabled: true,
})
}
export function getEpisodePercentageComplete(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) {
if (!history) return 0
const item = history[mediaId]
if (!item || !item.currentTime || !item.duration) return 0
if (item.episodeNumber !== progressNumber) return 0
const percent = Math.round((item.currentTime / item.duration) * 100)
if (percent > 90 || percent < 5) return 0
return percent
}
export function getEpisodeMinutesRemaining(history: Nullish<Continuity_WatchHistory>, mediaId: number, progressNumber: number) {
if (!history) return 0
const item = history[mediaId]
if (!item || !item.currentTime || !item.duration) return 0
if (item.episodeNumber !== progressNumber) return 0
return Math.round((item.duration - item.currentTime) / 60)
}
export function useHandleContinuityWithMediaPlayer(playerRef: React.RefObject<MediaPlayerInstance | HTMLVideoElement>,
episodeNumber: Nullish<number>,
mediaId: Nullish<number | string>,
) {
const serverStatus = useServerStatus()
const qc = useQueryClient()
React.useEffect(() => {
(async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistory.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] })
})()
}, [episodeNumber ?? 0])
const { mutate: updateWatchHistory } = useUpdateContinuityWatchHistoryItem()
function handleUpdateWatchHistory() {
if (!serverStatus?.settings?.library?.enableWatchContinuity) return
if (playerRef.current?.duration && playerRef.current?.currentTime) {
logger("CONTINUITY").info("Watch history updated", {
currentTime: playerRef.current?.currentTime,
duration: playerRef.current?.duration,
})
updateWatchHistory({
options: {
currentTime: playerRef.current?.currentTime ?? 0,
duration: playerRef.current?.duration ?? 0,
mediaId: Number(mediaId),
episodeNumber: episodeNumber ?? 0,
kind: "onlinestream",
},
})
}
}
return { handleUpdateWatchHistory }
}
export function useHandleCurrentMediaContinuity(mediaId: Nullish<number | string>) {
const serverStatus = useServerStatus()
const { data: watchHistory, isLoading: watchHistoryLoading } = useGetContinuityWatchHistoryItem(mediaId)
const waitForWatchHistory = watchHistoryLoading && serverStatus?.settings?.library?.enableWatchContinuity
function getEpisodeContinuitySeekTo(episodeNumber: Nullish<number>, playerCurrentTime: Nullish<number>, playerDuration: Nullish<number>) {
if (!serverStatus?.settings?.library?.enableWatchContinuity || !mediaId || !watchHistory || !playerDuration || !episodeNumber) return 0
const item = watchHistory?.item
if (!item || !item.currentTime || !item.duration || item.episodeNumber !== episodeNumber) return 0
if (!(item.currentTime > 0 && item.currentTime < playerDuration) || (item.currentTime / item.duration) > 90) return 0
logger("CONTINUITY").info("Found last watched time", {
currentTime: item.currentTime,
duration: item.duration,
episodeNumber: item.episodeNumber,
})
return item.currentTime
}
return {
watchHistory,
waitForWatchHistory,
getEpisodeContinuitySeekTo,
}
}

View File

@@ -0,0 +1,142 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
DebridAddTorrents_Variables,
DebridCancelDownload_Variables,
DebridCancelStream_Variables,
DebridDeleteTorrent_Variables,
DebridDownloadTorrent_Variables,
DebridGetTorrentFilePreviews_Variables,
DebridGetTorrentInfo_Variables,
DebridStartStream_Variables,
SaveDebridSettings_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Debrid_TorrentInfo, Debrid_TorrentItem, DebridClient_FilePreview, Models_DebridSettings } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetDebridSettings() {
return useServerQuery<Models_DebridSettings>({
endpoint: API_ENDPOINTS.DEBRID.GetDebridSettings.endpoint,
method: API_ENDPOINTS.DEBRID.GetDebridSettings.methods[0],
queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key],
enabled: true,
})
}
export function useSaveDebridSettings() {
const qc = useQueryClient()
return useServerMutation<Models_DebridSettings, SaveDebridSettings_Variables>({
endpoint: API_ENDPOINTS.DEBRID.SaveDebridSettings.endpoint,
method: API_ENDPOINTS.DEBRID.SaveDebridSettings.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.SaveDebridSettings.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.GetDebridSettings.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Settings saved")
},
})
}
export function useDebridAddTorrents(onSuccess: () => void) {
const qc = useQueryClient()
return useServerMutation<boolean, DebridAddTorrents_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridAddTorrents.endpoint,
method: API_ENDPOINTS.DEBRID.DebridAddTorrents.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridAddTorrents.key],
onSuccess: async () => {
onSuccess()
toast.success("Torrent added")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] })
},
})
}
export function useDebridDownloadTorrent() {
return useServerMutation<boolean, DebridDownloadTorrent_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.endpoint,
method: API_ENDPOINTS.DEBRID.DebridDownloadTorrent.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridDownloadTorrent.key],
onSuccess: async () => {
toast.info("Download started")
},
})
}
export function useDebridCancelDownload() {
return useServerMutation<boolean, DebridCancelDownload_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridCancelDownload.endpoint,
method: API_ENDPOINTS.DEBRID.DebridCancelDownload.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelDownload.key],
onSuccess: async () => {
toast.info("Download cancelled")
},
})
}
export function useDebridDeleteTorrent() {
const qc = useQueryClient()
return useServerMutation<boolean, DebridDeleteTorrent_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.endpoint,
method: API_ENDPOINTS.DEBRID.DebridDeleteTorrent.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridDeleteTorrent.key],
onSuccess: async () => {
toast.success("Torrent deleted")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key] })
},
})
}
export function useDebridGetTorrents(enabled: boolean, refetchInterval: number) {
return useServerQuery<Array<Debrid_TorrentItem>>({
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrents.endpoint,
method: API_ENDPOINTS.DEBRID.DebridGetTorrents.methods[0],
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrents.key],
enabled: enabled,
retry: 3,
refetchInterval: refetchInterval,
})
}
export function useDebridGetTorrentInfo(variables: Partial<DebridGetTorrentInfo_Variables>, enabled: boolean) {
return useServerQuery<Debrid_TorrentInfo, DebridGetTorrentInfo_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.endpoint,
method: API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.methods[0],
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentInfo.key, variables?.torrent?.infoHash],
data: variables as DebridGetTorrentInfo_Variables,
enabled: enabled,
gcTime: 0,
})
}
export function useDebridGetTorrentFilePreviews(variables: Partial<DebridGetTorrentFilePreviews_Variables>, enabled: boolean) {
return useServerQuery<Array<DebridClient_FilePreview>, DebridGetTorrentFilePreviews_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.endpoint,
method: API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.methods[0],
queryKey: [API_ENDPOINTS.DEBRID.DebridGetTorrentFilePreviews.key, variables?.torrent?.infoHash],
data: variables as DebridGetTorrentFilePreviews_Variables,
enabled: enabled,
gcTime: 0,
})
}
export function useDebridStartStream() {
return useServerMutation<boolean, DebridStartStream_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridStartStream.endpoint,
method: API_ENDPOINTS.DEBRID.DebridStartStream.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridStartStream.key],
onSuccess: async () => {
},
})
}
export function useDebridCancelStream() {
return useServerMutation<boolean, DebridCancelStream_Variables>({
endpoint: API_ENDPOINTS.DEBRID.DebridCancelStream.endpoint,
method: API_ENDPOINTS.DEBRID.DebridCancelStream.methods[0],
mutationKey: [API_ENDPOINTS.DEBRID.DebridCancelStream.key],
onSuccess: async () => {
toast.success("Stream cancelled")
},
})
}

View File

@@ -0,0 +1,16 @@
import { useServerQuery } from "@/api/client/requests"
import { DirectorySelector_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { DirectorySelectorResponse } from "@/api/generated/types"
export function useDirectorySelector(debouncedInput: string) {
return useServerQuery<DirectorySelectorResponse, DirectorySelector_Variables>({
endpoint: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.endpoint,
method: API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.methods[0],
queryKey: [API_ENDPOINTS.DIRECTORY_SELECTOR.DirectorySelector.key, debouncedInput],
data: { input: debouncedInput },
enabled: debouncedInput.length > 0,
muteError: true,
})
}

View File

@@ -0,0 +1,15 @@
import { useServerMutation } from "../client/requests"
import { DirectstreamPlayLocalFile_Variables } from "../generated/endpoint.types"
import { API_ENDPOINTS } from "../generated/endpoints"
import { Mediastream_MediaContainer } from "../generated/types"
export function useDirectstreamPlayLocalFile() {
return useServerMutation<Mediastream_MediaContainer, DirectstreamPlayLocalFile_Variables>({
endpoint: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.endpoint,
method: API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.methods[0],
mutationKey: [API_ENDPOINTS.DIRECTSTREAM.DirectstreamPlayLocalFile.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,64 @@
import { useServerMutation } from "@/api/client/requests"
import {
SetDiscordAnimeActivityWithProgress_Variables,
SetDiscordLegacyAnimeActivity_Variables,
SetDiscordMangaActivity_Variables,
UpdateDiscordAnimeActivityWithProgress_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
export function useSetDiscordMangaActivity() {
return useServerMutation<boolean, SetDiscordMangaActivity_Variables>({
endpoint: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.endpoint,
method: API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.methods[0],
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordMangaActivity.key],
onSuccess: async () => {
},
})
}
export function useSetDiscordLegacyAnimeActivity() {
return useServerMutation<boolean, SetDiscordLegacyAnimeActivity_Variables>({
endpoint: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.endpoint,
method: API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.methods[0],
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordLegacyAnimeActivity.key],
onSuccess: async () => {
},
})
}
export function useSetDiscordAnimeActivityWithProgress() {
return useServerMutation<boolean, SetDiscordAnimeActivityWithProgress_Variables>({
endpoint: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.endpoint,
method: API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.methods[0],
mutationKey: [API_ENDPOINTS.DISCORD.SetDiscordAnimeActivityWithProgress.key],
onSuccess: async () => {
},
})
}
export function useUpdateDiscordAnimeActivityWithProgress() {
return useServerMutation<boolean, UpdateDiscordAnimeActivityWithProgress_Variables>({
endpoint: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.endpoint,
method: API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.methods[0],
mutationKey: [API_ENDPOINTS.DISCORD.UpdateDiscordAnimeActivityWithProgress.key],
onSuccess: async () => {
},
})
}
export function useCancelDiscordActivity() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.DISCORD.CancelDiscordActivity.endpoint,
method: API_ENDPOINTS.DISCORD.CancelDiscordActivity.methods[0],
mutationKey: [API_ENDPOINTS.DISCORD.CancelDiscordActivity.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,13 @@
import { useServerQuery } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { ApiDocsGroup } from "@/api/generated/types"
export function useGetDocs() {
return useServerQuery<ApiDocsGroup[]>({
endpoint: API_ENDPOINTS.DOCS.GetDocs.endpoint,
method: API_ENDPOINTS.DOCS.GetDocs.methods[0],
queryKey: [API_ENDPOINTS.DOCS.GetDocs.key],
enabled: true,
})
}

View File

@@ -0,0 +1,40 @@
import { useServerMutation } from "@/api/client/requests"
import { DownloadRelease_Variables, DownloadTorrentFile_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { DownloadReleaseResponse } from "@/api/generated/types"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { toast } from "sonner"
export function useDownloadTorrentFile(onSuccess?: () => void) {
return useServerMutation<boolean, DownloadTorrentFile_Variables>({
endpoint: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.endpoint,
method: API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.methods[0],
mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadTorrentFile.key],
onSuccess: async () => {
toast.success("Files downloaded")
onSuccess?.()
},
})
}
export function useDownloadRelease() {
const { mutate: openInExplorer } = useOpenInExplorer()
return useServerMutation<DownloadReleaseResponse, DownloadRelease_Variables>({
endpoint: API_ENDPOINTS.DOWNLOAD.DownloadRelease.endpoint,
method: API_ENDPOINTS.DOWNLOAD.DownloadRelease.methods[0],
mutationKey: [API_ENDPOINTS.DOWNLOAD.DownloadRelease.key],
onSuccess: async data => {
toast.success("Update downloaded successfully!")
if (data?.error) {
toast.error(data.error)
}
if (data?.destination) {
openInExplorer({
path: data.destination,
})
}
},
})
}

View File

@@ -0,0 +1,15 @@
import { useServerMutation } from "@/api/client/requests"
import { OpenInExplorer_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
export function useOpenInExplorer() {
return useServerMutation<boolean, OpenInExplorer_Variables>({
endpoint: API_ENDPOINTS.EXPLORER.OpenInExplorer.endpoint,
method: API_ENDPOINTS.EXPLORER.OpenInExplorer.methods[0],
mutationKey: [API_ENDPOINTS.EXPLORER.OpenInExplorer.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,271 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
FetchExternalExtensionData_Variables,
GetAllExtensions_Variables,
GrantPluginPermissions_Variables,
InstallExternalExtension_Variables,
ReloadExternalExtension_Variables,
RunExtensionPlaygroundCode_Variables,
SaveExtensionUserConfig_Variables,
SetPluginSettingsPinnedTrays_Variables,
UninstallExternalExtension_Variables,
UpdateExtensionCode_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import {
Extension_Extension,
ExtensionRepo_AllExtensions,
ExtensionRepo_AnimeTorrentProviderExtensionItem,
ExtensionRepo_ExtensionInstallResponse,
ExtensionRepo_ExtensionUserConfig,
ExtensionRepo_MangaProviderExtensionItem,
ExtensionRepo_OnlinestreamProviderExtensionItem,
ExtensionRepo_StoredPluginSettingsData,
ExtensionRepo_UpdateData,
Nullish,
RunPlaygroundCodeResponse,
} from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { toast } from "sonner"
const unauthorizedPluginCountAtom = atom(0)
export function useUnauthorizedPluginCount() {
const [count] = useAtom(unauthorizedPluginCountAtom)
return count
}
export function useGetAllExtensions(withUpdates: boolean) {
const { data, ...rest } = useServerQuery<ExtensionRepo_AllExtensions, GetAllExtensions_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.GetAllExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetAllExtensions.key, withUpdates],
data: {
withUpdates: withUpdates,
},
gcTime: 0,
enabled: true,
})
const [, setCount] = useAtom(unauthorizedPluginCountAtom)
React.useEffect(() => {
setCount((data?.invalidExtensions ?? []).filter(n => n.code === "plugin_permissions_not_granted")?.length ?? 0)
}, [data])
return { data, ...rest }
}
export function useFetchExternalExtensionData(id: Nullish<string>) {
return useServerMutation<Extension_Extension, FetchExternalExtensionData_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.endpoint,
method: API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.FetchExternalExtensionData.key, id],
onSuccess: async () => {
},
})
}
export function useInstallExternalExtension() {
return useServerMutation<ExtensionRepo_ExtensionInstallResponse, InstallExternalExtension_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.endpoint,
method: API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.InstallExternalExtension.key],
onSuccess: async () => {
// DEVNOTE: No need to refetch, the websocket listener will do it
},
})
}
export function useUninstallExternalExtension() {
return useServerMutation<boolean, UninstallExternalExtension_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.endpoint,
method: API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.UninstallExternalExtension.key],
onSuccess: async () => {
// DEVNOTE: No need to refetch, the websocket listener will do it
toast.success("Extension uninstalled successfully.")
},
})
}
export function useGetExtensionPayload(id: string) {
return useServerQuery<string>({
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.endpoint.replace("{id}", id),
method: API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionPayload.key, id],
enabled: true,
})
}
export function useUpdateExtensionCode() {
return useServerMutation<boolean, UpdateExtensionCode_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.endpoint,
method: API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.UpdateExtensionCode.key],
onSuccess: async () => {
// DEVNOTE: No need to refetch, the websocket listener will do it
toast.success("Extension updated successfully.")
},
})
}
export function useReloadExternalExtensions() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtensions.key],
onSuccess: async () => {
},
})
}
export function useListExtensionData() {
return useServerQuery<Array<Extension_Extension>>({
endpoint: API_ENDPOINTS.EXTENSIONS.ListExtensionData.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ListExtensionData.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.ListExtensionData.key],
enabled: true,
})
}
export function useListMangaProviderExtensions() {
return useServerQuery<Array<ExtensionRepo_MangaProviderExtensionItem>>({
endpoint: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.ListMangaProviderExtensions.key],
enabled: true,
})
}
export function useListOnlinestreamProviderExtensions() {
return useServerQuery<Array<ExtensionRepo_OnlinestreamProviderExtensionItem>>({
endpoint: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.ListOnlinestreamProviderExtensions.key],
enabled: true,
})
}
export function useAnimeListTorrentProviderExtensions() {
return useServerQuery<Array<ExtensionRepo_AnimeTorrentProviderExtensionItem>>({
endpoint: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.ListAnimeTorrentProviderExtensions.key],
enabled: true,
})
}
export function useRunExtensionPlaygroundCode() {
return useServerMutation<RunPlaygroundCodeResponse, RunExtensionPlaygroundCode_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.endpoint,
method: API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.RunExtensionPlaygroundCode.key],
onSuccess: async () => {
},
})
}
export function useGetExtensionUserConfig(id: string) {
return useServerQuery<ExtensionRepo_ExtensionUserConfig>({
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.endpoint.replace("{id}", id),
method: API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUserConfig.key, id],
enabled: true,
gcTime: 0,
})
}
export function useSaveExtensionUserConfig() {
return useServerMutation<boolean, SaveExtensionUserConfig_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.endpoint,
method: API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.SaveExtensionUserConfig.key],
onSuccess: async () => {
// DEVNOTE: No need to refetch, the websocket listener will do it
toast.success("Config saved successfully.")
},
})
}
export function useListDevelopmentModeExtensions() {
return useServerQuery<Array<Extension_Extension>>({
endpoint: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key],
enabled: true,
})
}
export function useReloadExternalExtension() {
const queryClient = useQueryClient()
return useServerMutation<boolean, ReloadExternalExtension_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.endpoint,
method: API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.ReloadExternalExtension.key],
onSuccess: async () => {
toast.success("Extension reloaded successfully.")
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.ListDevelopmentModeExtensions.key] })
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
// DEVNOTE: No need to refetch, the websocket listener will do it
},
})
}
export function useGetPluginSettings() {
return useServerQuery<ExtensionRepo_StoredPluginSettingsData>({
endpoint: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.endpoint,
method: API_ENDPOINTS.EXTENSIONS.GetPluginSettings.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key],
enabled: true,
})
}
export function useSetPluginSettingsPinnedTrays() {
const queryClient = useQueryClient()
return useServerMutation<boolean, SetPluginSettingsPinnedTrays_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.endpoint,
method: API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.SetPluginSettingsPinnedTrays.key],
onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
},
})
}
export function useGrantPluginPermissions() {
const queryClient = useQueryClient()
return useServerMutation<boolean, GrantPluginPermissions_Variables>({
endpoint: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.endpoint,
method: API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.methods[0],
mutationKey: [API_ENDPOINTS.EXTENSIONS.GrantPluginPermissions.key],
onSuccess: async () => {
toast.success("Plugin permissions granted successfully.")
queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.EXTENSIONS.GetPluginSettings.key] })
},
})
}
export function useGetMarketplaceExtensions(marketplaceUrl?: string) {
const url = marketplaceUrl ? `?marketplace=${encodeURIComponent(marketplaceUrl)}` : ""
return useServerQuery<Array<Extension_Extension>>({
endpoint: `${API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.endpoint}${url}`,
method: API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetMarketplaceExtensions.key, marketplaceUrl],
enabled: true,
})
}
export function useGetExtensionUpdateData() {
return useServerQuery<Array<ExtensionRepo_UpdateData>>({
endpoint: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.endpoint,
method: API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.methods[0],
queryKey: [API_ENDPOINTS.EXTENSIONS.GetExtensionUpdateData.key],
enabled: true,
})
}

View File

@@ -0,0 +1,44 @@
import { useServerMutation } from "@/api/client/requests"
import { RemoveFileCacheBucket_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { toast } from "sonner"
export function useGetFileCacheTotalSize() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.endpoint,
method: API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.methods[0],
mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheTotalSize.key],
})
}
export function useRemoveFileCacheBucket(onSuccess?: () => void) {
return useServerMutation<boolean, RemoveFileCacheBucket_Variables>({
endpoint: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.endpoint,
method: API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.methods[0],
mutationKey: [API_ENDPOINTS.FILECACHE.RemoveFileCacheBucket.key],
onSuccess: async () => {
toast.success("Cache cleared")
onSuccess?.()
},
})
}
export function useGetFileCacheMediastreamVideoFilesTotalSize() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.endpoint,
method: API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.methods[0],
mutationKey: [API_ENDPOINTS.FILECACHE.GetFileCacheMediastreamVideoFilesTotalSize.key],
})
}
export function useClearFileCacheMediastreamVideoFiles(onSuccess?: () => void) {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.endpoint,
method: API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.methods[0],
mutationKey: [API_ENDPOINTS.FILECACHE.ClearFileCacheMediastreamVideoFiles.key],
onSuccess: async () => {
toast.success("Cache cleared")
onSuccess?.()
},
})
}

View File

@@ -0,0 +1,165 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Local_QueueState, Local_TrackedMediaItem } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import {
LocalAddTrackedMedia_Variables,
LocalRemoveTrackedMedia_Variables,
LocalSetHasLocalChanges_Variables,
SetOfflineMode_Variables,
} from "../generated/endpoint.types"
export function useLocalGetTrackedMediaItems() {
return useServerQuery<Array<Local_TrackedMediaItem>>({
endpoint: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.endpoint,
method: API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.methods[0],
queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key],
enabled: true,
gcTime: 0,
})
}
export function useLocalAddTrackedMedia() {
const qc = useQueryClient()
return useServerMutation<boolean, LocalAddTrackedMedia_Variables>({
endpoint: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.endpoint,
method: API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalAddTrackedMedia.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
toast.success("Added media for offline syncing")
},
})
}
export function useLocalRemoveTrackedMedia() {
const qc = useQueryClient()
return useServerMutation<boolean, LocalRemoveTrackedMedia_Variables>({
endpoint: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.endpoint,
method: API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalRemoveTrackedMedia.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
toast.success("Removed offline data")
},
})
}
export function useLocalSyncData() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.LOCAL.LocalSyncData.endpoint,
method: API_ENDPOINTS.LOCAL.LocalSyncData.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncData.key],
onSuccess: async () => {
toast.info("Syncing local data...")
},
})
}
export function useLocalGetSyncQueueData() {
return useServerQuery<Local_QueueState>({
endpoint: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.endpoint,
method: API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.methods[0],
queryKey: [API_ENDPOINTS.LOCAL.LocalGetSyncQueueState.key],
enabled: true,
})
}
export function useLocalGetIsMediaTracked(id: number, type: string) {
return useServerQuery<boolean>({
endpoint: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.endpoint.replace("{id}", String(id)).replace("{type}", String(type)),
method: API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.methods[0],
queryKey: [API_ENDPOINTS.LOCAL.LocalGetIsMediaTracked.key, id, type],
enabled: true,
})
}
export function useLocalSyncAnilistData() {
const qc = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.endpoint,
method: API_ENDPOINTS.LOCAL.LocalSyncAnilistData.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncAnilistData.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetTrackedMediaItems.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetRawAnimeCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
toast.success("Updated Anilist data")
},
})
}
export function useLocalSetHasLocalChanges() {
const qc = useQueryClient()
return useServerMutation<boolean, LocalSetHasLocalChanges_Variables>({
endpoint: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.endpoint,
method: API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalSetHasLocalChanges.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key] })
},
})
}
export function useLocalGetHasLocalChanges() {
return useServerQuery<boolean>({
endpoint: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.endpoint,
method: API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.methods[0],
queryKey: [API_ENDPOINTS.LOCAL.LocalGetHasLocalChanges.key],
enabled: true,
})
}
export function useLocalGetLocalStorageSize() {
return useServerQuery<string>({
endpoint: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.endpoint,
method: API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.methods[0],
queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize.key],
enabled: true,
})
}
export function useLocalSyncSimulatedDataToAnilist() {
const qc = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.endpoint,
method: API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.LocalSyncSimulatedDataToAnilist.key],
onSuccess: async () => {
({ queryKey: [API_ENDPOINTS.LOCAL.LocalGetLocalStorageSize] })
toast.success("Updated Anilist data")
},
})
}
export function useSetOfflineMode() {
return useServerMutation<boolean, SetOfflineMode_Variables>({
endpoint: API_ENDPOINTS.LOCAL.SetOfflineMode.endpoint,
method: API_ENDPOINTS.LOCAL.SetOfflineMode.methods[0],
mutationKey: [API_ENDPOINTS.LOCAL.SetOfflineMode.key],
onSuccess: async (data) => {
if (data) {
toast.success("Offline mode enabled")
window.location.href = "/offline"
} else {
toast.success("Offline mode disabled")
window.location.href = "/"
}
},
})
}

View File

@@ -0,0 +1,125 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
DeleteLocalFiles_Variables,
ImportLocalFiles_Variables,
LocalFileBulkAction_Variables,
UpdateLocalFileData_Variables,
UpdateLocalFiles_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_LocalFile, Nullish } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetLocalFiles() {
return useServerQuery<Array<Anime_LocalFile>>({
endpoint: API_ENDPOINTS.LOCALFILES.GetLocalFiles.endpoint,
method: API_ENDPOINTS.LOCALFILES.GetLocalFiles.methods[0],
queryKey: [API_ENDPOINTS.LOCALFILES.GetLocalFiles.key],
enabled: true,
})
}
export function useLocalFileBulkAction() {
const qc = useQueryClient()
return useServerMutation<Array<Anime_LocalFile>, LocalFileBulkAction_Variables>({
endpoint: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.endpoint,
method: API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.LocalFileBulkAction.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
},
})
}
export function useUpdateLocalFiles() {
const qc = useQueryClient()
return useServerMutation<boolean, UpdateLocalFiles_Variables>({
endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.endpoint,
method: API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFiles.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
},
})
}
export function useUpdateLocalFileData(id: Nullish<number>) {
const qc = useQueryClient()
const opts = useServerMutation<Array<Anime_LocalFile>, UpdateLocalFileData_Variables>({
endpoint: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.endpoint,
method: API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.UpdateLocalFileData.key],
onSuccess: async () => {
toast.success("File metadata updated")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
if (id) {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
}
},
})
return {
updateLocalFile: (lf: Anime_LocalFile, variables: Partial<UpdateLocalFileData_Variables>, onSuccess?: () => void) => {
opts.mutate({
path: lf.path,
metadata: lf.metadata,
locked: lf.locked,
ignored: lf.ignored,
mediaId: lf.mediaId,
...variables,
}, {
onSuccess: () => onSuccess?.(),
})
},
...opts,
}
}
export function useDeleteLocalFiles(id: Nullish<number>) {
const qc = useQueryClient()
return useServerMutation<Array<Anime_LocalFile>, DeleteLocalFiles_Variables>({
endpoint: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.endpoint,
method: API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.DeleteLocalFiles.key],
onSuccess: async () => {
toast.success("Files deleted")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
if (id) {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(id)] })
}
},
})
}
export function useRemoveEmptyDirectories() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.endpoint,
method: API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.RemoveEmptyDirectories.key],
onSuccess: async () => {
toast.success("Empty directories removed")
},
})
}
export function useImportLocalFiles() {
const qc = useQueryClient()
return useServerMutation<boolean, ImportLocalFiles_Variables>({
endpoint: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.endpoint,
method: API_ENDPOINTS.LOCALFILES.ImportLocalFiles.methods[0],
mutationKey: [API_ENDPOINTS.LOCALFILES.ImportLocalFiles.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
toast.success("Local files imported")
},
})
}

View File

@@ -0,0 +1,42 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { EditMALListEntryProgress_Variables, MALAuth_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { MalAuthResponse } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useMALAuth(variables: Partial<MALAuth_Variables>, enabled: boolean) {
return useServerQuery<MalAuthResponse, MALAuth_Variables>({
endpoint: API_ENDPOINTS.MAL.MALAuth.endpoint,
method: API_ENDPOINTS.MAL.MALAuth.methods[0],
queryKey: [API_ENDPOINTS.MAL.MALAuth.key],
data: variables as MALAuth_Variables,
enabled: enabled,
})
}
export function useEditMALListEntryProgress() {
return useServerMutation<boolean, EditMALListEntryProgress_Variables>({
endpoint: API_ENDPOINTS.MAL.EditMALListEntryProgress.endpoint,
method: API_ENDPOINTS.MAL.EditMALListEntryProgress.methods[0],
mutationKey: [API_ENDPOINTS.MAL.EditMALListEntryProgress.key],
onSuccess: async () => {
},
})
}
export function useMALLogout() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MAL.MALLogout.endpoint,
method: API_ENDPOINTS.MAL.MALLogout.methods[0],
mutationKey: [API_ENDPOINTS.MAL.MALLogout.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Successfully logged out of MyAnimeList")
},
})
}

View File

@@ -0,0 +1,222 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
AnilistListManga_Variables,
EmptyMangaEntryCache_Variables,
GetAnilistMangaCollection_Variables,
GetMangaEntryChapters_Variables,
GetMangaEntryPages_Variables,
GetMangaMapping_Variables,
MangaManualMapping_Variables,
MangaManualSearch_Variables,
RefetchMangaChapterContainers_Variables,
RemoveMangaMapping_Variables,
UpdateMangaProgress_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import {
AL_ListManga,
AL_MangaCollection,
AL_MangaDetailsById_Media,
HibikeManga_SearchResult,
Manga_ChapterContainer,
Manga_Collection,
Manga_Entry,
Manga_MangaLatestChapterNumberItem,
Manga_MappingResponse,
Manga_PageContainer,
Nullish,
} from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetAnilistMangaCollection() {
return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({
endpoint: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.endpoint,
method: API_ENDPOINTS.MANGA.GetAnilistMangaCollection.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetAnilistMangaCollection.key],
enabled: true,
})
}
export function useGetRawAnilistMangaCollection() {
return useServerQuery<AL_MangaCollection, GetAnilistMangaCollection_Variables>({
endpoint: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.endpoint,
method: API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetRawAnilistMangaCollection.key],
enabled: true,
})
}
export function useGetMangaCollection() {
return useServerQuery<Manga_Collection>({
endpoint: API_ENDPOINTS.MANGA.GetMangaCollection.endpoint,
method: API_ENDPOINTS.MANGA.GetMangaCollection.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key],
enabled: true,
})
}
export function useGetMangaEntry(id: Nullish<string | number>) {
return useServerQuery<Manga_Entry>({
endpoint: API_ENDPOINTS.MANGA.GetMangaEntry.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.MANGA.GetMangaEntry.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)],
enabled: !!id,
})
}
export function useGetMangaEntryDetails(id: Nullish<string | number>) {
return useServerQuery<AL_MangaDetailsById_Media>({
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDetails.endpoint.replace("{id}", String(id)),
method: API_ENDPOINTS.MANGA.GetMangaEntryDetails.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDetails.key, String(id)],
enabled: !!id,
})
}
export function useEmptyMangaEntryCache() {
const queryClient = useQueryClient()
return useServerMutation<boolean, EmptyMangaEntryCache_Variables>({
endpoint: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.endpoint,
method: API_ENDPOINTS.MANGA.EmptyMangaEntryCache.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.EmptyMangaEntryCache.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
},
})
}
export function useGetMangaEntryChapters(variables: Partial<GetMangaEntryChapters_Variables>) {
return useServerQuery<Manga_ChapterContainer, GetMangaEntryChapters_Variables>({
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryChapters.endpoint,
method: API_ENDPOINTS.MANGA.GetMangaEntryChapters.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key, String(variables.mediaId), variables.provider],
data: variables as GetMangaEntryChapters_Variables,
enabled: !!variables.mediaId && !!variables.provider,
muteError: true,
})
}
export function useGetMangaEntryPages(variables: Partial<GetMangaEntryPages_Variables>) {
return useServerQuery<Manga_PageContainer, GetMangaEntryPages_Variables>({
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryPages.endpoint,
method: API_ENDPOINTS.MANGA.GetMangaEntryPages.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key, String(variables.mediaId), variables.provider, variables.chapterId,
variables.doublePage],
data: variables as GetMangaEntryPages_Variables,
enabled: !!variables.mediaId && !!variables.provider && !!variables.chapterId,
})
}
export function useAnilistListManga(variables: AnilistListManga_Variables, enabled?: boolean) {
return useServerQuery<AL_ListManga, AnilistListManga_Variables>({
endpoint: API_ENDPOINTS.MANGA.AnilistListManga.endpoint,
method: API_ENDPOINTS.MANGA.AnilistListManga.methods[0],
queryKey: [API_ENDPOINTS.MANGA.AnilistListManga.key, variables],
data: variables,
enabled: enabled ?? true,
})
}
export function useUpdateMangaProgress(id: Nullish<string | number>) {
const queryClient = useQueryClient()
return useServerMutation<boolean, UpdateMangaProgress_Variables>({
endpoint: API_ENDPOINTS.MANGA.UpdateMangaProgress.endpoint,
method: API_ENDPOINTS.MANGA.UpdateMangaProgress.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.UpdateMangaProgress.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntry.key, String(id)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaCollection.key] })
},
})
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
export function useMangaManualSearch(mediaId: Nullish<number>, provider: Nullish<string>) {
return useServerMutation<Array<HibikeManga_SearchResult>, MangaManualSearch_Variables>({
endpoint: API_ENDPOINTS.MANGA.MangaManualSearch.endpoint,
method: API_ENDPOINTS.MANGA.MangaManualSearch.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.MangaManualSearch.key, String(mediaId), provider],
gcTime: 0,
})
}
export function useMangaManualMapping() {
const queryClient = useQueryClient()
return useServerMutation<boolean, MangaManualMapping_Variables>({
endpoint: API_ENDPOINTS.MANGA.MangaManualMapping.endpoint,
method: API_ENDPOINTS.MANGA.MangaManualMapping.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.MangaManualMapping.key],
onSuccess: async () => {
toast.success("Mapping added")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] })
},
})
}
export function useGetMangaMapping(variables: Partial<GetMangaMapping_Variables>) {
return useServerQuery<Manga_MappingResponse, GetMangaMapping_Variables>({
endpoint: API_ENDPOINTS.MANGA.GetMangaMapping.endpoint,
method: API_ENDPOINTS.MANGA.GetMangaMapping.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key, String(variables.mediaId), variables.provider],
data: variables as GetMangaMapping_Variables,
enabled: !!variables.provider && !!variables.mediaId,
})
}
export function useRemoveMangaMapping() {
const queryClient = useQueryClient()
return useServerMutation<boolean, RemoveMangaMapping_Variables>({
endpoint: API_ENDPOINTS.MANGA.RemoveMangaMapping.endpoint,
method: API_ENDPOINTS.MANGA.RemoveMangaMapping.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.RemoveMangaMapping.key],
onSuccess: async () => {
toast.info("Mapping removed")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaMapping.key] })
},
})
}
export function useGetMangaEntryDownloadedChapters(mId: Nullish<string | number>) {
return useServerQuery<Array<Manga_ChapterContainer>>({
endpoint: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.endpoint.replace("{id}", String(mId)),
method: API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryDownloadedChapters.key, String(mId)],
})
}
export function useGetMangaLatestChapterNumbersMap() {
return useServerQuery<Record<number, Array<Manga_MangaLatestChapterNumberItem>>>({
endpoint: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.endpoint,
method: API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.methods[0],
queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key],
enabled: true,
})
}
export function useRefetchMangaChapterContainers() {
const queryClient = useQueryClient()
return useServerMutation<boolean, RefetchMangaChapterContainers_Variables>({
endpoint: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.endpoint,
method: API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.methods[0],
mutationKey: [API_ENDPOINTS.MANGA.RefetchMangaChapterContainers.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaLatestChapterNumbersMap.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryChapters.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA.GetMangaEntryPages.key] })
toast.success("Sources refreshed")
},
})
}

View File

@@ -0,0 +1,122 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
DeleteMangaDownloadedChapters_Variables,
DownloadMangaChapters_Variables,
GetMangaDownloadData_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Manga_DownloadListItem, Manga_MediaDownloadData, Models_ChapterDownloadQueueItem, Nullish } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useDownloadMangaChapters(id: Nullish<string | number>, provider: Nullish<string>) {
const queryClient = useQueryClient()
return useServerMutation<boolean, DownloadMangaChapters_Variables>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DownloadMangaChapters.key, String(id), provider],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] })
},
})
}
export function useGetMangaDownloadData(variables: Partial<GetMangaDownloadData_Variables>) {
return useServerQuery<Manga_MediaDownloadData, GetMangaDownloadData_Variables>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.methods[0],
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key, String(variables.mediaId), String(variables.cached)],
data: variables as GetMangaDownloadData_Variables,
enabled: !!variables.mediaId,
})
}
export function useGetMangaDownloadQueue() {
return useServerQuery<Array<Models_ChapterDownloadQueueItem>>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.methods[0],
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key],
enabled: true,
})
}
export function useStartMangaDownloadQueue() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StartMangaDownloadQueue.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
toast.info("Downloading chapters")
},
})
}
export function useStopMangaDownloadQueue() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.StopMangaDownloadQueue.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
toast.success("Download queue stopped")
},
})
}
export function useClearAllChapterDownloadQueue() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ClearAllChapterDownloadQueue.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
toast.success("Download queue cleared")
},
})
}
export function useResetErroredChapterDownloadQueue() {
const queryClient = useQueryClient()
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.ResetErroredChapterDownloadQueue.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadQueue.key] })
toast.success("Reset errored chapters")
},
})
}
export function useDeleteMangaDownloadedChapters(id: Nullish<string | number>, provider: string | null) {
const queryClient = useQueryClient()
return useServerMutation<boolean, DeleteMangaDownloadedChapters_Variables>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.methods[0],
mutationKey: [API_ENDPOINTS.MANGA_DOWNLOAD.DeleteMangaDownloadedChapters.key, String(id), provider],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadData.key] })
toast.success("Chapters deleted")
},
})
}
export function useGetMangaDownloadsList() {
return useServerQuery<Array<Manga_DownloadListItem>>({
endpoint: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.endpoint,
method: API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.methods[0],
queryKey: [API_ENDPOINTS.MANGA_DOWNLOAD.GetMangaDownloadsList.key],
enabled: true,
})
}

View File

@@ -0,0 +1,14 @@
import { useServerMutation } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
export function useStartDefaultMediaPlayer() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.endpoint,
method: API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.methods[0],
mutationKey: [API_ENDPOINTS.MEDIAPLAYER.StartDefaultMediaPlayer.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,66 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
PreloadMediastreamMediaContainer_Variables,
RequestMediastreamMediaContainer_Variables,
SaveMediastreamSettings_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Mediastream_MediaContainer, Models_MediastreamSettings } from "@/api/generated/types"
import { logger } from "@/lib/helpers/debug"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetMediastreamSettings(enabled?: boolean) {
return useServerQuery<Models_MediastreamSettings>({
endpoint: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.endpoint,
method: API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.methods[0],
queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key],
enabled: enabled,
})
}
export function useSaveMediastreamSettings() {
const qc = useQueryClient()
return useServerMutation<Models_MediastreamSettings, SaveMediastreamSettings_Variables>({
endpoint: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.endpoint,
method: API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.methods[0],
mutationKey: [API_ENDPOINTS.MEDIASTREAM.SaveMediastreamSettings.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.MEDIASTREAM.GetMediastreamSettings.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Settings saved")
},
})
}
export function useRequestMediastreamMediaContainer(variables: Partial<RequestMediastreamMediaContainer_Variables>, enabled: boolean) {
return useServerQuery<Mediastream_MediaContainer, RequestMediastreamMediaContainer_Variables>({
endpoint: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.endpoint,
method: API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.methods[0],
queryKey: [API_ENDPOINTS.MEDIASTREAM.RequestMediastreamMediaContainer.key, variables?.path, variables?.streamType],
data: variables as RequestMediastreamMediaContainer_Variables,
enabled: !!variables.path && !!variables.streamType && enabled,
})
}
export function usePreloadMediastreamMediaContainer() {
return useServerMutation<boolean, PreloadMediastreamMediaContainer_Variables>({
endpoint: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.endpoint,
method: API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.methods[0],
mutationKey: [API_ENDPOINTS.MEDIASTREAM.PreloadMediastreamMediaContainer.key],
onSuccess: async () => {
logger("MEDIASTREAM").success("Preloaded mediastream media container")
},
})
}
export function useMediastreamShutdownTranscodeStream() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.endpoint,
method: API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.methods[0],
mutationKey: [API_ENDPOINTS.MEDIASTREAM.MediastreamShutdownTranscodeStream.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,36 @@
import { useServerMutation } from "@/api/client/requests"
import { PopulateFillerData_Variables, RemoveFillerData_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function usePopulateFillerData() {
const queryClient = useQueryClient()
return useServerMutation<true, PopulateFillerData_Variables>({
endpoint: API_ENDPOINTS.METADATA.PopulateFillerData.endpoint,
method: API_ENDPOINTS.METADATA.PopulateFillerData.methods[0],
mutationKey: [API_ENDPOINTS.METADATA.PopulateFillerData.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
toast.success("Filler data fetched")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] })
},
})
}
export function useRemoveFillerData() {
const queryClient = useQueryClient()
return useServerMutation<boolean, RemoveFillerData_Variables>({
endpoint: API_ENDPOINTS.METADATA.RemoveFillerData.endpoint,
method: API_ENDPOINTS.METADATA.RemoveFillerData.methods[0],
mutationKey: [API_ENDPOINTS.METADATA.RemoveFillerData.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
toast.success("Filler data removed")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME.GetAnimeEpisodeCollection.key] })
},
})
}

View File

@@ -0,0 +1,100 @@
import { useServerMutation, useServerQuery } from "../client/requests"
import { NakamaCreateWatchParty_Variables, NakamaPlayVideo_Variables, SendNakamaMessage_Variables } from "../generated/endpoint.types"
import { API_ENDPOINTS } from "../generated/endpoints"
import { Anime_LibraryCollection, Nakama_MessageResponse } from "../generated/types"
export function useNakamaWebSocket() {
return useServerQuery<boolean>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaWebSocket.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaWebSocket.methods[0],
queryKey: [API_ENDPOINTS.NAKAMA.NakamaWebSocket.key],
enabled: true,
})
}
export function useSendNakamaMessage() {
return useServerMutation<Nakama_MessageResponse, SendNakamaMessage_Variables>({
endpoint: API_ENDPOINTS.NAKAMA.SendNakamaMessage.endpoint,
method: API_ENDPOINTS.NAKAMA.SendNakamaMessage.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.SendNakamaMessage.key],
onSuccess: async () => {
},
})
}
export function useNakamaReconnectToHost() {
return useServerMutation<Nakama_MessageResponse, {}>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaReconnectToHost.key],
onSuccess: async () => {
},
})
}
export function useNakamaRemoveStaleConnections() {
return useServerMutation<Nakama_MessageResponse, {}>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaRemoveStaleConnections.key],
onSuccess: async () => {
},
})
}
export function useGetNakamaAnimeLibraryCollection() {
return useServerQuery<Anime_LibraryCollection>({
endpoint: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.endpoint,
method: API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.methods[0],
queryKey: [API_ENDPOINTS.NAKAMA.GetNakamaAnimeLibraryCollection.key],
enabled: true,
})
}
export function useNakamaPlayVideo() {
return useServerMutation<boolean, NakamaPlayVideo_Variables>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaPlayVideo.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaPlayVideo.key],
onSuccess: async () => {
},
})
}
export function useNakamaCreateWatchParty() {
return useServerMutation<any, NakamaCreateWatchParty_Variables>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaCreateWatchParty.key],
onSuccess: async () => {
},
})
}
export function useNakamaJoinWatchParty() {
return useServerMutation<Nakama_MessageResponse>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaJoinWatchParty.key],
onSuccess: async () => {
},
})
}
export function useNakamaLeaveWatchParty() {
return useServerMutation<Nakama_MessageResponse>({
endpoint: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.endpoint,
method: API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.methods[0],
mutationKey: [API_ENDPOINTS.NAKAMA.NakamaLeaveWatchParty.key],
onSuccess: async () => {
},
})
}

View File

@@ -0,0 +1,125 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
GetOnlineStreamEpisodeList_Variables,
GetOnlineStreamEpisodeSource_Variables,
GetOnlinestreamMapping_Variables,
OnlineStreamEmptyCache_Variables,
OnlinestreamManualMapping_Variables,
OnlinestreamManualSearch_Variables,
RemoveOnlinestreamMapping_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import {
HibikeOnlinestream_SearchResult,
Nullish,
Onlinestream_EpisodeListResponse,
Onlinestream_EpisodeSource,
Onlinestream_MappingResponse,
} from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetOnlineStreamEpisodeList(id: Nullish<string | number>, provider: Nullish<string>, dubbed: boolean) {
return useServerQuery<Onlinestream_EpisodeListResponse, GetOnlineStreamEpisodeList_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.methods[0],
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key, String(id), provider, dubbed],
data: {
mediaId: Number(id),
provider: provider!,
dubbed,
},
enabled: !!id,
muteError: true,
})
}
export function useGetOnlineStreamEpisodeSource(id: Nullish<string | number>,
provider: Nullish<string>,
episodeNumber: Nullish<number>,
dubbed: boolean,
enabled: boolean,
) {
return useServerQuery<Onlinestream_EpisodeSource, GetOnlineStreamEpisodeSource_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.methods[0],
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key, String(id), provider, episodeNumber, dubbed],
data: {
mediaId: Number(id),
episodeNumber: episodeNumber!,
dubbed: dubbed,
provider: provider!,
},
enabled: enabled && !!provider,
muteError: true,
})
}
export function useOnlineStreamEmptyCache() {
const queryClient = useQueryClient()
return useServerMutation<boolean, OnlineStreamEmptyCache_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.methods[0],
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlineStreamEmptyCache.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
toast.info("Stream cache emptied")
},
})
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
export function useOnlinestreamManualSearch(mId: number, provider: Nullish<string>) {
return useServerMutation<Array<HibikeOnlinestream_SearchResult>, OnlinestreamManualSearch_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.methods[0],
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualSearch.key],
onSuccess: async () => {
},
})
}
export function useOnlinestreamManualMapping() {
const qc = useQueryClient()
return useServerMutation<boolean, OnlinestreamManualMapping_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.methods[0],
mutationKey: [API_ENDPOINTS.ONLINESTREAM.OnlinestreamManualMapping.key],
onSuccess: async () => {
toast.success("Mapping added")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
},
})
}
export function useGetOnlinestreamMapping(variables: Partial<GetOnlinestreamMapping_Variables>) {
return useServerQuery<Onlinestream_MappingResponse, GetOnlinestreamMapping_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.methods[0],
queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key, String(variables.mediaId), variables.provider],
data: variables as GetOnlinestreamMapping_Variables,
enabled: !!variables.provider && !!variables.mediaId,
})
}
export function useRemoveOnlinestreamMapping() {
const qc = useQueryClient()
return useServerMutation<boolean, RemoveOnlinestreamMapping_Variables>({
endpoint: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.endpoint,
method: API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.methods[0],
mutationKey: [API_ENDPOINTS.ONLINESTREAM.RemoveOnlinestreamMapping.key],
onSuccess: async () => {
toast.info("Mapping removed")
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlinestreamMapping.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeList.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ONLINESTREAM.GetOnlineStreamEpisodeSource.key] })
},
})
}

View File

@@ -0,0 +1,137 @@
import { useServerMutation } from "@/api/client/requests"
import { PlaybackPlayVideo_Variables, PlaybackStartManualTracking_Variables, PlaybackStartPlaylist_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_LocalFile } from "@/api/generated/types"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function usePlaybackSyncCurrentProgress() {
const serverStatus = useServerStatus()
const queryClient = useQueryClient()
return useServerMutation<number>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackSyncCurrentProgress.key],
onSuccess: async mediaId => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key, String(mediaId)] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANILIST.GetAnimeCollection.key] })
},
})
}
export function usePlaybackPlayNextEpisode(...keys: any) {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayNextEpisode.key, ...keys],
onSuccess: async () => {
},
})
}
export function usePlaybackStartPlaylist({
onSuccess,
}: {
onSuccess?: () => void
}) {
const queryClient = useQueryClient()
return useServerMutation<boolean, PlaybackStartPlaylist_Variables>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartPlaylist.key],
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
onSuccess?.()
},
})
}
export function usePlaybackCancelCurrentPlaylist(...keys: any) {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelCurrentPlaylist.key, ...keys],
onSuccess: async () => {
toast.info("Cancelling playlist")
},
})
}
export function usePlaybackPlaylistNext(...keys: any) {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlaylistNext.key, ...keys],
onSuccess: async () => {
toast.info("Loading next file")
},
})
}
export function usePlaybackPlayVideo() {
return useServerMutation<boolean, PlaybackPlayVideo_Variables>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayVideo.key],
onSuccess: async () => {
},
})
}
export function usePlaybackPlayRandomVideo() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackPlayRandomVideo.key],
onSuccess: async () => {
toast.success("Playing random episode")
},
})
}
export function usePlaybackStartManualTracking() {
return useServerMutation<boolean, PlaybackStartManualTracking_Variables>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackStartManualTracking.key],
onSuccess: async () => {
},
})
}
export function usePlaybackCancelManualTracking({ onSuccess }: { onSuccess?: () => void }) {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackCancelManualTracking.key],
onSuccess: async () => {
onSuccess?.()
},
})
}
export function usePlaybackGetNextEpisode() {
return useServerMutation<Anime_LocalFile>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackGetNextEpisode.key],
})
}
export function usePlaybackAutoPlayNextEpisode() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.endpoint,
method: API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.methods[0],
mutationKey: [API_ENDPOINTS.PLAYBACK_MANAGER.PlaybackAutoPlayNextEpisode.key],
onSuccess: async () => {
toast.info("Loading next episode")
},
})
}

View File

@@ -0,0 +1,68 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { CreatePlaylist_Variables, DeletePlaylist_Variables, UpdatePlaylist_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_LocalFile, Anime_Playlist } from "@/api/generated/types"
import { Nullish } from "@/types/common"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useCreatePlaylist() {
const queryClient = useQueryClient()
return useServerMutation<Anime_Playlist, CreatePlaylist_Variables>({
endpoint: API_ENDPOINTS.PLAYLIST.CreatePlaylist.endpoint,
method: API_ENDPOINTS.PLAYLIST.CreatePlaylist.methods[0],
mutationKey: [API_ENDPOINTS.PLAYLIST.CreatePlaylist.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
toast.success("Playlist created")
},
})
}
export function useGetPlaylists() {
return useServerQuery<Array<Anime_Playlist>>({
endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylists.endpoint,
method: API_ENDPOINTS.PLAYLIST.GetPlaylists.methods[0],
queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key],
enabled: true,
})
}
export function useUpdatePlaylist() {
const queryClient = useQueryClient()
return useServerMutation<Anime_Playlist, UpdatePlaylist_Variables>({
endpoint: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.endpoint,
method: API_ENDPOINTS.PLAYLIST.UpdatePlaylist.methods[0],
mutationKey: [API_ENDPOINTS.PLAYLIST.UpdatePlaylist.key],
onSuccess: async () => {
await queryClient.refetchQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
toast.success("Playlist updated")
},
})
}
export function useDeletePlaylist() {
const queryClient = useQueryClient()
return useServerMutation<boolean, DeletePlaylist_Variables>({
endpoint: API_ENDPOINTS.PLAYLIST.DeletePlaylist.endpoint,
method: API_ENDPOINTS.PLAYLIST.DeletePlaylist.methods[0],
mutationKey: [API_ENDPOINTS.PLAYLIST.DeletePlaylist.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylists.key] })
toast.success("Playlist deleted")
},
})
}
export function useGetPlaylistEpisodes(id: Nullish<number>, progress: Nullish<number>) {
return useServerQuery<Array<Anime_LocalFile>>({
endpoint: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.endpoint.replace("{id}", String(id)).replace("{progress}", String(progress || 0)),
method: API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.methods[0],
queryKey: [API_ENDPOINTS.PLAYLIST.GetPlaylistEpisodes.key, String(id), String(progress || 0)],
enabled: !!id,
})
}

View File

@@ -0,0 +1,37 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { InstallLatestUpdate_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Status, Updater_Update } from "@/api/generated/types"
import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { toast } from "sonner"
export function useGetLatestUpdate(enabled: boolean) {
return useServerQuery<Updater_Update>({
endpoint: API_ENDPOINTS.RELEASES.GetLatestUpdate.endpoint,
method: API_ENDPOINTS.RELEASES.GetLatestUpdate.methods[0],
queryKey: [API_ENDPOINTS.RELEASES.GetLatestUpdate.key],
enabled: enabled,
})
}
export function useInstallLatestUpdate() {
const setServerStatus = useSetServerStatus()
return useServerMutation<Status, InstallLatestUpdate_Variables>({
endpoint: API_ENDPOINTS.RELEASES.InstallLatestUpdate.endpoint,
method: API_ENDPOINTS.RELEASES.InstallLatestUpdate.methods[0],
mutationKey: [API_ENDPOINTS.RELEASES.InstallLatestUpdate.key],
onSuccess: async (data) => {
setServerStatus(data) // Update server status
toast.info("Installing update...")
},
})
}
export function useGetChangelog(before: string, after: string, enabled: boolean) {
return useServerQuery<{ version: string, lines: string[] }[]>({
endpoint: API_ENDPOINTS.RELEASES.GetChangelog.endpoint + `?before=${before}&after=${after}`,
method: API_ENDPOINTS.RELEASES.GetChangelog.methods[0],
queryKey: [API_ENDPOINTS.RELEASES.GetChangelog.key],
enabled: enabled,
})
}

View File

@@ -0,0 +1,23 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { SaveIssueReport_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
export function useSaveIssueReport() {
return useServerMutation<boolean, SaveIssueReport_Variables>({
endpoint: API_ENDPOINTS.REPORT.SaveIssueReport.endpoint,
method: API_ENDPOINTS.REPORT.SaveIssueReport.methods[0],
mutationKey: [API_ENDPOINTS.REPORT.SaveIssueReport.key],
onSuccess: async () => {
},
})
}
export function useDownloadIssueReport() {
return useServerQuery<string>({
endpoint: API_ENDPOINTS.REPORT.DownloadIssueReport.endpoint,
method: API_ENDPOINTS.REPORT.DownloadIssueReport.methods[0],
queryKey: [API_ENDPOINTS.REPORT.DownloadIssueReport.key],
enabled: true,
})
}

View File

@@ -0,0 +1,26 @@
import { useServerMutation } from "@/api/client/requests"
import { ScanLocalFiles_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Anime_LocalFile } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useScanLocalFiles(onSuccess?: () => void) {
const queryClient = useQueryClient()
return useServerMutation<Array<Anime_LocalFile>, ScanLocalFiles_Variables>({
endpoint: API_ENDPOINTS.SCAN.ScanLocalFiles.endpoint,
method: API_ENDPOINTS.SCAN.ScanLocalFiles.methods[0],
mutationKey: [API_ENDPOINTS.SCAN.ScanLocalFiles.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
toast.success("Library scanned")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetAnimeEntry.key] })
onSuccess?.()
},
})
}

View File

@@ -0,0 +1,13 @@
import { useServerQuery } from "@/api/client/requests"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Summary_ScanSummaryItem } from "@/api/generated/types"
export function useGetScanSummaries() {
return useServerQuery<Array<Summary_ScanSummaryItem>>({
endpoint: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.endpoint,
method: API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.methods[0],
queryKey: [API_ENDPOINTS.SCAN_SUMMARY.GetScanSummaries.key],
enabled: true,
})
}

View File

@@ -0,0 +1,64 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { GettingStarted_Variables, SaveAutoDownloaderSettings_Variables, SaveSettings_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Models_Settings, Status } from "@/api/generated/types"
import { isLoginModalOpenAtom } from "@/app/(main)/_atoms/server-status.atoms"
import { useQueryClient } from "@tanstack/react-query"
import { useSetAtom } from "jotai/react"
import { toast } from "sonner"
export function useGetSettings() {
return useServerQuery<Models_Settings>({
endpoint: API_ENDPOINTS.SETTINGS.GetSettings.endpoint,
method: API_ENDPOINTS.SETTINGS.GetSettings.methods[0],
queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key],
enabled: true,
})
}
export function useGettingStarted() {
const queryClient = useQueryClient()
const setLoginModalOpen = useSetAtom(isLoginModalOpenAtom)
return useServerMutation<Status, GettingStarted_Variables>({
endpoint: API_ENDPOINTS.SETTINGS.GettingStarted.endpoint,
method: API_ENDPOINTS.SETTINGS.GettingStarted.methods[0],
mutationKey: [API_ENDPOINTS.SETTINGS.GettingStarted.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
setLoginModalOpen(true)
},
})
}
export function useSaveSettings() {
const queryClient = useQueryClient()
return useServerMutation<Status, SaveSettings_Variables>({
endpoint: API_ENDPOINTS.SETTINGS.SaveSettings.endpoint,
method: API_ENDPOINTS.SETTINGS.SaveSettings.methods[0],
mutationKey: [API_ENDPOINTS.SETTINGS.SaveSettings.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Settings saved")
},
})
}
export function useSaveAutoDownloaderSettings() {
const queryClient = useQueryClient()
return useServerMutation<boolean, SaveAutoDownloaderSettings_Variables>({
endpoint: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.endpoint,
method: API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.methods[0],
mutationKey: [API_ENDPOINTS.SETTINGS.SaveAutoDownloaderSettings.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.SETTINGS.GetSettings.key] })
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Settings saved")
},
})
}

View File

@@ -0,0 +1,278 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { getServerBaseUrl } from "@/api/client/server-url"
import { DeleteLogs_Variables, GetAnnouncements_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { MemoryStatsResponse, Status, Updater_Announcement } from "@/api/generated/types"
import { serverAuthTokenAtom } from "@/app/(main)/_atoms/server-status.atoms"
import { copyToClipboard, openTab } from "@/lib/helpers/browser"
import { __isDesktop__ } from "@/types/constants"
import { useQueryClient } from "@tanstack/react-query"
import { useAtomValue } from "jotai"
import { toast } from "sonner"
export function useGetStatus() {
return useServerQuery<Status>({
endpoint: API_ENDPOINTS.STATUS.GetStatus.endpoint,
method: API_ENDPOINTS.STATUS.GetStatus.methods[0],
queryKey: [API_ENDPOINTS.STATUS.GetStatus.key],
enabled: true,
retryDelay: 1000,
// Fixes macOS desktop app startup issue
retry: 6,
// Mute error if the platform is desktop
muteError: __isDesktop__,
})
}
export function useGetLogFilenames() {
return useServerQuery<Array<string>>({
endpoint: API_ENDPOINTS.STATUS.GetLogFilenames.endpoint,
method: API_ENDPOINTS.STATUS.GetLogFilenames.methods[0],
queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key],
enabled: true,
})
}
export function useDeleteLogs() {
const qc = useQueryClient()
return useServerMutation<boolean, DeleteLogs_Variables>({
endpoint: API_ENDPOINTS.STATUS.DeleteLogs.endpoint,
method: API_ENDPOINTS.STATUS.DeleteLogs.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.DeleteLogs.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetLogFilenames.key] })
toast.success("Logs deleted")
},
})
}
export function useGetLatestLogContent() {
const qc = useQueryClient()
return useServerMutation<string>({
endpoint: API_ENDPOINTS.STATUS.GetLatestLogContent.endpoint,
method: API_ENDPOINTS.STATUS.GetLatestLogContent.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.GetLatestLogContent.key],
onSuccess: async data => {
if (!data) return toast.error("Couldn't fetch logs")
try {
await copyToClipboard(data)
toast.success("Copied to clipboard")
}
catch (err: any) {
console.error("Clipboard write error:", err)
toast.error("Failed to copy logs: " + err.message)
}
},
})
}
export function useGetAnnouncements() {
return useServerMutation<Array<Updater_Announcement>, GetAnnouncements_Variables>({
endpoint: API_ENDPOINTS.STATUS.GetAnnouncements.endpoint,
method: API_ENDPOINTS.STATUS.GetAnnouncements.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.GetAnnouncements.key],
})
}
// Memory profiling hooks
export function useGetMemoryStats() {
return useServerQuery<MemoryStatsResponse>({
endpoint: API_ENDPOINTS.STATUS.GetMemoryStats.endpoint,
method: API_ENDPOINTS.STATUS.GetMemoryStats.methods[0],
queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key],
enabled: false, // Manual trigger only
refetchInterval: false,
})
}
export function useForceGC() {
const qc = useQueryClient()
return useServerMutation<MemoryStatsResponse>({
endpoint: API_ENDPOINTS.STATUS.ForceGC.endpoint,
method: API_ENDPOINTS.STATUS.ForceGC.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.ForceGC.key],
onSuccess: async () => {
// Invalidate and refetch memory stats after GC
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetMemoryStats.key] })
toast.success("Garbage collection completed")
},
})
}
export function useDownloadMemoryProfile() {
const password = useAtomValue(serverAuthTokenAtom)
return useServerMutation<string, { profileType: "heap" | "allocs" }>({
endpoint: API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint,
method: API_ENDPOINTS.STATUS.GetMemoryProfile.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.GetMemoryProfile.key],
onMutate: async (variables) => {
const profileType = variables.profileType || "heap"
toast.info(`Generating ${profileType} profile...`)
let downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetMemoryProfile.endpoint
if (profileType === "heap") {
downloadUrl += "?heap=true"
} else if (profileType === "allocs") {
downloadUrl += "?allocs=true"
}
try {
const headers: Record<string, string> = {}
if (password) {
headers["X-Seanime-Token"] = password
}
const response = await fetch(downloadUrl, {
method: "GET",
headers,
})
if (!response.ok) {
throw new Error(`HTTP error: status: ${response.status}`)
}
const blob = await response.blob()
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
const filename = `seanime-${profileType}-profile-${timestamp}.pprof`
const url = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.setAttribute("download", filename)
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success(`Profile "${profileType}" downloaded`)
}
catch (error) {
console.error("Download error:", error)
toast.error(`Failed to download ${profileType} profile`)
}
throw new Error("Download handled in onMutate")
},
onError: (error) => {
if (error.message !== "Download handled in onMutate") {
toast.error("Failed to download memory profile")
}
},
})
}
export function useDownloadGoRoutineProfile() {
const password = useAtomValue(serverAuthTokenAtom)
return useServerMutation<string>({
endpoint: API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint,
method: API_ENDPOINTS.STATUS.GetGoRoutineProfile.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.GetGoRoutineProfile.key],
onMutate: async () => {
toast.info("Generating goroutine profile...")
const downloadUrl = getServerBaseUrl() + API_ENDPOINTS.STATUS.GetGoRoutineProfile.endpoint
try {
const headers: Record<string, string> = {}
if (password) {
headers["X-Seanime-Token"] = password
}
const response = await fetch(downloadUrl, {
method: "GET",
headers,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
const filename = `seanime-goroutine-profile-${timestamp}.pprof`
const url = window.URL.createObjectURL(blob)
openTab(url)
toast.success("Goroutine profile downloaded")
}
catch (error) {
console.error("Download error:", error)
toast.error("Failed to download goroutine profile")
}
throw new Error("Download handled in onMutate")
},
onError: (error) => {
if (error.message !== "Download handled in onMutate") {
toast.error("Failed to download goroutine profile")
}
},
})
}
export function useDownloadCPUProfile() {
const password = useAtomValue(serverAuthTokenAtom)
return useServerMutation<string, { duration?: number }>({
endpoint: API_ENDPOINTS.STATUS.GetCPUProfile.endpoint,
method: API_ENDPOINTS.STATUS.GetCPUProfile.methods[0],
mutationKey: [API_ENDPOINTS.STATUS.GetCPUProfile.key],
onMutate: async (variables) => {
const duration = variables?.duration || 30
toast.info(`Generating CPU profile for ${duration} seconds...`)
const downloadUrl = `${getServerBaseUrl()}${API_ENDPOINTS.STATUS.GetCPUProfile.endpoint}?duration=${duration}`
try {
const headers: Record<string, string> = {}
if (password) {
headers["X-Seanime-Token"] = password
}
const response = await fetch(downloadUrl, {
method: "GET",
headers,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").split("T")[0] + "_" +
new Date().toISOString().replace(/[:.]/g, "-").split("T")[1].split(".")[0]
const filename = `seanime-cpu-profile-${timestamp}.pprof`
const url = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.setAttribute("download", filename)
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success(`CPU profile (${duration}s) downloaded`)
}
catch (error) {
console.error("Download error:", error)
toast.error(`Failed to download CPU profile`)
}
throw new Error("Download handled in onMutate")
},
onError: (error) => {
if (error.message !== "Download handled in onMutate") {
toast.error("Failed to download CPU profile")
}
},
})
}

View File

@@ -0,0 +1,30 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import { UpdateTheme_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Models_Theme } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetTheme() {
return useServerQuery<Models_Theme>({
endpoint: API_ENDPOINTS.THEME.GetTheme.endpoint,
method: API_ENDPOINTS.THEME.GetTheme.methods[0],
queryKey: [API_ENDPOINTS.THEME.GetTheme.key],
enabled: true,
})
}
export function useUpdateTheme() {
const queryClient = useQueryClient()
return useServerMutation<Models_Theme, UpdateTheme_Variables>({
endpoint: API_ENDPOINTS.THEME.UpdateTheme.endpoint,
method: API_ENDPOINTS.THEME.UpdateTheme.methods[0],
mutationKey: [API_ENDPOINTS.THEME.UpdateTheme.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("UI settings saved")
},
})
}

View File

@@ -0,0 +1,63 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
TorrentClientAction_Variables,
TorrentClientAddMagnetFromRule_Variables,
TorrentClientDownload_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { TorrentClient_Torrent } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetActiveTorrentList(enabled: boolean) {
return useServerQuery<Array<TorrentClient_Torrent>>({
endpoint: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.endpoint,
method: API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.methods[0],
queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key],
refetchInterval: 1500,
gcTime: 0,
enabled: enabled,
})
}
export function useTorrentClientAction(onSuccess?: () => void) {
const queryClient = useQueryClient()
return useServerMutation<boolean, TorrentClientAction_Variables>({
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.endpoint,
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.methods[0],
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAction.key],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENT_CLIENT.GetActiveTorrentList.key] })
toast.success("Action performed")
onSuccess?.()
},
})
}
export function useTorrentClientDownload(onSuccess?: () => void) {
return useServerMutation<boolean, TorrentClientDownload_Variables>({
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.endpoint,
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.methods[0],
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientDownload.key],
onSuccess: async () => {
toast.success("Download started")
onSuccess?.()
},
})
}
export function useTorrentClientAddMagnetFromRule() {
const queryClient = useQueryClient()
return useServerMutation<boolean, TorrentClientAddMagnetFromRule_Variables>({
endpoint: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.endpoint,
method: API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.methods[0],
mutationKey: [API_ENDPOINTS.TORRENT_CLIENT.TorrentClientAddMagnetFromRule.key],
onSuccess: async () => {
toast.success("Download started")
await queryClient.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
},
})
}

View File

@@ -0,0 +1,15 @@
import { useServerQuery } from "@/api/client/requests"
import { SearchTorrent_Variables } from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Torrent_SearchData } from "@/api/generated/types"
export function useSearchTorrent(variables: SearchTorrent_Variables, enabled: boolean) {
return useServerQuery<Torrent_SearchData, SearchTorrent_Variables>({
endpoint: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.endpoint,
method: API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.methods[0],
data: variables,
queryKey: [API_ENDPOINTS.TORRENT_SEARCH.SearchTorrent.key, JSON.stringify(variables)],
enabled: enabled,
gcTime: variables.episodeNumber === 0 ? 0 : undefined,
})
}

View File

@@ -0,0 +1,88 @@
import { useServerMutation, useServerQuery } from "@/api/client/requests"
import {
GetTorrentstreamBatchHistory_Variables,
GetTorrentstreamTorrentFilePreviews_Variables,
SaveTorrentstreamSettings_Variables,
TorrentstreamStartStream_Variables,
} from "@/api/generated/endpoint.types"
import { API_ENDPOINTS } from "@/api/generated/endpoints"
import { Models_TorrentstreamSettings, Nullish, Torrentstream_BatchHistoryResponse, Torrentstream_FilePreview } from "@/api/generated/types"
import { useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
export function useGetTorrentstreamSettings() {
return useServerQuery<Models_TorrentstreamSettings>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.methods[0],
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key],
enabled: true,
})
}
export function useSaveTorrentstreamSettings() {
const qc = useQueryClient()
return useServerMutation<Models_TorrentstreamSettings, SaveTorrentstreamSettings_Variables>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.methods[0],
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.SaveTorrentstreamSettings.key],
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamSettings.key] })
await qc.invalidateQueries({ queryKey: [API_ENDPOINTS.STATUS.GetStatus.key] })
toast.success("Settings saved")
},
})
}
export function useTorrentstreamStartStream() {
return useServerMutation<boolean, TorrentstreamStartStream_Variables>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.methods[0],
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStartStream.key],
onSuccess: async () => {
},
})
}
export function useTorrentstreamStopStream() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.methods[0],
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamStopStream.key],
onSuccess: async () => {
toast.success("Stream stopped")
},
})
}
export function useTorrentstreamDropTorrent() {
return useServerMutation<boolean>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.methods[0],
mutationKey: [API_ENDPOINTS.TORRENTSTREAM.TorrentstreamDropTorrent.key],
onSuccess: async () => {
toast.success("Torrent dropped")
},
})
}
export function useGetTorrentstreamTorrentFilePreviews(variables: Partial<GetTorrentstreamTorrentFilePreviews_Variables>, enabled: boolean) {
return useServerQuery<Array<Torrentstream_FilePreview>, GetTorrentstreamTorrentFilePreviews_Variables>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.methods[0],
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamTorrentFilePreviews.key, variables],
data: variables as GetTorrentstreamTorrentFilePreviews_Variables,
enabled: enabled,
})
}
export function useGetTorrentstreamBatchHistory(mediaId: Nullish<string | number>, enabled: boolean) {
return useServerQuery<Torrentstream_BatchHistoryResponse, GetTorrentstreamBatchHistory_Variables>({
endpoint: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.endpoint,
method: API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.methods[0],
queryKey: [API_ENDPOINTS.TORRENTSTREAM.GetTorrentstreamBatchHistory.key, String(mediaId), enabled],
data: {
mediaId: Number(mediaId)!,
},
enabled: !!mediaId,
})
}

View File

@@ -0,0 +1,187 @@
"use client"
import { Anime_Episode } from "@/api/generated/types"
import { __libraryHeaderEpisodeAtom } from "@/app/(main)/(library)/_containers/continue-watching"
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
import { cn } from "@/components/ui/core/styling"
import { getImageUrl } from "@/lib/server/assets"
import { ThemeMediaPageBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { atom, useAtomValue } from "jotai"
import { useSetAtom } from "jotai/react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import React, { useEffect, useState } from "react"
import { useWindowScroll } from "react-use"
export const __libraryHeaderImageAtom = atom<{ bannerImage?: string | null, episodeImage?: string | null } | null>({
bannerImage: null,
episodeImage: null,
})
const MotionImage = motion.create(Image)
export function LibraryHeader({ list }: { list: Anime_Episode[] }) {
const ts = useThemeSettings()
const image = useAtomValue(__libraryHeaderImageAtom)
const [actualImage, setActualImage] = useState<string | null>(null)
const [prevImage, setPrevImage] = useState<string | null>(null)
const [dimmed, setDimmed] = useState(false)
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
const bannerImage = image?.bannerImage || image?.episodeImage || ""
const shouldHideBanner = (
(ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !image?.bannerImage)
|| ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide
)
const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !image?.bannerImage) ||
ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur
useEffect(() => {
if (image != actualImage) {
if (actualImage === null) {
setActualImage(bannerImage)
} else {
setActualImage(null)
}
}
}, [image])
React.useLayoutEffect(() => {
const t = setTimeout(() => {
if (image != actualImage) {
setActualImage(bannerImage)
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === image?.episodeImage || ep.baseAnime?.coverImage?.extraLarge === image?.episodeImage || ep.episodeMetadata?.image === image?.episodeImage) || null)
}
}, 600)
return () => {
clearTimeout(t)
}
}, [image])
useEffect(() => {
if (actualImage) {
setPrevImage(actualImage)
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === actualImage || ep.baseAnime?.coverImage?.extraLarge === actualImage || ep.episodeMetadata?.image === actualImage) || null)
}
}, [actualImage])
const { y } = useWindowScroll()
useEffect(() => {
if (y > 100)
setDimmed(true)
else
setDimmed(false)
}, [(y > 100)])
if (!image) return null
return (
<>
<div
data-library-header-container
className={cn(
"LIB_HEADER_CONTAINER __header h-[25rem] z-[1] top-0 w-full absolute group/library-header pointer-events-none",
// Make it not fixed when the user scrolls down if a background image is set
!ts.libraryScreenCustomBackgroundImage && "fixed",
)}
>
<div
data-library-header-banner-top-gradient
className={cn(
"w-full z-[3] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent",
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
)}
/>
<motion.div
data-library-header-inner-container
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className={cn(
"LIB_HEADER_INNER_CONTAINER h-full z-[0] w-full flex-none object-cover object-center absolute top-0 overflow-hidden bg-[--background]",
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
)}
>
{!ts.disableSidebarTransparency && <div
data-library-header-banner-inner-container-top-gradient
className="hidden lg:block h-full absolute z-[2] w-[20%] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
/>}
<div
data-library-header-banner-inner-container-bottom-gradient
className="w-full z-[3] opacity-50 absolute top-0 h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
/>
{/*<div*/}
{/* className="LIB_HEADER_TOP_FADE w-full absolute z-[2] top-0 h-[10rem] opacity-20 bg-gradient-to-b from-[var(--background)] to-transparent via"*/}
{/*/>*/}
<AnimatePresence>
{!!actualImage && (
<motion.div
key="library-header-banner-image-container"
data-library-header-image-container
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0.4 }}
transition={{ duration: 0.5 }}
>
<MotionImage
data-library-header-banner-image
src={getImageUrl(actualImage || prevImage!)}
alt="banner image"
fill
quality={100}
priority
sizes="100vw"
className={cn(
"object-cover object-center z-[1] opacity-100 transition-opacity duration-700 scroll-locked-offset",
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
{ "opacity-5": dimmed },
)}
initial={{ scale: 1.01, y: 0 }}
animate={{
scale: Math.min(1 + y * 0.0002, 1.03),
// y: Math.max(y * -0.9, -10)
}}
exit={{ scale: 1.01, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>
</motion.div>
)}
</AnimatePresence>
{/* {prevImage && <MotionImage
data-library-header-banner-previous-image
src={getImageUrl(actualImage || prevImage!)}
alt="banner image"
fill
quality={100}
priority
sizes="100vw"
className={cn(
"object-cover object-center z-[1] opacity-50 transition-opacity scroll-locked-offset",
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
{ "opacity-5": dimmed },
)}
initial={{ scale: 1, y: 0 }}
animate={{ scale: Math.min(1 + y * 0.0002, 1.03), y: Math.max(y * -0.9, -10) }}
exit={{ scale: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>} */}
<div
data-library-header-banner-bottom-gradient
className="LIB_HEADER_IMG_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] lg:h-[15rem] bg-gradient-to-t from-[--background] lg:via-opacity-50 lg:via-10% to-transparent"
/>
</motion.div>
</div>
</>
)
}

View File

@@ -0,0 +1,136 @@
import { useLocalFileBulkAction, useRemoveEmptyDirectories } from "@/api/hooks/localfiles.hooks"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import { atom, useAtom } from "jotai"
import React from "react"
import { BiLockAlt, BiLockOpenAlt } from "react-icons/bi"
import { toast } from "sonner"
export const __bulkAction_modalAtomIsOpen = atom<boolean>(false)
export function BulkActionModal() {
const [isOpen, setIsOpen] = useAtom(__bulkAction_modalAtomIsOpen)
const { mutate: performBulkAction, isPending } = useLocalFileBulkAction()
function handleLockFiles() {
performBulkAction({
action: "lock",
}, {
onSuccess: () => {
setIsOpen(false)
toast.success("Files locked")
},
})
}
function handleUnlockFiles() {
performBulkAction({
action: "unlock",
}, {
onSuccess: () => {
setIsOpen(false)
toast.success("Files unlocked")
},
})
}
const { mutate: removeEmptyDirectories, isPending: isRemoving } = useRemoveEmptyDirectories()
function handleRemoveEmptyDirectories() {
removeEmptyDirectories(undefined, {
onSuccess: () => {
setIsOpen(false)
},
})
}
const confirmRemoveEmptyDirs = useConfirmationDialog({
title: "Remove empty directories",
description: "This action will remove all empty directories in the library. Are you sure you want to continue?",
onConfirm: () => {
handleRemoveEmptyDirectories()
},
})
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("anime-library-bulk-actions", {
priority: 1,
items: [
{
id: "lock-files", value: "lock", heading: "Library",
render: () => (
<p>Lock all files</p>
),
onSelect: ({ ctx }) => {
handleLockFiles()
},
},
{
id: "unlock-files", value: "unlock", heading: "Library",
render: () => (
<p>Unlock all files</p>
),
onSelect: ({ ctx }) => {
handleUnlockFiles()
},
},
],
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase())
},
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
showBasedOnInput: "startsWith",
})
return () => remove("anime-library-bulk-actions")
}, [])
return (
<Modal
open={isOpen} onOpenChange={() => setIsOpen(false)} title="Bulk actions"
contentClass="space-y-4"
>
<AppLayoutStack spacing="sm">
{/*<p>These actions do not affect ignored files.</p>*/}
<div className="flex gap-2 flex-col md:flex-row">
<Button
leftIcon={<BiLockAlt className="text-2xl" />}
intent="gray-outline"
className="w-full"
disabled={isPending || isRemoving}
onClick={handleLockFiles}
>
Lock all files
</Button>
<Button
leftIcon={<BiLockOpenAlt className="text-2xl" />}
intent="gray-outline"
className="w-full"
disabled={isPending || isRemoving}
onClick={handleUnlockFiles}
>
Unlock all files
</Button>
</div>
<Button
intent="gray-outline"
className="w-full"
disabled={isPending}
loading={isRemoving}
onClick={() => confirmRemoveEmptyDirs.open()}
>
Remove empty directories
</Button>
</AppLayoutStack>
<ConfirmationDialog {...confirmRemoveEmptyDirs} />
</Modal>
)
}

View File

@@ -0,0 +1,272 @@
"use client"
import { Anime_Episode, Continuity_WatchHistory } from "@/api/generated/types"
import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
import { __libraryHeaderImageAtom } from "@/app/(main)/(library)/_components/library-header"
import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms"
import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { episodeCardCarouselItemClass } from "@/components/shared/classnames"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { TextGenerateEffect } from "@/components/shared/text-generate-effect"
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
import { useDebounce } from "@/hooks/use-debounce"
import { anilist_animeIsMovie } from "@/lib/helpers/media"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { useWindowSize } from "@uidotdev/usehooks"
import { atom } from "jotai/index"
import { useAtom, useSetAtom } from "jotai/react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import React from "react"
import { seaCommand_compareMediaTitles } from "../../_features/sea-command/utils"
export const __libraryHeaderEpisodeAtom = atom<Anime_Episode | null>(null)
export function ContinueWatching({ episodes, isLoading, linkTemplate }: {
episodes: Anime_Episode[],
isLoading: boolean
linkTemplate?: string
}) {
const router = useRouter()
const ts = useThemeSettings()
const { data: watchHistory } = useGetContinuityWatchHistory()
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
const [headerEpisode, setHeaderEpisode] = useAtom(__libraryHeaderEpisodeAtom)
const [episodeRefs, setEpisodeRefs] = React.useState<React.RefObject<any>[]>([])
const [inViewEpisodes, setInViewEpisodes] = React.useState<number[]>([])
const debouncedInViewEpisodes = useDebounce(inViewEpisodes, 500)
const { width } = useWindowSize()
// Create refs for each episode
React.useEffect(() => {
setEpisodeRefs(episodes.map(() => React.createRef()))
}, [episodes])
// Observe each episode
React.useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const index = episodeRefs.findIndex(ref => ref.current === entry.target)
if (index !== -1) {
if (entry.isIntersecting) {
setInViewEpisodes(prev => prev.includes(index) ? prev : [...prev, index])
} else {
setInViewEpisodes(prev => prev.filter((idx) => idx !== index))
}
}
})
}, { threshold: 0.5 }) // Trigger callback when 50% of the element is visible
episodeRefs.forEach((ref) => {
if (ref.current) {
observer.observe(ref.current)
}
})
return () => {
episodeRefs.forEach((ref) => {
if (ref.current) {
observer.unobserve(ref.current)
}
})
}
}, [episodeRefs, width])
const prevSelectedEpisodeRef = React.useRef<Anime_Episode | null>(null)
// Set header image when new episode is in view
React.useEffect(() => {
if (debouncedInViewEpisodes.length === 0) return
let candidateIndices = debouncedInViewEpisodes
// Exclude previously selected episode if possible
if (prevSelectedEpisodeRef.current && debouncedInViewEpisodes.length > 1) {
candidateIndices = debouncedInViewEpisodes.filter(idx => episodes[idx]?.baseAnime?.id !== prevSelectedEpisodeRef.current?.baseAnime?.id)
}
if (candidateIndices.length === 0) candidateIndices = debouncedInViewEpisodes
const randomCandidateIdx = candidateIndices[Math.floor(Math.random() * candidateIndices.length)]
const selectedEpisode = episodes[randomCandidateIdx]
if (selectedEpisode) {
setHeaderImage({
bannerImage: selectedEpisode.baseAnime?.bannerImage || null,
episodeImage: selectedEpisode.baseAnime?.bannerImage || selectedEpisode.baseAnime?.coverImage?.extraLarge || null,
})
prevSelectedEpisodeRef.current = selectedEpisode
}
}, [debouncedInViewEpisodes, episodes, setHeaderImage])
const { setPlayNext } = usePlayNext()
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("continue-watching", {
items: episodes.map(episode => ({
data: episode,
id: `${episode.localFile?.path || episode.baseAnime?.title?.userPreferred || ""}-${episode.episodeNumber || 1}`,
value: `${episode.episodeNumber || 1}`,
heading: "Continue Watching",
priority: 100,
render: () => (
<>
<div className="w-12 aspect-[6/5] flex-none rounded-[--radius-md] relative overflow-hidden">
<Image
src={episode.episodeMetadata?.image || ""}
alt="episode image"
fill
className="object-center object-cover"
/>
</div>
<div className="flex gap-1 items-center w-full">
<p className="max-w-[70%] truncate">{episode.baseAnime?.title?.userPreferred || ""}</p>&nbsp;-&nbsp;
{!anilist_animeIsMovie(episode.baseAnime) ? <>
<p className="text-[--muted]">Ep</p><span>{episode.episodeNumber}</span>
</> : <>
<p className="text-[--muted]">Movie</p>
</>}
</div>
</>
),
onSelect: () => setPlayNext(episode.baseAnime?.id, () => {
router.push(`/entry?id=${episode.baseAnime?.id}`)
}),
})),
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase()) ||
seaCommand_compareMediaTitles(item.data.baseAnime?.title, input)
},
priority: 100,
})
return () => remove("continue-watching")
}, [episodes, inject, remove, router, setPlayNext])
if (episodes.length > 0) return (
<PageWrapper className="space-y-3 lg:space-y-6 p-4 relative z-[4]" data-continue-watching-container>
<h2 data-continue-watching-title>Continue watching</h2>
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerEpisode?.baseAnime) && <TextGenerateEffect
data-continue-watching-media-title
words={headerEpisode?.baseAnime?.title?.userPreferred || ""}
className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1"
/>}
<Carousel
className="w-full max-w-full"
gap="md"
opts={{
align: "start",
}}
autoScroll
autoScrollDelay={8000}
>
<CarouselDotButtons />
<CarouselContent>
{episodes.map((episode, idx) => (
<CarouselItem
key={episode?.localFile?.path || idx}
className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)}
>
<_EpisodeCard
key={episode.localFile?.path || ""}
episode={episode}
mRef={episodeRefs[idx]}
overrideLink={linkTemplate}
watchHistory={watchHistory}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</PageWrapper>
)
return null
}
const _EpisodeCard = React.memo(({ episode, mRef, overrideLink, watchHistory }: {
episode: Anime_Episode,
mRef: React.RefObject<any>,
overrideLink?: string
watchHistory: Continuity_WatchHistory | undefined
}) => {
const serverStatus = useServerStatus()
const router = useRouter()
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
React.useEffect(() => {
setHeaderImage(prev => {
if (prev?.episodeImage === null) {
return {
bannerImage: episode.baseAnime?.bannerImage || null,
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
}
}
return prev
})
setHeaderEpisode(prev => {
if (prev === null) {
return episode
}
return prev
})
}, [])
const { setPlayNext } = usePlayNext()
return (
<EpisodeCard
key={episode.localFile?.path || ""}
episode={episode}
image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge}
topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred}
title={episode.displayTitle}
isInvalid={episode.isInvalid}
progressTotal={episode.baseAnime?.episodes}
progressNumber={episode.progressNumber}
episodeNumber={episode.episodeNumber}
length={episode.episodeMetadata?.length}
hasDiscrepancy={episode.episodeNumber !== episode.progressNumber}
percentageComplete={getEpisodePercentageComplete(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
minutesRemaining={getEpisodeMinutesRemaining(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
anime={{
id: episode?.baseAnime?.id || 0,
image: episode?.baseAnime?.coverImage?.medium,
title: episode?.baseAnime?.title?.userPreferred,
}}
onMouseEnter={() => {
React.startTransition(() => {
setHeaderImage({
bannerImage: episode.baseAnime?.bannerImage || null,
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
})
})
}}
mRef={mRef}
onClick={() => {
if (!overrideLink) {
setPlayNext(episode.baseAnime?.id, () => {
if (!serverStatus?.isOffline) {
router.push(`/entry?id=${episode.baseAnime?.id}`)
} else {
router.push(`/offline/entry/anime?id=${episode.baseAnime?.id}`)
}
})
} else {
setPlayNext(episode.baseAnime?.id, () => {
router.push(overrideLink.replace("{id}", episode.baseAnime?.id ? String(episode.baseAnime.id) : ""))
})
}
}}
/>
)
})

View File

@@ -0,0 +1,112 @@
"use client"
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
import { cn } from "@/components/ui/core/styling"
import { getAssetUrl } from "@/lib/server/assets"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { motion } from "motion/react"
import React, { useEffect } from "react"
import { useWindowScroll } from "react-use"
type CustomLibraryBannerProps = {
discrete?: boolean
isLibraryScreen?: boolean // Anime library or manga library
}
export function CustomLibraryBanner(props: CustomLibraryBannerProps) {
/**
* Library screens: Shows the custom banner IF theme settings are set to use a custom banner
* Other pages: Shows the custom banner
*/
const { discrete, isLibraryScreen } = props
const ts = useThemeSettings()
const image = React.useMemo(() => ts.libraryScreenCustomBannerImage ? getAssetUrl(ts.libraryScreenCustomBannerImage) : "",
[ts.libraryScreenCustomBannerImage])
const [dimmed, setDimmed] = React.useState(false)
const { y } = useWindowScroll()
useEffect(() => {
if (y > 100)
setDimmed(true)
else
setDimmed(false)
}, [(y > 100)])
if (isLibraryScreen && ts.libraryScreenBannerType !== ThemeLibraryScreenBannerType.Custom) return null
if (discrete && !!ts.libraryScreenCustomBackgroundImage) return null
if (!image) return null
return (
<>
{!discrete && <div
data-custom-library-banner-top-spacer
className={cn(
"py-20",
ts.hideTopNavbar && "py-28",
)}
></div>}
<div
data-custom-library-banner-container
className={cn(
"__header h-[30rem] z-[1] top-0 w-full fixed group/library-header transition-opacity duration-1000",
discrete && "opacity-20",
!!ts.libraryScreenCustomBackgroundImage && "absolute", // If there's a background image, make the banner absolute
(!ts.libraryScreenCustomBackgroundImage && dimmed) && "opacity-5", // If the user has scrolled down, dim the banner
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
"scroll-locked-offset",
)}
>
{(!ts.disableSidebarTransparency && !discrete) && <div
data-custom-library-banner-top-gradient
className="hidden lg:block h-full absolute z-[2] w-[20rem] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
/>}
<div
data-custom-library-banner-bottom-gradient
className="w-full z-[3] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className={cn(
"h-[30rem] z-[0] w-full flex-none absolute top-0 overflow-hidden",
"scroll-locked-offset",
)}
data-custom-library-banner-inner-container
>
<div
data-custom-library-banner-top-gradient
className={cn(
"CUSTOM_LIB_BANNER_TOP_FADE w-full absolute z-[2] top-0 h-[5rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via",
discrete && "opacity-70",
)}
/>
<div
data-custom-library-banner-image
className={cn(
"CUSTOM_LIB_BANNER_IMG z-[1] absolute inset-0 w-full h-full bg-cover bg-no-repeat transition-opacity duration-1000",
"scroll-locked-offset",
)}
style={{
backgroundImage: `url(${image})`,
backgroundPosition: ts.libraryScreenCustomBannerPosition || "50% 50%",
opacity: (ts.libraryScreenCustomBannerOpacity || 100) / 100,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
}}
/>
<div
data-custom-library-banner-bottom-gradient
className={cn(
"CUSTOM_LIB_BANNER_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-opacity-50 via-10% to-transparent via",
discrete && "via-50% via-opacity-100 h-[40rem]",
)}
/>
</motion.div>
</div>
</>
)
}

View File

@@ -0,0 +1,135 @@
import { Anime_LocalFile } from "@/api/generated/types"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
import { LuffyError } from "@/components/shared/luffy-error"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Drawer } from "@/components/ui/drawer"
import { upath } from "@/lib/helpers/upath"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { TbFileSad } from "react-icons/tb"
import { toast } from "sonner"
export const __ignoredFileManagerIsOpen = atom(false)
type IgnoredFileManagerProps = {
files: Anime_LocalFile[]
}
export function IgnoredFileManager(props: IgnoredFileManagerProps) {
const { files } = props
const [isOpen, setIsOpen] = useAtom(__ignoredFileManagerIsOpen)
const { mutate: openInExplorer } = useOpenInExplorer()
const { mutate: updateLocalFiles, isPending: isUpdating } = useUpdateLocalFiles()
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
React.useLayoutEffect(() => {
setSelectedPaths(files?.map(lf => lf.path) ?? [])
}, [files])
function handleUnIgnoreSelected() {
if (selectedPaths.length > 0) {
updateLocalFiles({
paths: selectedPaths,
action: "unignore",
}, {
onSuccess: () => {
toast.success("Files un-ignored")
},
})
}
}
return (
<Drawer
open={isOpen}
onOpenChange={() => setIsOpen(false)}
// contentClass="max-w-5xl"
size="xl"
title="Ignored files"
>
<AppLayoutStack className="mt-4">
{files.length > 0 && <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-1"></div>
<Button
leftIcon={<TbFileSad className="text-lg" />}
intent="white"
size="sm"
rounded
loading={isUpdating}
onClick={handleUnIgnoreSelected}
>
Un-ignore selection
</Button>
</div>}
{files.length === 0 && <LuffyError title={null}>
No ignored files
</LuffyError>}
{files.length > 0 &&
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[85vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
<div className="p-2">
<Checkbox
label={`Select all files`}
value={(selectedPaths.length === files?.length) ? true : (selectedPaths.length === 0
? false
: "indeterminate")}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (draft.length === files?.length) {
return []
} else {
return files?.map(lf => lf.path) ?? []
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
{files.map((lf, index) => (
<div
key={`${lf.path}-${index}`}
className="p-2 "
>
<div className="flex items-center">
<Checkbox
label={`${upath.basename(lf.path)}`}
value={selectedPaths.includes(lf.path)}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (checked) {
return [...draft, lf.path]
} else {
return draft.filter(p => p !== lf.path)
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
</div>
))}
</div>}
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -0,0 +1,138 @@
import { Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
import { __mainLibrary_paramsAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { IconButton } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { getLibraryCollectionTitle } from "@/lib/server/utils"
import { useAtom } from "jotai/react"
import React from "react"
import { LuListFilter } from "react-icons/lu"
export function LibraryCollectionLists({ collectionList, isLoading, streamingMediaIds }: {
collectionList: Anime_LibraryCollectionList[],
isLoading: boolean,
streamingMediaIds: number[]
}) {
return (
<PageWrapper
key="library-collection-lists"
className="space-y-8"
data-library-collection-lists
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.25,
},
}}>
{collectionList.map(collection => {
if (!collection.entries?.length) return null
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
})}
</PageWrapper>
)
}
export function LibraryCollectionFilteredLists({ collectionList, isLoading, streamingMediaIds }: {
collectionList: Anime_LibraryCollectionList[],
isLoading: boolean,
streamingMediaIds: number[]
}) {
// const params = useAtomValue(__mainLibrary_paramsAtom)
return (
<PageWrapper
key="library-filtered-lists"
className="space-y-8"
data-library-filtered-lists
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.25,
},
}}>
{/*<h3 className="text-center truncate">*/}
{/* {params.genre?.join(", ")}*/}
{/*</h3>*/}
<MediaCardLazyGrid itemCount={collectionList?.flatMap(n => n.entries)?.length ?? 0}>
{collectionList?.flatMap(n => n.entries)?.filter(Boolean)?.map(entry => {
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
})}
</MediaCardLazyGrid>
</PageWrapper>
)
}
export const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: {
list: Anime_LibraryCollectionList,
streamingMediaIds: number[]
}) => {
const isCurrentlyWatching = list.type === "CURRENT"
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
return (
<React.Fragment key={list.type}>
<div className="flex gap-3 items-center" data-library-collection-list-item-header data-list-type={list.type}>
<h2 className="p-0 m-0">{getLibraryCollectionTitle(list.type)}</h2>
<div className="flex flex-1"></div>
{isCurrentlyWatching && <DropdownMenu
trigger={<IconButton
intent="white-basic"
size="xs"
className="mt-1"
icon={<LuListFilter />}
/>}
>
<DropdownMenuItem
onClick={() => {
setParams(draft => {
draft.continueWatchingOnly = !draft.continueWatchingOnly
return
})
}}
>
{params.continueWatchingOnly ? "Show all" : "Show unwatched only"}
</DropdownMenuItem>
</DropdownMenu>}
</div>
<MediaCardLazyGrid
itemCount={list?.entries?.length || 0}
data-library-collection-list-item-media-card-lazy-grid
data-list-type={list.type}
>
{list.entries?.map(entry => {
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
})}
</MediaCardLazyGrid>
</React.Fragment>
)
})
export const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
entry: Anime_LibraryCollectionEntry,
streamingMediaIds: number[]
}) => {
return (
<MediaEntryCard
media={entry.media!}
listData={entry.listData}
libraryData={entry.libraryData}
nakamaLibraryData={entry.nakamaLibraryData}
showListDataButton
withAudienceScore={false}
type="anime"
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId) && entry.listData?.status === "CURRENT"}
/>
)
})

View File

@@ -0,0 +1,191 @@
"use client"
import { Anime_LibraryCollectionList, Anime_LocalFile, Anime_UnknownGroup } from "@/api/generated/types"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { __bulkAction_modalAtomIsOpen } from "@/app/(main)/(library)/_containers/bulk-action-modal"
import { __ignoredFileManagerIsOpen } from "@/app/(main)/(library)/_containers/ignored-file-manager"
import { PlayRandomEpisodeButton } from "@/app/(main)/(library)/_containers/play-random-episode-button"
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
import { __unknownMedia_drawerIsOpen } from "@/app/(main)/(library)/_containers/unknown-media-manager"
import { __unmatchedFileManagerIsOpen } from "@/app/(main)/(library)/_containers/unmatched-file-manager"
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { SeaLink } from "@/components/shared/sea-link"
import { Button, IconButton } from "@/components/ui/button"
import { cn } from "@/components/ui/core/styling"
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { Tooltip } from "@/components/ui/tooltip"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { useAtom, useSetAtom } from "jotai/react"
import React from "react"
import { BiCollection, BiDotsVerticalRounded, BiFolder } from "react-icons/bi"
import { FiSearch } from "react-icons/fi"
import { IoLibrary, IoLibrarySharp } from "react-icons/io5"
import { MdOutlineVideoLibrary } from "react-icons/md"
import { PiClockCounterClockwiseFill } from "react-icons/pi"
import { TbFileSad, TbReload } from "react-icons/tb"
import { PluginAnimeLibraryDropdownItems } from "../../_features/plugin/actions/plugin-actions"
export type LibraryToolbarProps = {
collectionList: Anime_LibraryCollectionList[]
ignoredLocalFiles: Anime_LocalFile[]
unmatchedLocalFiles: Anime_LocalFile[]
unknownGroups: Anime_UnknownGroup[]
isLoading: boolean
hasEntries: boolean
isStreamingOnly: boolean
isNakamaLibrary: boolean
}
export function LibraryToolbar(props: LibraryToolbarProps) {
const {
collectionList,
ignoredLocalFiles,
unmatchedLocalFiles,
unknownGroups,
hasEntries,
isStreamingOnly,
isNakamaLibrary,
} = props
const ts = useThemeSettings()
const setBulkActionIsOpen = useSetAtom(__bulkAction_modalAtomIsOpen)
const status = useServerStatus()
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
const setUnmatchedFileManagerOpen = useSetAtom(__unmatchedFileManagerIsOpen)
const setIgnoredFileManagerOpen = useSetAtom(__ignoredFileManagerIsOpen)
const setUnknownMediaManagerOpen = useSetAtom(__unknownMedia_drawerIsOpen)
const setPlaylistsModalOpen = useSetAtom(__playlists_modalOpenAtom)
const [libraryView, setLibraryView] = useAtom(__library_viewAtom)
const { mutate: openInExplorer } = useOpenInExplorer()
const hasLibraryPath = !!status?.settings?.library?.libraryPath
return (
<>
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && hasEntries) && <div
className={cn(
"h-28",
ts.hideTopNavbar && "h-40",
)}
data-library-toolbar-top-padding
></div>}
<div className="flex flex-wrap w-full justify-end gap-2 p-4 relative z-[10]" data-library-toolbar-container>
<div className="flex flex-1" data-library-toolbar-spacer></div>
{(hasEntries) && (
<>
<Tooltip
trigger={<IconButton
data-library-toolbar-switch-view-button
intent={libraryView === "base" ? "white-subtle" : "white"}
icon={<IoLibrary className="text-2xl" />}
onClick={() => setLibraryView(p => p === "detailed" ? "base" : "detailed")}
/>}
>
Switch view
</Tooltip>
{!(isStreamingOnly || isNakamaLibrary) && <Tooltip
trigger={<IconButton
data-library-toolbar-playlists-button
intent={"white-subtle"}
icon={<MdOutlineVideoLibrary className="text-2xl" />}
onClick={() => setPlaylistsModalOpen(true)}
/>}
>Playlists</Tooltip>}
{!(isStreamingOnly || isNakamaLibrary) && <PlayRandomEpisodeButton />}
{/*Show up even when there's no local entries*/}
{!isNakamaLibrary && hasLibraryPath && <Button
data-library-toolbar-scan-button
intent={hasEntries ? "primary-subtle" : "primary"}
leftIcon={hasEntries ? <TbReload className="text-xl" /> : <FiSearch className="text-xl" />}
onClick={() => setScannerModalOpen(true)}
hideTextOnSmallScreen
>
{hasEntries ? "Refresh library" : "Scan your library"}
</Button>}
</>
)}
{(unmatchedLocalFiles.length > 0) && <Button
data-library-toolbar-unmatched-button
intent="alert"
leftIcon={<IoLibrarySharp />}
className="animate-bounce"
onClick={() => setUnmatchedFileManagerOpen(true)}
>
Resolve unmatched ({unmatchedLocalFiles.length})
</Button>}
{(unknownGroups.length > 0) && <Button
data-library-toolbar-unknown-button
intent="warning"
leftIcon={<IoLibrarySharp />}
className="animate-bounce"
onClick={() => setUnknownMediaManagerOpen(true)}
>
Resolve hidden media ({unknownGroups.length})
</Button>}
{(!isStreamingOnly && !isNakamaLibrary && hasLibraryPath) &&
<DropdownMenu
trigger={<IconButton
data-library-toolbar-dropdown-menu-trigger
icon={<BiDotsVerticalRounded />} intent="gray-basic"
/>}
>
<DropdownMenuItem
data-library-toolbar-open-directory-button
disabled={!hasLibraryPath}
className={cn("cursor-pointer", { "!text-[--muted]": !hasLibraryPath })}
onClick={() => {
openInExplorer({ path: status?.settings?.library?.libraryPath ?? "" })
}}
>
<BiFolder />
<span>Open directory</span>
</DropdownMenuItem>
<DropdownMenuItem
data-library-toolbar-bulk-actions-button
onClick={() => setBulkActionIsOpen(true)}
disabled={!hasEntries}
className={cn({ "!text-[--muted]": !hasEntries })}
>
<BiCollection />
<span>Bulk actions</span>
</DropdownMenuItem>
<DropdownMenuItem
data-library-toolbar-ignored-files-button
onClick={() => setIgnoredFileManagerOpen(true)}
// disabled={!hasEntries}
className={cn({ "!text-[--muted]": !hasEntries })}
>
<TbFileSad />
<span>Ignored files</span>
</DropdownMenuItem>
<SeaLink href="/scan-summaries">
<DropdownMenuItem
data-library-toolbar-scan-summaries-button
// className={cn({ "!text-[--muted]": !hasEntries })}
>
<PiClockCounterClockwiseFill />
<span>Scan summaries</span>
</DropdownMenuItem>
</SeaLink>
<PluginAnimeLibraryDropdownItems />
</DropdownMenu>}
</div>
</>
)
}

View File

@@ -0,0 +1,33 @@
import { usePlaybackPlayRandomVideo } from "@/api/hooks/playback_manager.hooks"
import { IconButton } from "@/components/ui/button"
import { Tooltip } from "@/components/ui/tooltip"
import React from "react"
import { LiaRandomSolid } from "react-icons/lia"
type PlayRandomEpisodeButtonProps = {
children?: React.ReactNode
}
export function PlayRandomEpisodeButton(props: PlayRandomEpisodeButtonProps) {
const {
children,
...rest
} = props
const { mutate: playRandom, isPending } = usePlaybackPlayRandomVideo()
return (
<>
<Tooltip
trigger={<IconButton
data-play-random-episode-button
intent={"white-subtle"}
icon={<LiaRandomSolid className="text-2xl" />}
loading={isPending}
onClick={() => playRandom()}
/>}
>Play random anime</Tooltip>
</>
)
}

View File

@@ -0,0 +1,358 @@
import { AL_BaseAnime, Anime_LibraryCollectionEntry, Anime_LocalFile } from "@/api/generated/types"
import { useGetLocalFiles } from "@/api/hooks/localfiles.hooks"
import { useGetPlaylistEpisodes } from "@/api/hooks/playlist.hooks"
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
import { imageShimmer } from "@/components/shared/image-helpers"
import { Button, IconButton } from "@/components/ui/button"
import { cn } from "@/components/ui/core/styling"
import { Modal } from "@/components/ui/modal"
import { Select } from "@/components/ui/select"
import { TextInput } from "@/components/ui/text-input"
import { useDebounce } from "@/hooks/use-debounce"
import { DndContext, DragEndEvent } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { useAtomValue } from "jotai/react"
import Image from "next/image"
import React from "react"
import { BiPlus, BiTrash } from "react-icons/bi"
import { toast } from "sonner"
type PlaylistManagerProps = {
paths: string[]
setPaths: (paths: string[]) => void
}
export function PlaylistManager(props: PlaylistManagerProps) {
const {
paths: controlledPaths,
setPaths: onChange,
...rest
} = props
const libraryCollection = useAtomValue(animeLibraryCollectionAtom)
const { data: localFiles } = useGetLocalFiles()
const firstRender = React.useRef(true)
const [paths, setPaths] = React.useState(controlledPaths)
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false
return
}
setPaths(controlledPaths)
}, [controlledPaths])
React.useEffect(() => {
onChange(paths)
}, [paths])
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
setPaths((items) => {
const oldIndex = items.indexOf(active.id as any)
const newIndex = items.indexOf(over?.id as any)
return arrayMove(items, oldIndex, newIndex)
})
}
}, [])
const [selectedCategory, setSelectedCategory] = React.useState("CURRENT")
const [searchInput, setSearchInput] = React.useState("")
const debouncedSearchInput = useDebounce(searchInput, 500)
const entries = React.useMemo(() => {
if (debouncedSearchInput.length !== 0) return (libraryCollection?.lists
?.filter(n => n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT")
?.flatMap(n => n.entries)
?.filter(Boolean) ?? []).filter(n => n?.media?.title?.english?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()) ||
n?.media?.title?.romaji?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()))
return libraryCollection?.lists?.filter(n => {
if (selectedCategory === "-") return n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT"
return n.type === selectedCategory
})
?.flatMap(n => n.entries)
?.filter(n => !!n?.libraryData)
?.filter(Boolean) ?? []
}, [libraryCollection, debouncedSearchInput, selectedCategory])
return (
<div className="space-y-4">
<div className="space-y-2">
<Modal
title="Select an anime"
contentClass="max-w-4xl"
trigger={<Button
leftIcon={<BiPlus className="text-2xl" />}
intent="white"
className="rounded-full"
disabled={paths.length >= 10}
>Add an episode</Button>}
>
<div className="grid grid-cols-[150px,1fr] gap-2">
<Select
value={selectedCategory}
onValueChange={v => setSelectedCategory(v)}
options={[
{ label: "Current", value: "CURRENT" },
{ label: "Paused", value: "PAUSED" },
{ label: "Planning", value: "PLANNING" },
{ label: "All", value: "-" },
]}
disabled={searchInput.length !== 0}
/>
<TextInput
placeholder="Search"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{entries?.map(entry => {
return (
<PlaylistMediaEntry key={entry.mediaId} entry={entry} paths={paths} setPaths={setPaths} />
)
})}
</div>
</Modal>
</div>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
strategy={verticalListSortingStrategy}
items={paths}
>
<div className="space-y-2">
<ul className="space-y-2">
{paths.map(path => localFiles?.find(n => n.path === path))?.filter(Boolean).map((lf, index) => (
<SortableItem
key={lf.path}
id={lf.path}
localFile={lf}
media={libraryCollection?.lists?.flatMap(n => n.entries)
?.filter(Boolean)
?.find(n => lf?.mediaId === n.mediaId)?.media}
setPaths={setPaths}
/>
))}
</ul>
</div>
</SortableContext>
</DndContext>
</div>
)
}
type PlaylistMediaEntryProps = {
entry: Anime_LibraryCollectionEntry
paths: string[]
setPaths: React.Dispatch<React.SetStateAction<string[]>>
}
function PlaylistMediaEntry(props: PlaylistMediaEntryProps) {
const { entry, paths, setPaths } = props
return <Modal
title={entry.media?.title?.userPreferred || entry.media?.title?.romaji || ""}
trigger={(
<div
key={entry.mediaId}
className="col-span-1 aspect-[6/7] rounded-[--radius-md] border overflow-hidden relative transition cursor-pointer bg-[var(--background)] md:opacity-60 md:hover:opacity-100"
>
<Image
src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover"
/>
<p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]">
{entry.media?.title?.userPreferred || entry.media?.title?.romaji}
</p>
<div
className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent"
/>
</div>
)}
>
<EntryEpisodeList
selectedPaths={paths}
setSelectedPaths={setPaths}
entry={entry}
/>
</Modal>
}
function SortableItem({ localFile, id, media, setPaths }: {
id: string,
localFile: Anime_LocalFile | undefined,
media: AL_BaseAnime | undefined,
setPaths: any
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
if (!localFile) return null
if (!media) return (
<li ref={setNodeRef} style={style}>
<div
className="px-2.5 py-2 bg-[var(--background)] border-[--red] rounded-[--radius-md] border flex gap-3 relative"
>
<IconButton
className="absolute top-2 right-2 rounded-full"
icon={<BiTrash />}
intent="alert-subtle"
size="sm"
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
/>
<div
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
{...attributes} {...listeners}
/>
<div>
<p className="text-lg text-white font-semibold">
<span>
???
</span>
<span className="text-gray-400 font-medium max-w-lg truncate">
</span>
</p>
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
</div>
</div>
</li>
)
return (
<li ref={setNodeRef} style={style}>
<div
className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"
>
<IconButton
className="absolute top-2 right-2 rounded-full"
icon={<BiTrash />}
intent="alert-subtle"
size="sm"
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
/>
<div
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
{...attributes} {...listeners}
/>
<div
className="w-16 aspect-square rounded-[--radius-md] border overflow-hidden relative transition bg-[var(--background)]"
>
<Image
src={media?.coverImage?.large || media?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover"
/>
</div>
<div>
<div className="text-lg text-white font-semibold flex gap-1">
{localFile.metadata && <p>
{media?.format !== "MOVIE" ? `Episode ${localFile.metadata?.episode}` : "Movie"}
</p>}
<p className="max-w-full truncate text-gray-400 font-medium max-w-lg truncate">
{" - "}{media?.title?.userPreferred || media?.title?.romaji}
</p>
</div>
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
</div>
</div>
</li>
)
}
type EntryEpisodeListProps = {
entry: Anime_LibraryCollectionEntry
selectedPaths: string[]
setSelectedPaths: React.Dispatch<React.SetStateAction<string[]>>
}
function EntryEpisodeList(props: EntryEpisodeListProps) {
const {
entry,
selectedPaths,
setSelectedPaths,
...rest
} = props
const { data } = useGetPlaylistEpisodes(entry.mediaId, entry.listData?.progress || 0)
const handleSelect = (value: string) => {
if (selectedPaths.length <= 10) {
setSelectedPaths(prev => {
if (prev.includes(value)) {
return prev.filter(n => n !== value)
}
return [...prev, value]
})
} else {
toast.error("You can't add more than 10 episodes to a playlist")
}
}
return (
<div className="flex flex-col gap-2 overflow-auto p-1">
{data?.filter(n => !!n.metadata)?.sort((a, b) => a.metadata!.episode - b.metadata!.episode)?.map(lf => {
return (
<div
key={lf.path}
className={cn(
"px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border cursor-pointer opacity-80 max-w-full",
selectedPaths.includes(lf.path) ? "bg-gray-800 opacity-100 text-white ring-1 ring-[--zinc]" : "hover:bg-[--subtle]",
"transition",
)}
onClick={() => handleSelect(lf.path)}
>
<p className="">{entry.media?.format !== "MOVIE" ? `Episode ${lf.metadata!.episode}` : "Movie"}</p>
<p className="text-sm text-[--muted] font-normal italic max-w-lg line-clamp-1">{lf.name}</p>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { Anime_Playlist } from "@/api/generated/types"
import { useCreatePlaylist, useDeletePlaylist, useUpdatePlaylist } from "@/api/hooks/playlist.hooks"
import { PlaylistManager } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-manager"
import { Button } from "@/components/ui/button"
import { DangerZone } from "@/components/ui/form"
import { Modal } from "@/components/ui/modal"
import { Separator } from "@/components/ui/separator"
import { TextInput } from "@/components/ui/text-input"
import React from "react"
import { toast } from "sonner"
type PlaylistModalProps = {
playlist?: Anime_Playlist
trigger: React.ReactElement
}
export function PlaylistModal(props: PlaylistModalProps) {
const {
playlist,
trigger,
} = props
const [isOpen, setIsOpen] = React.useState(false)
const [name, setName] = React.useState(playlist?.name ?? "")
const [paths, setPaths] = React.useState<string[]>(playlist?.localFiles?.map(l => l.path) ?? [])
const isUpdate = !!playlist
const { mutate: createPlaylist, isPending: isCreating } = useCreatePlaylist()
const { mutate: deletePlaylist, isPending: isDeleting } = useDeletePlaylist()
const { mutate: updatePlaylist, isPending: isUpdating } = useUpdatePlaylist()
function reset() {
setName("")
setPaths([])
}
React.useEffect(() => {
if (isUpdate && !!playlist && !!playlist.localFiles) {
setName(playlist.name)
setPaths(playlist.localFiles.map(l => l.path))
}
}, [playlist, isOpen])
function handleSubmit() {
if (name.length === 0) {
toast.error("Please enter a name for the playlist")
return
}
if (isUpdate && !!playlist) {
updatePlaylist({ dbId: playlist.dbId, name, paths })
} else {
setIsOpen(false)
createPlaylist({ name, paths }, {
onSuccess: () => {
reset()
},
})
}
}
return (
<Modal
title={isUpdate ? "Edit playlist" : "Create a playlist"}
trigger={trigger}
open={isOpen}
onOpenChange={v => setIsOpen(v)}
contentClass="max-w-4xl"
>
<div className="space-y-4">
<div className="space-y-4">
<TextInput
label="Name"
value={name}
onChange={e => setName(e.target.value)}
/>
<Separator />
<PlaylistManager
paths={paths}
setPaths={setPaths}
/>
<div className="">
<Button disabled={paths.length === 0} onClick={handleSubmit} loading={isCreating || isDeleting || isUpdating}>
{isUpdate ? "Update" : "Create"}
</Button>
</div>
</div>
{isUpdate && <DangerZone
actionText="Delete playlist" onDelete={() => {
if (isUpdate && !!playlist) {
deletePlaylist({ dbId: playlist.dbId }, {
onSuccess: () => {
setIsOpen(false)
},
})
}
}}
/>}
</div>
</Modal>
)
}

View File

@@ -0,0 +1,123 @@
import { useGetPlaylists } from "@/api/hooks/playlist.hooks"
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
import { StartPlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal"
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { imageShimmer } from "@/components/shared/image-helpers"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
import { cn } from "@/components/ui/core/styling"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { useAtomValue, useSetAtom } from "jotai/react"
import Image from "next/image"
import React from "react"
import { BiEditAlt } from "react-icons/bi"
import { FaCirclePlay } from "react-icons/fa6"
import { MdOutlineVideoLibrary } from "react-icons/md"
type PlaylistsListProps = {}
export function PlaylistsList(props: PlaylistsListProps) {
const {} = props
const { data: playlists, isLoading } = useGetPlaylists()
const userMedia = useAtomValue(__anilist_userAnimeMediaAtom)
const serverStatus = useServerStatus()
const setOpen = useSetAtom(__playlists_modalOpenAtom)
const handlePlaylistLoaded = React.useCallback(() => {
setOpen(false)
}, [])
if (isLoading) return <LoadingSpinner />
if (!playlists?.length) {
return (
<div className="text-center text-[--muted] space-y-1">
<MdOutlineVideoLibrary className="mx-auto text-5xl text-[--muted]" />
<div>
No playlists
</div>
</div>
)
}
return (
// <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<Carousel
className="w-full max-w-full"
gap="none"
opts={{
align: "start",
}}
>
<CarouselDotButtons />
<CarouselContent>
{playlists.map(p => {
const mainMedia = userMedia?.find(m => m.id === p.localFiles?.[0]?.mediaId)
return (
<CarouselItem
key={p.dbId}
className={cn(
"md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 min-[2000px]:basis-1/6",
"aspect-[7/6] p-2",
)}
// onClick={() => handleSelect(lf.path)}
>
<div className="group/playlist-item flex gap-3 h-full justify-between items-center bg-gray-950 rounded-[--radius-md] transition relative overflow-hidden">
{(mainMedia?.coverImage?.large || mainMedia?.bannerImage) && <Image
src={mainMedia?.coverImage?.extraLarge || mainMedia?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover z-[1]"
/>}
<div className="absolute inset-0 z-[2] bg-gray-900 opacity-50 hover:opacity-70 transition-opacity flex items-center justify-center" />
<div className="absolute inset-0 z-[6] flex items-center justify-center">
<StartPlaylistModal
canStart={serverStatus?.settings?.library?.autoUpdateProgress}
playlist={p}
onPlaylistLoaded={handlePlaylistLoaded}
trigger={
<FaCirclePlay className="block text-5xl cursor-pointer opacity-50 hover:opacity-100 transition-opacity" />}
/>
</div>
<div className="absolute top-2 right-2 z-[6] flex items-center justify-center">
<PlaylistModal
trigger={<Button
className="w-full flex-none rounded-full"
leftIcon={<BiEditAlt />}
intent="white-subtle"
size="sm"
>Edit</Button>} playlist={p}
/>
</div>
<div className="absolute w-full bottom-0 h-fit z-[6]">
<div className="space-y-0 pb-3 items-center">
<p className="text-md font-bold text-white max-w-lg truncate text-center">{p.name}</p>
{p.localFiles &&
<p className="text-sm text-[--muted] font-normal line-clamp-1 text-center">{p.localFiles.length} episode{p.localFiles.length > 1
? `s`
: ""}</p>}
</div>
</div>
<MediaCardBodyBottomGradient />
</div>
</CarouselItem>
)
})}
</CarouselContent>
</Carousel>
)
}

View File

@@ -0,0 +1,60 @@
import { Anime_Playlist } from "@/api/generated/types"
import { usePlaybackStartPlaylist } from "@/api/hooks/playback_manager.hooks"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import React from "react"
import { FaPlay } from "react-icons/fa"
type StartPlaylistModalProps = {
trigger?: React.ReactElement
playlist: Anime_Playlist
canStart?: boolean
onPlaylistLoaded: () => void
}
export function StartPlaylistModal(props: StartPlaylistModalProps) {
const {
trigger,
playlist,
canStart,
onPlaylistLoaded,
...rest
} = props
const { mutate: startPlaylist, isPending } = usePlaybackStartPlaylist({
onSuccess: onPlaylistLoaded,
})
if (!playlist?.localFiles?.length) return null
return (
<Modal
title="Start playlist"
titleClass="text-center"
trigger={trigger}
>
<p className="text-center">
You are about to start the playlist <strong>"{playlist.name}"</strong>,
which contains {playlist.localFiles.length} episode{playlist.localFiles.length > 1 ? "s" : ""}.
</p>
<p className="text-[--muted] text-center">
Reminder: The playlist will be deleted once you start it, whether you finish it or not.
</p>
{!canStart && (
<p className="text-orange-300 text-center">
Please enable "Automatically update progress" to start
</p>
)}
<Button
className="w-full flex-none"
leftIcon={<FaPlay />}
intent="primary"
size="lg"
loading={isPending}
disabled={!canStart}
onClick={() => startPlaylist({ dbId: playlist.dbId })}
>Start</Button>
</Modal>
)
}

View File

@@ -0,0 +1,75 @@
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
import { PlaylistsList } from "@/app/(main)/(library)/_containers/playlists/_components/playlists-list"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { Alert } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Drawer } from "@/components/ui/drawer"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
type PlaylistsModalProps = {}
export const __playlists_modalOpenAtom = atom(false)
export function PlaylistsModal(props: PlaylistsModalProps) {
const {} = props
const serverStatus = useServerStatus()
const [isOpen, setIsOpen] = useAtom(__playlists_modalOpenAtom)
return (
<>
<Drawer
open={isOpen}
onOpenChange={v => setIsOpen(v)}
size="lg"
side="bottom"
contentClass=""
>
{/*<div*/}
{/* className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover"*/}
{/*>*/}
{/* <div*/}
{/* className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/}
{/* />*/}
{/*</div>*/}
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h4>Playlists</h4>
<p className="text-[--muted] text-sm">
Playlists only work with system media players and local files.
</p>
</div>
<div className="flex gap-2 items-center md:pr-8">
<PlaylistModal
trigger={
<Button intent="white" className="rounded-full">
Add a playlist
</Button>
}
/>
</div>
</div>
{!serverStatus?.settings?.library?.autoUpdateProgress && <Alert
className="max-w-2xl mx-auto"
intent="warning"
description={<>
<p>
You need to enable "Automatically update progress" to use playlists.
</p>
</>}
/>}
<div className="">
<PlaylistsList />
</div>
</div>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import { __scanner_isScanningAtom } from "@/app/(main)/(library)/_containers/scanner-modal"
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { Card, CardDescription, CardHeader } from "@/components/ui/card"
import { Spinner } from "@/components/ui/loading-spinner"
import { ProgressBar } from "@/components/ui/progress-bar"
import { WSEvents } from "@/lib/server/ws-events"
import { useAtom } from "jotai/react"
import React, { useState } from "react"
export function ScanProgressBar() {
const [isScanning] = useAtom(__scanner_isScanningAtom)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState("Scanning...")
React.useEffect(() => {
if (!isScanning) {
setProgress(0)
setStatus("Scanning...")
}
}, [isScanning])
useWebsocketMessageListener<number>({
type: WSEvents.SCAN_PROGRESS,
onMessage: data => {
console.log("Scan progress", data)
setProgress(data)
},
})
useWebsocketMessageListener<string>({
type: WSEvents.SCAN_STATUS,
onMessage: data => {
console.log("Scan status", data)
setStatus(data)
},
})
if (!isScanning) return null
return (
<>
<div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]" data-scan-progress-bar-container>
<ProgressBar size="xs" value={progress} />
</div>
{/*<div className="fixed left-0 top-8 w-full flex justify-center z-[100]">*/}
{/* <div className="bg-gray-900 rounded-full border h-14 px-6 flex gap-2 items-center">*/}
{/* <Spinner className="w-4 h-4" />*/}
{/* <p>{progress}% - {status}</p>*/}
{/* </div>*/}
{/*</div>*/}
<div className="z-50 fixed bottom-4 right-4" data-scan-progress-bar-card-container>
<PageWrapper>
<Card className="w-fit max-w-[400px] relative" data-scan-progress-bar-card>
<CardHeader>
<CardDescription className="flex items-center gap-2 text-base text-[--foregorund]">
<Spinner className="size-6" /> {progress}% - {status}
</CardDescription>
</CardHeader>
</Card>
</PageWrapper>
</div>
</>
)
}

View File

@@ -0,0 +1,181 @@
import { useScanLocalFiles } from "@/api/hooks/scan.hooks"
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { GlowingEffect } from "@/components/shared/glowing-effect"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { useBoolean } from "@/hooks/use-disclosure"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { FiSearch } from "react-icons/fi"
export const __scanner_modalIsOpen = atom(false)
export const __scanner_isScanningAtom = atom(false)
export function ScannerModal() {
const serverStatus = useServerStatus()
const [isOpen, setOpen] = useAtom(__scanner_modalIsOpen)
const [, setScannerIsScanning] = useAtom(__scanner_isScanningAtom)
const [userMedia] = useAtom(__anilist_userAnimeMediaAtom)
const anilistDataOnly = useBoolean(true)
const skipLockedFiles = useBoolean(true)
const skipIgnoredFiles = useBoolean(true)
const { mutate: scanLibrary, isPending: isScanning } = useScanLocalFiles(() => {
setOpen(false)
})
React.useEffect(() => {
if (!userMedia?.length) anilistDataOnly.off()
else anilistDataOnly.on()
}, [userMedia])
React.useEffect(() => {
setScannerIsScanning(isScanning)
}, [isScanning])
function handleScan() {
scanLibrary({
enhanced: !anilistDataOnly.active,
skipLockedFiles: skipLockedFiles.active,
skipIgnoredFiles: skipIgnoredFiles.active,
})
setOpen(false)
}
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("scanner-controls", {
priority: 1,
items: [{
id: "refresh",
value: "refresh",
heading: "Library",
render: () => (
<p>Refresh library</p>
),
onSelect: ({ ctx }) => {
ctx.close()
setTimeout(() => {
handleScan()
}, 500)
},
showBasedOnInput: "startsWith",
}],
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase())
},
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
showBasedOnInput: "startsWith",
})
return () => remove("scanner-controls")
}, [])
return (
<>
<Modal
data-scanner-modal
open={isOpen}
onOpenChange={o => {
// if (!isScanning) {
// setOpen(o)
// }
setOpen(o)
}}
// title="Library scanner"
titleClass="text-center"
contentClass="space-y-4 max-w-2xl bg-gray-950 bg-opacity-70 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none rounded-xl"
overlayClass="bg-gray-950/70 backdrop-blur-sm"
>
<GlowingEffect
spread={50}
glow={true}
disabled={false}
proximity={100}
inactiveZone={0.01}
// movementDuration={4}
className="!mt-0 opacity-30"
/>
{/* <div
data-scanner-modal-top-pattern
className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-40 top-0 left-0 bg-no-repeat bg-right bg-cover"
>
<div
className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"
/>
</div> */}
{serverStatus?.user?.isSimulated && <div className="border border-dashed rounded-md py-2 px-4 !mt-5">
Using this feature without an AniList account is not recommended if you have a large library, as it may lead to rate limits and
slower scanning. Please consider using an account for a better experience.
</div>}
<div className="space-y-4" data-scanner-modal-content>
<AppLayoutStack className="space-y-2">
<h5 className="text-[--muted]">Local files</h5>
<Switch
side="right"
label="Skip locked files"
value={skipLockedFiles.active}
onValueChange={v => skipLockedFiles.set(v as boolean)}
// size="lg"
/>
<Switch
side="right"
label="Skip ignored files"
value={skipIgnoredFiles.active}
onValueChange={v => skipIgnoredFiles.set(v as boolean)}
// size="lg"
/>
<Separator />
<AppLayoutStack className="space-y-2">
<h5 className="text-[--muted]">Matching data</h5>
<Switch
side="right"
label="Use my AniList lists only"
moreHelp="Disabling this will cause Seanime to send more API requests which may lead to rate limits and slower scanning"
// label="Enhanced scanning"
value={anilistDataOnly.active}
onValueChange={v => anilistDataOnly.set(v as boolean)}
// className="data-[state=checked]:bg-amber-700 dark:data-[state=checked]:bg-amber-700"
// size="lg"
help={!anilistDataOnly.active
? <span><span className="text-[--orange]">Slower for large libraries</span>. For faster scanning, add the anime
entries present in your library to your
lists and re-enable this before
scanning.</span>
: ""}
disabled={!userMedia?.length}
/>
</AppLayoutStack>
</AppLayoutStack>
</div>
<Button
onClick={handleScan}
intent="primary"
leftIcon={<FiSearch />}
loading={isScanning}
className="w-full"
disabled={!serverStatus?.settings?.library?.libraryPath}
>
Scan
</Button>
</Modal>
</>
)
}

View File

@@ -0,0 +1,148 @@
import { Anime_UnknownGroup } from "@/api/generated/types"
import { useAddUnknownMedia } from "@/api/hooks/anime_collection.hooks"
import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks"
import { SeaLink } from "@/components/shared/sea-link"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Drawer } from "@/components/ui/drawer"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React, { useCallback } from "react"
import { BiLinkExternal, BiPlus } from "react-icons/bi"
import { TbDatabasePlus } from "react-icons/tb"
import { toast } from "sonner"
export const __unknownMedia_drawerIsOpen = atom(false)
type UnknownMediaManagerProps = {
unknownGroups: Anime_UnknownGroup[]
}
export function UnknownMediaManager(props: UnknownMediaManagerProps) {
const { unknownGroups } = props
const [isOpen, setIsOpen] = useAtom(__unknownMedia_drawerIsOpen)
const { mutate: addUnknownMedia, isPending: isAdding } = useAddUnknownMedia()
const { mutate: performBulkAction, isPending: isUnmatching } = useAnimeEntryBulkAction()
/**
* Add all unknown media to AniList
*/
const handleAddUnknownMedia = useCallback(() => {
addUnknownMedia({ mediaIds: unknownGroups.map(n => n.mediaId) })
}, [unknownGroups])
/**
* Close the drawer if there are no unknown groups
*/
React.useEffect(() => {
if (unknownGroups.length === 0) {
setIsOpen(false)
}
}, [unknownGroups])
/**
* Unmatch all files for a media
*/
const handleUnmatchMedia = useCallback((mediaId: number) => {
performBulkAction({
mediaId,
action: "unmatch",
}, {
onSuccess: () => {
toast.success("Media unmatched")
},
})
}, [])
if (unknownGroups.length === 0) return null
return (
<Drawer
data-unknown-media-manager-drawer
open={isOpen}
onOpenChange={o => {
if (!isAdding) {
setIsOpen(o)
}
}}
size="xl"
title="Resolve hidden media"
>
<AppLayoutStack className="mt-4">
<p className="">
Seanime matched {unknownGroups.length} group{unknownGroups.length === 1 ? "" : "s"} to media that {unknownGroups.length === 1
? "is"
: "are"} absent from your
AniList collection.<br />
Add the media to be able to see the entry in your library or unmatch them if incorrect.
</p>
<Button
leftIcon={<TbDatabasePlus />}
onClick={handleAddUnknownMedia}
loading={isAdding}
disabled={isUnmatching}
>
Add all to AniList
</Button>
<div className="divide divide-y divide-[--border] space-y-4">
{unknownGroups.map(group => {
return (
<div key={group.mediaId} className="pt-4 space-y-2">
<div className="flex items-center w-full justify-between">
<h4 className="font-semibold flex gap-2 items-center">
<span>Anilist ID:{" "}</span>
<SeaLink
href={`https://anilist.co/anime/${group.mediaId}`}
target="_blank"
className="underline text-brand-200 flex gap-1.5 items-center"
>
{group.mediaId} <BiLinkExternal />
</SeaLink>
</h4>
<div className="flex gap-2 items-center">
<Button
size="sm"
intent="primary-subtle"
disabled={isAdding}
onClick={() => addUnknownMedia({ mediaIds: [group.mediaId] })}
leftIcon={<BiPlus />}
>
Add to AniList
</Button>
<Button
size="sm"
intent="warning-subtle"
disabled={isUnmatching}
onClick={() => handleUnmatchMedia(group.mediaId)}
>
Unmatch
</Button>
</div>
</div>
<div className="bg-gray-900 border p-2 px-2 rounded-[--radius-md] space-y-1 max-h-40 max-w-full overflow-x-auto overflow-y-auto text-sm">
{group.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
.map(lf => {
return <p key={lf.path} className="text-[--muted] line-clamp-1 tracking-wide">
{lf.parsedInfo?.original || lf.path}
</p>
})}
</div>
</div>
)
})}
</div>
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -0,0 +1,374 @@
import { Anime_UnmatchedGroup } from "@/api/generated/types"
import { useAnimeEntryManualMatch, useFetchAnimeEntrySuggestions } from "@/api/hooks/anime_entries.hooks"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
import { SeaLink } from "@/components/shared/sea-link"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/components/ui/core/styling"
import { Drawer } from "@/components/ui/drawer"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { NumberInput } from "@/components/ui/number-input"
import { RadioGroup } from "@/components/ui/radio-group"
import { upath } from "@/lib/helpers/upath"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import Image from "next/image"
import React from "react"
import { FaArrowLeft, FaArrowRight } from "react-icons/fa"
import { FcFolder } from "react-icons/fc"
import { FiSearch } from "react-icons/fi"
import { TbFileSad } from "react-icons/tb"
import { toast } from "sonner"
export const __unmatchedFileManagerIsOpen = atom(false)
type UnmatchedFileManagerProps = {
unmatchedGroups: Anime_UnmatchedGroup[]
}
export function UnmatchedFileManager(props: UnmatchedFileManagerProps) {
const { unmatchedGroups } = props
const [isOpen, setIsOpen] = useAtom(__unmatchedFileManagerIsOpen)
const [page, setPage] = React.useState(0)
const maxPage = unmatchedGroups.length - 1
const [currentGroup, setCurrentGroup] = React.useState(unmatchedGroups?.[0])
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
const [anilistId, setAnilistId] = React.useState(0)
const { mutate: openInExplorer } = useOpenInExplorer()
const {
data: suggestions,
mutate: fetchSuggestions,
isPending: suggestionsLoading,
reset: resetSuggestions,
} = useFetchAnimeEntrySuggestions()
const { mutate: updateLocalFiles, isPending: isUpdatingFile } = useUpdateLocalFiles()
const { mutate: manualMatch, isPending: isMatching } = useAnimeEntryManualMatch()
const isUpdating = isUpdatingFile || isMatching
const [_r, setR] = React.useState(0)
const handleSelectAnime = React.useCallback((value: string | null) => {
if (value && !isNaN(Number(value))) {
setAnilistId(Number(value))
setR(r => r + 1)
}
}, [])
// Reset the selected paths when the current group changes
React.useLayoutEffect(() => {
setSelectedPaths(currentGroup?.localFiles?.map(lf => lf.path) ?? [])
}, [currentGroup])
// Reset the current group and page when the drawer is opened
React.useEffect(() => {
setPage(0)
setCurrentGroup(unmatchedGroups[0])
}, [isOpen, unmatchedGroups])
// Set the current group when the page changes
React.useEffect(() => {
setCurrentGroup(unmatchedGroups[page])
setAnilistId(0)
resetSuggestions()
}, [page, unmatchedGroups])
const AnilistIdInput = React.useCallback(() => {
return <NumberInput
value={anilistId}
onValueChange={v => setAnilistId(v)}
formatOptions={{
useGrouping: false,
}}
/>
}, [currentGroup?.dir, _r])
function onActionSuccess() {
if (page === 0 && unmatchedGroups.length === 1) {
setIsOpen(false)
}
setAnilistId(0)
resetSuggestions()
setPage(0)
setCurrentGroup(unmatchedGroups[0])
}
/**
* Manually match the current group with the specified Anilist ID.
* If the current group is the last group and there are no more unmatched groups, close the drawer.
*/
function handleMatchSelected() {
if (!!currentGroup && anilistId > 0 && selectedPaths.length > 0) {
manualMatch({
paths: selectedPaths,
mediaId: anilistId,
}, {
onSuccess: () => {
onActionSuccess()
},
})
}
}
const handleFetchSuggestions = React.useCallback(() => {
fetchSuggestions({
dir: currentGroup.dir,
})
}, [currentGroup?.dir, fetchSuggestions])
function handleIgnoreSelected() {
if (selectedPaths.length > 0) {
updateLocalFiles({
paths: selectedPaths,
action: "ignore",
}, {
onSuccess: () => {
onActionSuccess()
toast.success("Files ignored")
},
})
}
}
React.useEffect(() => {
if (!currentGroup) {
setIsOpen(false)
}
}, [currentGroup])
if (!currentGroup) return null
return (
<Drawer
data-unmatched-file-manager-drawer
open={isOpen}
onOpenChange={() => setIsOpen(false)}
// contentClass="max-w-5xl"
size="xl"
title="Unmatched files"
>
<AppLayoutStack className="mt-4">
<div className={cn("flex w-full justify-between", { "hidden": unmatchedGroups.length <= 1 })}>
<Button
intent="gray-subtle"
leftIcon={<FaArrowLeft />}
disabled={page === 0}
onClick={() => {
setPage(p => p - 1)
}}
className={cn("transition-opacity", { "opacity-0": page === 0 })}
>Previous</Button>
<p>
{page + 1} / {maxPage + 1}
</p>
<Button
intent="gray-subtle"
rightIcon={<FaArrowRight />}
disabled={page >= maxPage}
onClick={() => {
setPage(p => p + 1)
}}
className={cn("transition-opacity", { "opacity-0": page >= maxPage })}
>Next</Button>
</div>
<div
className="bg-gray-900 border p-2 px-4 rounded-[--radius-md] line-clamp-1 flex gap-2 items-center cursor-pointer transition hover:bg-opacity-80"
onClick={() => openInExplorer({
path: currentGroup.dir,
})}
>
<FcFolder className="text-2xl" />
{currentGroup.dir}
</div>
<div className="flex items-center flex-wrap gap-2">
<div className="flex gap-2 items-center w-full">
<p className="flex-none text-lg mr-2 font-semibold">Anilist ID</p>
<AnilistIdInput />
<Button
intent="white"
onClick={handleMatchSelected}
disabled={isUpdating}
>Match selection</Button>
</div>
{/*<div className="flex flex-1">*/}
{/*</div>*/}
</div>
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[50vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
<div className="p-2">
<Checkbox
label={`Select all files`}
value={(selectedPaths.length === currentGroup?.localFiles?.length) ? true : (selectedPaths.length === 0
? false
: "indeterminate")}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (draft.length === currentGroup?.localFiles?.length) {
return []
} else {
return currentGroup?.localFiles?.map(lf => lf.path) ?? []
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
{currentGroup.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
.map((lf, index) => (
<div
key={`${lf.path}-${index}`}
className="p-2 "
>
<div className="flex items-center">
<Checkbox
label={`${upath.basename(lf.path)}`}
value={selectedPaths.includes(lf.path)}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (checked) {
return [...draft, lf.path]
} else {
return draft.filter(p => p !== lf.path)
}
})
}
}}
labelClass="text-sm tracking-wide data-[checked=false]:opacity-50"
fieldClass="w-[fit-content]"
/>
</div>
</div>
))}
</div>
{/*<Separator />*/}
{/*<Separator />*/}
<div className="flex flex-wrap items-center gap-1">
<Button
leftIcon={<FiSearch />}
intent="primary-subtle"
onClick={handleFetchSuggestions}
>
Fetch suggestions
</Button>
<SeaLink
target="_blank"
href={`https://anilist.co/search/anime?search=${encodeURIComponent(currentGroup?.localFiles?.[0]?.parsedInfo?.title || currentGroup?.localFiles?.[0]?.parsedFolderInfo?.[0]?.title || "")}`}
>
<Button
intent="white-link"
>
Search on AniList
</Button>
</SeaLink>
<div className="flex flex-1"></div>
<Button
leftIcon={<TbFileSad className="text-lg" />}
intent="warning-subtle"
size="sm"
rounded
disabled={isUpdating}
onClick={handleIgnoreSelected}
>
Ignore selection
</Button>
</div>
{suggestionsLoading && <LoadingSpinner />}
{(!suggestionsLoading && !!suggestions?.length) && <RadioGroup
defaultValue="1"
fieldClass="w-full"
fieldLabelClass="text-md"
label="Select Anime"
value={String(anilistId)}
onValueChange={handleSelectAnime}
options={suggestions?.map((media) => (
{
label: <div>
<p className="text-base md:text-md font-medium !-mt-1.5 line-clamp-1">{media.title?.userPreferred || media.title?.english || media.title?.romaji || "N/A"}</p>
<div className="mt-2 flex w-full gap-4">
{media.coverImage?.medium && <div
className="h-28 w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
>
<Image
src={media.coverImage.medium}
alt={""}
fill
quality={100}
priority
sizes="10rem"
className="object-cover object-center"
/>
</div>}
<div className="text-[--muted]">
<p>Type: <span
className="text-gray-200 font-semibold"
>{media.format}</span>
</p>
<p>Aired: {media.startDate?.year ? new Intl.DateTimeFormat("en-US", {
year: "numeric",
}).format(new Date(media.startDate?.year || 0, media.startDate?.month || 0)) : "-"}</p>
<p>Status: {media.status}</p>
<SeaLink href={`https://anilist.co/anime/${media.id}`} target="_blank">
<Button
intent="primary-link"
size="sm"
className="px-0"
>Open on AniList</Button>
</SeaLink>
</div>
</div>
</div>,
value: String(media.id) || "",
}
))}
stackClass="grid grid-cols-1 md:grid-cols-2 gap-2 space-y-0"
itemContainerClass={cn(
"items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full",
"bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900",
"data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950",
"focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition",
"border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0",
)}
itemClass={cn(
"border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent",
"data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent",
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent",
)}
itemIndicatorClass="hidden"
itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer"
/>}
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -0,0 +1,121 @@
import { Anime_LibraryCollectionList } from "@/api/generated/types"
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { useDebounce } from "@/hooks/use-debounce"
import { CollectionParams, DEFAULT_ANIME_COLLECTION_PARAMS, filterAnimeCollectionEntries, filterEntriesByTitle } from "@/lib/helpers/filtering"
import { useThemeSettings } from "@/lib/theme/hooks"
import { atomWithImmer } from "jotai-immer"
import { useAtom, useAtomValue } from "jotai/index"
import React from "react"
export const DETAILED_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
...DEFAULT_ANIME_COLLECTION_PARAMS,
sorting: "TITLE",
}
// export const __library_paramsAtom = atomWithStorage("sea-library-sorting-params", DETAILED_LIBRARY_DEFAULT_PARAMS, undefined, { getOnInit: true })
export const __library_paramsAtom = atomWithImmer(DETAILED_LIBRARY_DEFAULT_PARAMS)
export const __library_selectedListAtom = atomWithImmer<string>("-")
export const __library_debouncedSearchInputAtom = atomWithImmer<string>("")
export function useHandleDetailedLibraryCollection() {
const serverStatus = useServerStatus()
const { animeLibraryCollectionDefaultSorting } = useThemeSettings()
const { data: watchHistory } = useGetContinuityWatchHistory()
/**
* Fetch the library collection data
*/
const { data, isLoading } = useGetLibraryCollection()
const [paramsToDebounce, setParamsToDebounce] = useAtom(__library_paramsAtom)
const debouncedParams = useDebounce(paramsToDebounce, 500)
const debouncedSearchInput = useAtomValue(__library_debouncedSearchInputAtom)
React.useLayoutEffect(() => {
let _params = { ...paramsToDebounce }
_params.sorting = animeLibraryCollectionDefaultSorting as any
setParamsToDebounce(_params)
}, [data, animeLibraryCollectionDefaultSorting])
/**
* Sort and filter the collection data
*/
const _filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
if (!data || !data.lists) return []
let _lists = data.lists.map(obj => {
if (!obj) return obj
const arr = filterAnimeCollectionEntries(obj.entries,
paramsToDebounce,
serverStatus?.settings?.anilist?.enableAdultContent,
data.continueWatchingList,
watchHistory)
return {
type: obj.type,
status: obj.status,
entries: arr,
}
})
return [
_lists.find(n => n.type === "CURRENT"),
_lists.find(n => n.type === "PAUSED"),
_lists.find(n => n.type === "PLANNING"),
_lists.find(n => n.type === "COMPLETED"),
_lists.find(n => n.type === "DROPPED"),
].filter(Boolean)
}, [data, debouncedParams, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
const filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
return _filteredCollection.map(obj => {
if (!obj) return obj
const arr = filterEntriesByTitle(obj.entries, debouncedSearchInput)
return {
type: obj.type,
status: obj.status,
entries: arr,
}
}).filter(Boolean)
}, [_filteredCollection, debouncedSearchInput])
const continueWatchingList = React.useMemo(() => {
if (!data?.continueWatchingList) return []
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
return data.continueWatchingList.filter(entry => entry.baseAnime?.isAdult === false)
}
return data.continueWatchingList
}, [
data?.continueWatchingList,
serverStatus?.settings?.anilist?.enableAdultContent,
serverStatus?.settings?.anilist?.blurAdultContent,
])
const libraryGenres = React.useMemo(() => {
const allGenres = filteredCollection?.flatMap(l => {
return l.entries?.flatMap(e => e.media?.genres) ?? []
})
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
}, [filteredCollection])
return {
isLoading: isLoading,
stats: data?.stats,
libraryCollectionList: filteredCollection,
libraryGenres: libraryGenres,
continueWatchingList: continueWatchingList,
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
unmatchedGroups: data?.unmatchedGroups ?? [],
unknownGroups: data?.unknownGroups ?? [],
}
}

View File

@@ -0,0 +1,232 @@
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import {
CollectionParams,
DEFAULT_ANIME_COLLECTION_PARAMS,
filterAnimeCollectionEntries,
sortContinueWatchingEntries,
} from "@/lib/helpers/filtering"
import { useThemeSettings } from "@/lib/theme/hooks"
import { atomWithImmer } from "jotai-immer"
import { useAtomValue, useSetAtom } from "jotai/react"
import React from "react"
export const MAIN_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
...DEFAULT_ANIME_COLLECTION_PARAMS,
sorting: "TITLE", // Will be set to default sorting on mount
continueWatchingOnly: false,
}
export const __mainLibrary_paramsAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
export const __mainLibrary_paramsInputAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
export function useHandleLibraryCollection() {
const serverStatus = useServerStatus()
const atom_setLibraryCollection = useSetAtom(animeLibraryCollectionAtom)
const { animeLibraryCollectionDefaultSorting, continueWatchingDefaultSorting } = useThemeSettings()
const { data: watchHistory } = useGetContinuityWatchHistory()
/**
* Fetch the anime library collection
*/
const { data, isLoading } = useGetLibraryCollection()
/**
* Store the received data in `libraryCollectionAtom`
*/
React.useEffect(() => {
if (!!data) {
atom_setLibraryCollection(data)
}
}, [data])
/**
* Get the current params
*/
const params = useAtomValue(__mainLibrary_paramsAtom)
/**
* Sort the collection
* - This is displayed when there's no filters applied
*/
const sortedCollection = React.useMemo(() => {
if (!data || !data.lists) return []
// Stream
if (data.stream) {
// Add to current list
let currentList = data.lists.find(n => n.type === "CURRENT")
if (currentList) {
let entries = [...(currentList.entries ?? [])]
for (let anime of (data.stream.anime ?? [])) {
if (!entries.some(e => e.mediaId === anime.id)) {
entries.push({
media: anime,
mediaId: anime.id,
listData: data.stream.listData?.[anime.id],
libraryData: undefined,
})
}
}
data.lists.find(n => n.type === "CURRENT")!.entries = entries
}
}
let _lists = data.lists.map(obj => {
if (!obj) return obj
//
let sortingParams = {
...DEFAULT_ANIME_COLLECTION_PARAMS,
continueWatchingOnly: params.continueWatchingOnly,
sorting: animeLibraryCollectionDefaultSorting as any,
} as CollectionParams<"anime">
let continueWatchingList = [...(data.continueWatchingList ?? [])]
if (data.stream) {
for (let entry of (data.stream?.continueWatchingList ?? [])) {
continueWatchingList = [...continueWatchingList, entry]
}
}
let arr = filterAnimeCollectionEntries(
obj.entries,
sortingParams,
serverStatus?.settings?.anilist?.enableAdultContent,
continueWatchingList,
watchHistory,
)
// Reset `continueWatchingOnly` to false if it's about to make the list disappear
if (arr.length === 0 && sortingParams.continueWatchingOnly) {
// TODO: Add a toast to notify the user that the list is empty
sortingParams = {
...sortingParams,
continueWatchingOnly: false, // Override
}
arr = filterAnimeCollectionEntries(
obj.entries,
sortingParams,
serverStatus?.settings?.anilist?.enableAdultContent,
continueWatchingList,
watchHistory,
)
}
return {
type: obj.type,
status: obj.status,
entries: arr,
}
})
return [
_lists.find(n => n.type === "CURRENT"),
_lists.find(n => n.type === "PAUSED"),
_lists.find(n => n.type === "PLANNING"),
_lists.find(n => n.type === "COMPLETED"),
_lists.find(n => n.type === "DROPPED"),
].filter(Boolean)
}, [data, params, animeLibraryCollectionDefaultSorting, serverStatus?.settings?.anilist?.enableAdultContent])
/**
* Filter the collection
* - This is displayed when there's filters applied
*/
const filteredCollection = React.useMemo(() => {
if (!data || !data.lists) return []
let _lists = data.lists.map(obj => {
if (!obj) return obj
const paramsToApply = {
...params,
sorting: animeLibraryCollectionDefaultSorting,
} as CollectionParams<"anime">
const arr = filterAnimeCollectionEntries(obj.entries,
paramsToApply,
serverStatus?.settings?.anilist?.enableAdultContent,
data.continueWatchingList,
watchHistory)
return {
type: obj.type,
status: obj.status,
entries: arr,
}
})
return [
_lists.find(n => n.type === "CURRENT"),
_lists.find(n => n.type === "PAUSED"),
_lists.find(n => n.type === "PLANNING"),
_lists.find(n => n.type === "COMPLETED"),
_lists.find(n => n.type === "DROPPED"),
].filter(Boolean)
}, [data, params, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
/**
* Sort the continue watching list
*/
const continueWatchingList = React.useMemo(() => {
if (!data?.continueWatchingList) return []
let list = [...data.continueWatchingList]
if (data.stream) {
for (let entry of (data.stream.continueWatchingList ?? [])) {
list = [...list, entry]
}
}
const entries = sortedCollection.flatMap(n => n.entries)
list = sortContinueWatchingEntries(list, continueWatchingDefaultSorting as any, entries, watchHistory)
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
return list.filter(entry => entry.baseAnime?.isAdult === false)
}
return list
}, [
data?.stream,
sortedCollection,
data?.continueWatchingList,
continueWatchingDefaultSorting,
serverStatus?.settings?.anilist?.enableAdultContent,
serverStatus?.settings?.anilist?.blurAdultContent,
watchHistory,
])
/**
* Get the genres from all media in the library
*/
const libraryGenres = React.useMemo(() => {
const allGenres = filteredCollection?.flatMap(l => {
return l.entries?.flatMap(e => e.media?.genres) ?? []
})
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
}, [filteredCollection])
return {
libraryGenres,
isLoading: isLoading,
libraryCollectionList: sortedCollection,
filteredLibraryCollectionList: filteredCollection,
continueWatchingList: continueWatchingList,
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
unmatchedGroups: data?.unmatchedGroups ?? [],
unknownGroups: data?.unknownGroups ?? [],
streamingMediaIds: data?.stream?.anime?.map(n => n.id) ?? [],
hasEntries: sortedCollection.some(n => n.entries?.length > 0),
isStreamingOnly: sortedCollection.every(n => n.entries?.every(e => !e.libraryData)),
isNakamaLibrary: React.useMemo(() => data?.lists?.some(l => l.entries?.some(e => !!e.nakamaLibraryData)) ?? false, [data?.lists]),
}
}

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai"
export const __library_viewAtom = atom<"base" | "detailed">("base")

View File

@@ -0,0 +1,366 @@
import { Anime_Episode, Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
import {
__library_debouncedSearchInputAtom,
__library_paramsAtom,
__library_selectedListAtom,
DETAILED_LIBRARY_DEFAULT_PARAMS,
useHandleDetailedLibraryCollection,
} from "@/app/(main)/(library)/_lib/handle-detailed-library-collection"
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { ADVANCED_SEARCH_FORMATS, ADVANCED_SEARCH_SEASONS, ADVANCED_SEARCH_STATUS } from "@/app/(main)/search/_lib/advanced-search-constants"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { IconButton } from "@/components/ui/button"
import { cn } from "@/components/ui/core/styling"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { Select } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { StaticTabs } from "@/components/ui/tabs"
import { TextInput } from "@/components/ui/text-input"
import { useDebounce } from "@/hooks/use-debounce"
import { ANIME_COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering"
import { getLibraryCollectionTitle } from "@/lib/server/utils"
import { useThemeSettings } from "@/lib/theme/hooks"
import { getYear } from "date-fns"
import { useAtomValue, useSetAtom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { AiOutlineArrowLeft } from "react-icons/ai"
import { BiTrash } from "react-icons/bi"
import { FaSortAmountDown } from "react-icons/fa"
import { FiSearch } from "react-icons/fi"
import { LuCalendar, LuLeaf } from "react-icons/lu"
import { MdPersonalVideo } from "react-icons/md"
import { RiSignalTowerLine } from "react-icons/ri"
type LibraryViewProps = {
collectionList: Anime_LibraryCollectionList[]
continueWatchingList: Anime_Episode[]
isLoading: boolean
hasEntries: boolean
streamingMediaIds: number[]
isNakamaLibrary: boolean
}
export function DetailedLibraryView(props: LibraryViewProps) {
const {
// collectionList: _collectionList,
continueWatchingList,
isLoading,
hasEntries,
streamingMediaIds,
isNakamaLibrary,
...rest
} = props
const ts = useThemeSettings()
const setView = useSetAtom(__library_viewAtom)
const nakamaStatus = useNakamaStatus()
const {
stats,
libraryCollectionList,
libraryGenres,
} = useHandleDetailedLibraryCollection()
if (isLoading) return <LoadingSpinner />
if (!hasEntries) return null
return (
<PageWrapper className="p-4 space-y-8 relative z-[4]" data-detailed-library-view-container>
{/* <div
className={cn(
"absolute top-[-20rem] left-0 w-full h-[30rem] bg-gradient-to-t from-[--background] to-transparent z-[-1]",
TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
)}
/> */}
<div className="flex flex-col md:flex-row gap-4 justify-between" data-detailed-library-view-header-container>
<div className="flex gap-4 items-center relative w-fit">
<IconButton
icon={<AiOutlineArrowLeft />}
rounded
intent="white-outline"
size="sm"
onClick={() => setView("base")}
/>
{!isNakamaLibrary && <h3 className="text-ellipsis truncate">Library</h3>}
{isNakamaLibrary &&
<h3 className="text-ellipsis truncate">{nakamaStatus?.hostConnectionStatus?.username || "Host"}'s Library</h3>}
</div>
<SearchInput />
</div>
<div
className={cn(
"grid grid-cols-3 lg:grid-cols-6 gap-4 [&>div]:text-center [&>div>p]:text-[--muted]",
isNakamaLibrary && "lg:grid-cols-5",
)}
data-detailed-library-view-stats-container
>
{!isNakamaLibrary && <div>
<h3>{stats?.totalSize}</h3>
<p>Library</p>
</div>}
<div>
<h3>{stats?.totalFiles}</h3>
<p>Files</p>
</div>
<div>
<h3>{stats?.totalEntries}</h3>
<p>Entries</p>
</div>
<div>
<h3>{stats?.totalShows}</h3>
<p>TV Shows</p>
</div>
<div>
<h3>{stats?.totalMovies}</h3>
<p>Movies</p>
</div>
<div>
<h3>{stats?.totalSpecials}</h3>
<p>Specials</p>
</div>
</div>
<SearchOptions />
<GenreSelector genres={libraryGenres} />
{libraryCollectionList.map(collection => {
if (!collection.entries?.length) return null
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
})}
</PageWrapper>
)
}
const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: { list: Anime_LibraryCollectionList, streamingMediaIds: number[] }) => {
const selectedList = useAtomValue(__library_selectedListAtom)
if (selectedList !== "-" && selectedList !== list.type) return null
return (
<React.Fragment key={list.type}>
<h2>{getLibraryCollectionTitle(list.type)} <span className="text-[--muted] font-medium ml-3">{list?.entries?.length ?? 0}</span></h2>
<MediaCardLazyGrid itemCount={list?.entries?.length || 0}>
{list.entries?.map(entry => {
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
})}
</MediaCardLazyGrid>
</React.Fragment>
)
})
const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
entry: Anime_LibraryCollectionEntry,
streamingMediaIds: number[]
}) => {
return (
<MediaEntryCard
media={entry.media!}
listData={entry.listData}
libraryData={entry.libraryData}
nakamaLibraryData={entry.nakamaLibraryData}
showListDataButton
withAudienceScore={false}
type="anime"
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId)}
/>
)
})
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const SearchInput = () => {
const [inputValue, setInputValue] = React.useState("")
const setDebouncedInput = useSetAtom(__library_debouncedSearchInputAtom)
const debouncedInput = useDebounce(inputValue, 500)
React.useEffect(() => {
setDebouncedInput(inputValue)
}, [debouncedInput])
return (
<div className="w-full md:w-[300px]">
<TextInput
leftIcon={<FiSearch />}
value={inputValue}
onValueChange={v => {
setInputValue(v)
}}
className="rounded-full bg-gray-900/50"
/>
</div>
)
}
export function SearchOptions() {
const serverStatus = useServerStatus()
const [params, setParams] = useAtom(__library_paramsAtom)
const [selectedIndex, setSelectedIndex] = useAtom(__library_selectedListAtom)
return (
<AppLayoutStack className="px-4 xl:px-0" data-detailed-library-view-search-options-container>
<div className="flex w-full justify-center">
<StaticTabs
className="h-10 w-fit pb-6"
triggerClass="px-4 py-1"
items={[
{ name: "All", isCurrent: selectedIndex === "-", onClick: () => setSelectedIndex("-") },
{ name: "Watching", isCurrent: selectedIndex === "CURRENT", onClick: () => setSelectedIndex("CURRENT") },
{ name: "Planning", isCurrent: selectedIndex === "PLANNING", onClick: () => setSelectedIndex("PLANNING") },
{ name: "Paused", isCurrent: selectedIndex === "PAUSED", onClick: () => setSelectedIndex("PAUSED") },
{ name: "Completed", isCurrent: selectedIndex === "COMPLETED", onClick: () => setSelectedIndex("COMPLETED") },
{ name: "Dropped", isCurrent: selectedIndex === "DROPPED", onClick: () => setSelectedIndex("DROPPED") },
]}
/>
</div>
<div
className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-[1fr_1fr_1fr_1fr_1fr_auto_auto] gap-4"
data-detailed-library-view-search-options-grid
>
<Select
label="Sorting"
leftAddon={<FaSortAmountDown className={cn(params.sorting !== "TITLE" && "text-indigo-300 font-bold text-xl")} />}
className="w-full"
fieldClass="flex items-center"
inputContainerClass="w-full"
options={ANIME_COLLECTION_SORTING_OPTIONS}
value={params.sorting || "TITLE"}
onValueChange={v => setParams(draft => {
draft.sorting = v as any
return
})}
fieldLabelClass="hidden"
// disabled={!!params.title && params.title.length > 0}
/>
<Select
leftAddon={
<MdPersonalVideo className={cn((params.format as any) !== null && (params.format as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
label="Format" placeholder="All formats"
className="w-full"
fieldClass="w-full"
options={ADVANCED_SEARCH_FORMATS}
value={params.format || ""}
onValueChange={v => setParams(draft => {
draft.format = v as any
return
})}
fieldLabelClass="hidden"
/>
<Select
leftAddon={
<RiSignalTowerLine className={cn((params.status as any) !== null && (params.status as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
label="Status" placeholder="All statuses"
className="w-full"
fieldClass="w-full"
options={[
...ADVANCED_SEARCH_STATUS,
]}
value={params.status || ""}
onValueChange={v => setParams(draft => {
draft.status = v as any
return
})}
fieldLabelClass="hidden"
/>
<Select
leftAddon={
<LuLeaf className={cn((params.season as any) !== null && (params.season as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
label="Season"
placeholder="All seasons"
className="w-full"
fieldClass="w-full flex items-center"
inputContainerClass="w-full"
options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))}
value={params.season || ""}
onValueChange={v => setParams(draft => {
draft.season = v as any
return
})}
fieldLabelClass="hidden"
/>
<Select
leftAddon={<LuCalendar className={cn((params.year !== null && params.year !== "") && "text-indigo-300 font-bold text-xl")} />}
label="Year" placeholder="Timeless"
className="w-full"
fieldClass="w-full"
options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx).map(year => ({
value: String(year),
label: String(year),
}))}
value={params.year || ""}
onValueChange={v => setParams(draft => {
draft.year = v as any
return
})}
fieldLabelClass="hidden"
/>
<div className="flex gap-4 items-center w-full">
<IconButton
icon={<BiTrash />} intent="alert-subtle" className="flex-none" onClick={() => {
setParams(DETAILED_LIBRARY_DEFAULT_PARAMS)
}}
/>
</div>
{serverStatus?.settings?.anilist?.enableAdultContent && <div className="flex h-full items-center">
<Switch
label="Adult"
value={params.isAdult}
onValueChange={v => setParams(draft => {
draft.isAdult = v
return
})}
fieldLabelClass="hidden"
/>
</div>}
</div>
</AppLayoutStack>
)
}
function GenreSelector({ genres }: { genres: string[] }) {
const [params, setParams] = useAtom(__library_paramsAtom)
return (
<MediaGenreSelector
items={[
{
name: "All",
isCurrent: !params!.genre?.length,
onClick: () => setParams(draft => {
draft.genre = []
return
}),
},
...genres.map(genre => ({
name: genre,
isCurrent: params!.genre?.includes(genre) ?? false,
onClick: () => setParams(draft => {
if (draft.genre?.includes(genre)) {
draft.genre = draft.genre?.filter(g => g !== genre)
} else {
draft.genre = [...(draft.genre || []), genre]
}
return
}),
})),
]}
/>
)
}

View File

@@ -0,0 +1,148 @@
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { DiscoverPageHeader } from "@/app/(main)/discover/_components/discover-page-header"
import { DiscoverTrending } from "@/app/(main)/discover/_containers/discover-trending"
import { LuffyError } from "@/components/shared/luffy-error"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { SeaLink } from "@/components/shared/sea-link"
import { Button } from "@/components/ui/button"
import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll"
import { StaticTabs } from "@/components/ui/tabs"
import { useDebounce } from "@/hooks/use-debounce"
import { useSetAtom } from "jotai/index"
import { useAtom } from "jotai/react"
import React from "react"
import { FiSearch } from "react-icons/fi"
import { LuCog } from "react-icons/lu"
type EmptyLibraryViewProps = {
isLoading: boolean
hasEntries: boolean
}
export function EmptyLibraryView(props: EmptyLibraryViewProps) {
const {
isLoading,
hasEntries,
...rest
} = props
const serverStatus = useServerStatus()
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
if (hasEntries || isLoading) return null
/**
* Show empty library message and trending if library is empty
*/
return (
<>
<DiscoverPageHeader />
<PageWrapper className="p-4 sm:p-8 pt-0 space-y-8 relative z-[4]" data-empty-library-view-container>
<div className="text-center space-y-4">
<div className="w-fit mx-auto space-y-4">
{!!serverStatus?.settings?.library?.libraryPath ? <>
<h2>Empty library</h2>
<Button
intent="primary-outline"
leftIcon={<FiSearch />}
size="xl"
rounded
onClick={() => setScannerModalOpen(true)}
>
Scan your library
</Button>
</> : (
<LuffyError
title="Your library is empty"
className=""
>
<div className="text-center space-y-4">
<SeaLink href="/settings?tab=library">
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
Set the path to your local library and scan it
</Button>
</SeaLink>
{serverStatus?.settings?.library?.enableOnlinestream && <p>
<SeaLink href="/settings?tab=onlinestream">
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
Include online streaming in your library
</Button>
</SeaLink>
</p>}
{serverStatus?.torrentstreamSettings?.enabled && <p>
<SeaLink href="/settings?tab=torrentstream">
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
Include torrent streaming in your library
</Button>
</SeaLink>
</p>}
{serverStatus?.debridSettings?.enabled && <p>
<SeaLink href="/settings?tab=debrid">
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
Include debrid streaming in your library
</Button>
</SeaLink>
</p>}
</div>
</LuffyError>
)}
</div>
</div>
<div className="">
<h3>Trending this season</h3>
<DiscoverTrending />
</div>
</PageWrapper>
</>
)
}
function GenreSelector({
genres,
}: { genres: string[] }) {
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
const debouncedParams = useDebounce(params, 500)
React.useEffect(() => {
setActualParams(params)
}, [debouncedParams])
if (!genres.length) return null
return (
<HorizontalDraggableScroll className="scroll-pb-1 pt-4 flex">
<div className="flex flex-1"></div>
<StaticTabs
className="px-2 overflow-visible gap-2 py-4 w-fit"
triggerClass="text-base rounded-[--radius-md] ring-2 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300"
items={[
// {
// name: "All",
// isCurrent: !params!.genre?.length,
// onClick: () => setParams(draft => {
// draft.genre = []
// return
// }),
// },
...genres.map(genre => ({
name: genre,
isCurrent: params!.genre?.includes(genre) ?? false,
onClick: () => setParams(draft => {
if (draft.genre?.includes(genre)) {
draft.genre = draft.genre?.filter(g => g !== genre)
} else {
draft.genre = [...(draft.genre || []), genre]
}
return
}),
})),
]}
/>
<div className="flex flex-1"></div>
</HorizontalDraggableScroll>
)
}

View File

@@ -0,0 +1,136 @@
import { Anime_Episode, Anime_LibraryCollectionList } from "@/api/generated/types"
import { ContinueWatching } from "@/app/(main)/(library)/_containers/continue-watching"
import { LibraryCollectionFilteredLists, LibraryCollectionLists } from "@/app/(main)/(library)/_containers/library-collection"
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { cn } from "@/components/ui/core/styling"
import { Skeleton } from "@/components/ui/skeleton"
import { useDebounce } from "@/hooks/use-debounce"
import { useThemeSettings } from "@/lib/theme/hooks"
import { useSetAtom } from "jotai/index"
import { useAtom } from "jotai/react"
import { AnimatePresence } from "motion/react"
import React from "react"
type LibraryViewProps = {
genres: string[]
collectionList: Anime_LibraryCollectionList[]
filteredCollectionList: Anime_LibraryCollectionList[]
continueWatchingList: Anime_Episode[]
isLoading: boolean
hasEntries: boolean
streamingMediaIds: number[]
}
export function LibraryView(props: LibraryViewProps) {
const {
genres,
collectionList,
continueWatchingList,
filteredCollectionList,
isLoading,
hasEntries,
streamingMediaIds,
...rest
} = props
const ts = useThemeSettings()
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
if (isLoading) return <React.Fragment>
<div className="p-4 space-y-4 relative z-[4]">
<Skeleton className="h-12 w-full max-w-lg relative" />
<div
className={cn(
"grid h-[22rem] min-[2000px]:h-[24rem] grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 min-[2000px]:grid-cols-8 gap-4",
)}
>
{[1, 2, 3, 4, 5, 6, 7, 8]?.map((_, idx) => {
return <Skeleton
key={idx} className={cn(
"h-[22rem] min-[2000px]:h-[24rem] col-span-1 aspect-[6/7] flex-none rounded-[--radius-md] relative overflow-hidden",
"[&:nth-child(8)]:hidden min-[2000px]:[&:nth-child(8)]:block",
"[&:nth-child(7)]:hidden 2xl:[&:nth-child(7)]:block",
"[&:nth-child(6)]:hidden xl:[&:nth-child(6)]:block",
"[&:nth-child(5)]:hidden xl:[&:nth-child(5)]:block",
"[&:nth-child(4)]:hidden lg:[&:nth-child(4)]:block",
"[&:nth-child(3)]:hidden md:[&:nth-child(3)]:block",
)}
/>
})}
</div>
</div>
</React.Fragment>
return (
<>
<ContinueWatching
episodes={continueWatchingList}
isLoading={isLoading}
/>
{(
!ts.disableLibraryScreenGenreSelector &&
collectionList.flatMap(n => n.entries)?.length > 2
) && <GenreSelector genres={genres} />}
<PageWrapper key="library-collection-lists" className="p-4 space-y-8 relative z-[4]" data-library-collection-lists-container>
<AnimatePresence mode="wait" initial={false}>
{!params.genre?.length ?
<LibraryCollectionLists
key="library-collection-lists"
collectionList={collectionList}
isLoading={isLoading}
streamingMediaIds={streamingMediaIds}
/>
: <LibraryCollectionFilteredLists
key="library-filtered-lists"
collectionList={filteredCollectionList}
isLoading={isLoading}
streamingMediaIds={streamingMediaIds}
/>
}
</AnimatePresence>
</PageWrapper>
</>
)
}
function GenreSelector({
genres,
}: { genres: string[] }) {
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
const debouncedParams = useDebounce(params, 200)
React.useEffect(() => {
setActualParams(params)
}, [debouncedParams])
if (!genres.length) return null
return (
<PageWrapper className="space-y-3 lg:space-y-6 relative z-[4]" data-library-genre-selector-container>
<MediaGenreSelector
items={[
...genres.map(genre => ({
name: genre,
isCurrent: params!.genre?.includes(genre) ?? false,
onClick: () => setParams(draft => {
if (draft.genre?.includes(genre)) {
draft.genre = draft.genre?.filter(g => g !== genre)
} else {
draft.genre = [...(draft.genre || []), genre]
}
return
}),
})),
]}
/>
</PageWrapper>
)
}

View File

@@ -0,0 +1,18 @@
"use client"
import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image"
import React from "react"
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{/*[CUSTOM UI]*/}
<CustomBackgroundImage />
{children}
</>
)
}
export const dynamic = "force-static"

Some files were not shown because too many files have changed in this diff Show More