├── assets
└── screenshots
│ ├── UI.png
│ ├── filters.png
│ ├── showdetails.png
│ └── notification.png
├── frontend
├── src
│ ├── assets
│ │ ├── logodark.png
│ │ ├── logolight.png
│ │ └── logotransparent.png
│ ├── main.jsx
│ ├── components
│ │ ├── SonarrSelector.jsx
│ │ ├── StatCard.jsx
│ │ ├── AnimatedCounter.jsx
│ │ ├── Pagination.jsx
│ │ ├── Login.jsx
│ │ ├── Activity.jsx
│ │ ├── Register.jsx
│ │ ├── AddSonarrModal.jsx
│ │ ├── ShowCard.jsx
│ │ ├── ActivityHistory.jsx
│ │ ├── SearchResultsModal.jsx
│ │ ├── SonarrInstanceManager.jsx
│ │ ├── Settings.jsx
│ │ ├── EnhancedProgressBar.jsx
│ │ ├── ShowDetail.jsx
│ │ └── Dashboard.jsx
│ ├── index.css
│ ├── App.jsx
│ ├── services
│ │ └── api.js
│ └── hooks
│ │ └── useWebSocket.js
├── vite.config.js
├── .gitignore
├── index.html
├── package.json
└── eslint.config.js
├── backend
├── requirements.txt
├── Dockerfile
├── database.py
├── auth.py
├── schemas.py
├── cache.py
├── models.py
├── bulk_operation_manager.py
└── websocket_manager.py
├── docker-compose.yml
├── LICENSE
├── .gitignore
├── .github
└── workflows
│ └── docker-build.yml
└── README.md
/assets/screenshots/UI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/assets/screenshots/UI.png
--------------------------------------------------------------------------------
/assets/screenshots/filters.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/assets/screenshots/filters.png
--------------------------------------------------------------------------------
/assets/screenshots/showdetails.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/assets/screenshots/showdetails.png
--------------------------------------------------------------------------------
/frontend/src/assets/logodark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/frontend/src/assets/logodark.png
--------------------------------------------------------------------------------
/frontend/src/assets/logolight.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/frontend/src/assets/logolight.png
--------------------------------------------------------------------------------
/assets/screenshots/notification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/assets/screenshots/notification.png
--------------------------------------------------------------------------------
/frontend/src/assets/logotransparent.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/d3v1l1989/seasonarr/HEAD/frontend/src/assets/logotransparent.png
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | fastapi==0.104.1
2 | uvicorn[standard]==0.24.0
3 | sqlalchemy==2.0.23
4 | python-jose[cryptography]==3.3.0
5 | passlib[bcrypt]==1.7.4
6 | python-multipart==0.0.6
7 | httpx==0.25.2
8 | websockets==12.0
9 | pydantic==2.5.0
10 | python-dotenv==1.0.0
--------------------------------------------------------------------------------
/frontend/src/main.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import './index.css'
4 | import App from './App.jsx'
5 |
6 | createRoot(document.getElementById('root')).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Seasonarr
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build React frontend
2 | FROM --platform=$BUILDPLATFORM node:18-alpine as frontend-builder
3 | WORKDIR /frontend
4 | COPY frontend/package*.json ./
5 | RUN npm ci
6 | COPY frontend .
7 | RUN npm run build
8 |
9 | # Python backend
10 | FROM python:3.11-slim
11 |
12 | WORKDIR /app
13 |
14 | COPY backend/requirements.txt .
15 | RUN pip install --no-cache-dir -r requirements.txt
16 |
17 | COPY backend .
18 |
19 | # Copy React build files to static directory
20 | COPY --from=frontend-builder /frontend/dist ./static
21 |
22 | EXPOSE 8000
23 |
24 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | seasonarr:
3 | image: ghcr.io/d3v1l1989/seasonarr:latest
4 | container_name: seasonarr
5 | restart: unless-stopped
6 | hostname: seasonarr
7 | platform: linux/amd64
8 | environment:
9 | - PUID=1000
10 | - PGID=1000
11 | - TZ=Etc/UTC
12 | - DATABASE_URL=sqlite:///./data/seasonarr.db
13 | - JWT_SECRET_KEY=change-this-to-a-secure-random-string
14 | volumes:
15 | - seasonarr_data:/app/data
16 | - /etc/localtime:/etc/localtime:ro
17 | ports:
18 | - "8000:8000"
19 | networks:
20 | - sonarrNetwork
21 |
22 | volumes:
23 | seasonarr_data:
24 |
25 | networks:
26 | sonarrNetwork:
27 | external: true
--------------------------------------------------------------------------------
/frontend/src/components/SonarrSelector.jsx:
--------------------------------------------------------------------------------
1 | export default function SonarrSelector({ instances, selectedInstance, onInstanceChange }) {
2 | return (
3 |
4 |
18 |
19 | );
20 | }
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "axios": "^1.10.0",
14 | "react": "^19.1.0",
15 | "react-dom": "^19.1.0",
16 | "react-router-dom": "^7.6.3"
17 | },
18 | "devDependencies": {
19 | "@eslint/js": "^9.30.1",
20 | "@types/react": "^19.1.8",
21 | "@types/react-dom": "^19.1.6",
22 | "@vitejs/plugin-react": "^4.6.0",
23 | "eslint": "^9.30.1",
24 | "eslint-plugin-react-hooks": "^5.2.0",
25 | "eslint-plugin-react-refresh": "^0.4.20",
26 | "globals": "^16.3.0",
27 | "vite": "^7.0.4"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/StatCard.jsx:
--------------------------------------------------------------------------------
1 | import AnimatedCounter from './AnimatedCounter';
2 |
3 | export default function StatCard({
4 | number,
5 | label,
6 | isLoading = false,
7 | delay = 0,
8 | className = ""
9 | }) {
10 | return (
11 |
12 |
19 |
20 | {isLoading ? (
21 | Loading...
22 | ) : (
23 | label
24 | )}
25 |
26 | {isLoading &&
}
27 |
28 | );
29 | }
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import { defineConfig, globalIgnores } from 'eslint/config'
6 |
7 | export default defineConfig([
8 | globalIgnores(['dist']),
9 | {
10 | files: ['**/*.{js,jsx}'],
11 | extends: [
12 | js.configs.recommended,
13 | reactHooks.configs['recommended-latest'],
14 | reactRefresh.configs.vite,
15 | ],
16 | languageOptions: {
17 | ecmaVersion: 2020,
18 | globals: globals.browser,
19 | parserOptions: {
20 | ecmaVersion: 'latest',
21 | ecmaFeatures: { jsx: true },
22 | sourceType: 'module',
23 | },
24 | },
25 | rules: {
26 | 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27 | },
28 | },
29 | ])
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 d3v1l1989
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Environment files
2 | .env
3 | .env.local
4 | .env.development.local
5 | .env.test.local
6 | .env.production.local
7 |
8 | # Database
9 | *.db
10 | *.sqlite
11 | *.sqlite3
12 |
13 | # Logs
14 | logs
15 | *.log
16 | npm-debug.log*
17 | yarn-debug.log*
18 | yarn-error.log*
19 | pnpm-debug.log*
20 | lerna-debug.log*
21 |
22 | # Runtime data
23 | pids
24 | *.pid
25 | *.seed
26 | *.pid.lock
27 |
28 | # Dependencies
29 | node_modules/
30 | __pycache__/
31 | *.py[cod]
32 | *$py.class
33 | *.so
34 | .Python
35 | build/
36 | develop-eggs/
37 | dist/
38 | downloads/
39 | eggs/
40 | .eggs/
41 | lib/
42 | lib64/
43 | parts/
44 | sdist/
45 | var/
46 | wheels/
47 | pip-wheel-metadata/
48 | share/python-wheels/
49 | *.egg-info/
50 | .installed.cfg
51 | *.egg
52 | MANIFEST
53 |
54 | # Frontend build
55 | /frontend/dist
56 | /frontend/build
57 |
58 | # IDE
59 | .vscode/
60 | .idea/
61 | *.swp
62 | *.swo
63 | *~
64 |
65 | # OS
66 | .DS_Store
67 | .DS_Store?
68 | ._*
69 | .Spotlight-V100
70 | .Trashes
71 | ehthumbs.db
72 | Thumbs.db
73 |
74 | # Docker
75 | .dockerignore
76 | docker-compose.local.yml
77 |
78 | # Temporary files
79 | *.tmp
80 | *.temp
81 |
82 | CLAUDE.md
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | button {
39 | border-radius: 8px;
40 | border: 1px solid transparent;
41 | padding: 0.6em 1.2em;
42 | font-size: 1em;
43 | font-weight: 500;
44 | font-family: inherit;
45 | background-color: #1a1a1a;
46 | cursor: pointer;
47 | transition: border-color 0.25s;
48 | }
49 | button:hover {
50 | border-color: #646cff;
51 | }
52 | button:focus,
53 | button:focus-visible {
54 | outline: 4px auto -webkit-focus-ring-color;
55 | }
56 |
57 | @media (prefers-color-scheme: light) {
58 | :root {
59 | color: #213547;
60 | background-color: #ffffff;
61 | }
62 | a:hover {
63 | color: #747bff;
64 | }
65 | button {
66 | background-color: #f9f9f9;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/.github/workflows/docker-build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Image
2 |
3 | on:
4 | push:
5 | branches: [ main, beta-arm-support ]
6 | pull_request:
7 | branches: [ main, beta-arm-support ]
8 | release:
9 | types: [published]
10 |
11 | env:
12 | REGISTRY: ghcr.io
13 | IMAGE_NAME: ${{ github.repository }}
14 |
15 | jobs:
16 | build:
17 | runs-on: ubuntu-latest
18 | permissions:
19 | contents: read
20 | packages: write
21 |
22 | steps:
23 | - name: Checkout repository
24 | uses: actions/checkout@v4
25 |
26 | - name: Log in to Container Registry
27 | uses: docker/login-action@v3
28 | with:
29 | registry: ${{ env.REGISTRY }}
30 | username: ${{ github.actor }}
31 | password: ${{ secrets.GITHUB_TOKEN }}
32 |
33 | - name: Extract metadata
34 | id: meta
35 | uses: docker/metadata-action@v5
36 | with:
37 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
38 | tags: |
39 | type=ref,event=branch
40 | type=ref,event=pr
41 | type=semver,pattern={{version}}
42 | type=semver,pattern={{major}}.{{minor}}
43 | type=raw,value=latest,enable={{is_default_branch}}
44 | type=raw,value=beta-arm,enable=${{ github.ref == 'refs/heads/beta-arm-support' }}
45 | type=ref,event=tag
46 |
47 | - name: Set up Docker Buildx
48 | uses: docker/setup-buildx-action@v3
49 |
50 | - name: Build and push Docker image
51 | uses: docker/build-push-action@v5
52 | with:
53 | context: .
54 | file: ./backend/Dockerfile
55 | platforms: linux/amd64,linux/arm64
56 | push: true
57 | tags: ${{ steps.meta.outputs.tags }}
58 | labels: ${{ steps.meta.outputs.labels }}
59 | cache-from: type=gha
60 | cache-to: type=gha,mode=max
--------------------------------------------------------------------------------
/frontend/src/components/AnimatedCounter.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 |
3 | export default function AnimatedCounter({
4 | value,
5 | duration = 1000,
6 | isLoading = false,
7 | formatNumber = true
8 | }) {
9 | const [displayValue, setDisplayValue] = useState(0);
10 | const countRef = useRef(null);
11 | const startTimeRef = useRef(null);
12 |
13 | useEffect(() => {
14 | if (isLoading || value === 0) {
15 | setDisplayValue(0);
16 | return;
17 | }
18 |
19 | // Start animation from 0
20 | setDisplayValue(0);
21 | startTimeRef.current = null;
22 |
23 | const animateCount = (timestamp) => {
24 | if (!startTimeRef.current) {
25 | startTimeRef.current = timestamp;
26 | }
27 |
28 | const elapsed = timestamp - startTimeRef.current;
29 | const progress = Math.min(elapsed / duration, 1);
30 |
31 | // Easing function for smooth animation
32 | const easeOutQuart = 1 - Math.pow(1 - progress, 4);
33 | const currentValue = Math.floor(easeOutQuart * value);
34 |
35 | setDisplayValue(currentValue);
36 |
37 | if (progress < 1) {
38 | countRef.current = requestAnimationFrame(animateCount);
39 | } else {
40 | setDisplayValue(value); // Ensure we end on the exact value
41 | }
42 | };
43 |
44 | countRef.current = requestAnimationFrame(animateCount);
45 |
46 | return () => {
47 | if (countRef.current) {
48 | cancelAnimationFrame(countRef.current);
49 | }
50 | };
51 | }, [value, duration, isLoading]);
52 |
53 | const formatDisplayValue = (num) => {
54 | if (!formatNumber) return num;
55 | return num.toLocaleString();
56 | };
57 |
58 | if (isLoading) {
59 | return (
60 |
61 | Loading...
62 |
63 | );
64 | }
65 |
66 | return (
67 |
68 |
69 | {formatDisplayValue(displayValue)}
70 |
71 |
72 | );
73 | }
--------------------------------------------------------------------------------
/backend/database.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import create_engine
2 | from sqlalchemy.ext.declarative import declarative_base
3 | from sqlalchemy.orm import sessionmaker
4 | import os
5 |
6 | DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./seasonarr.db")
7 |
8 | # Configure connection pooling for better performance
9 | if DATABASE_URL.startswith("sqlite"):
10 | # SQLite specific configuration
11 | engine = create_engine(
12 | DATABASE_URL,
13 | connect_args={
14 | "check_same_thread": False,
15 | "timeout": 30, # 30 second timeout
16 | "isolation_level": None # Autocommit mode for better concurrency
17 | },
18 | pool_pre_ping=True, # Validate connections before use
19 | pool_recycle=3600, # Recycle connections every hour
20 | echo=False # Set to True for SQL debugging
21 | )
22 | else:
23 | # For other databases (PostgreSQL, MySQL, etc.)
24 | engine = create_engine(
25 | DATABASE_URL,
26 | pool_size=10, # Number of connections to maintain
27 | max_overflow=20, # Additional connections beyond pool_size
28 | pool_pre_ping=True, # Validate connections before use
29 | pool_recycle=3600, # Recycle connections every hour
30 | echo=False # Set to True for SQL debugging
31 | )
32 | SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
33 |
34 | Base = declarative_base()
35 |
36 | def get_db():
37 | db = SessionLocal()
38 | try:
39 | yield db
40 | finally:
41 | db.close()
42 |
43 | def init_db():
44 | # Ensure data directory exists
45 | db_path = DATABASE_URL.replace("sqlite:///", "")
46 | db_dir = os.path.dirname(db_path)
47 | if db_dir and not os.path.exists(db_dir):
48 | os.makedirs(db_dir, exist_ok=True)
49 |
50 | from models import User, SonarrInstance, UserSettings
51 | Base.metadata.create_all(bind=engine)
52 |
53 | def check_if_first_run():
54 | from models import User
55 | db = SessionLocal()
56 | try:
57 | user_count = db.query(User).count()
58 | return user_count == 0
59 | finally:
60 | db.close()
--------------------------------------------------------------------------------
/frontend/src/components/Pagination.jsx:
--------------------------------------------------------------------------------
1 | export default function Pagination({ currentPage, totalPages, onPageChange }) {
2 | if (totalPages <= 1) return null;
3 |
4 | const getPageNumbers = () => {
5 | const pages = [];
6 | const maxVisiblePages = 5;
7 |
8 | if (totalPages <= maxVisiblePages) {
9 | // Show all pages if total pages is small
10 | for (let i = 1; i <= totalPages; i++) {
11 | pages.push(i);
12 | }
13 | } else {
14 | // Show smart pagination with ellipsis
15 | const startPage = Math.max(1, currentPage - 2);
16 | const endPage = Math.min(totalPages, currentPage + 2);
17 |
18 | // Always show first page
19 | if (startPage > 1) {
20 | pages.push(1);
21 | if (startPage > 2) {
22 | pages.push('...');
23 | }
24 | }
25 |
26 | // Show pages around current page
27 | for (let i = startPage; i <= endPage; i++) {
28 | pages.push(i);
29 | }
30 |
31 | // Always show last page
32 | if (endPage < totalPages) {
33 | if (endPage < totalPages - 1) {
34 | pages.push('...');
35 | }
36 | pages.push(totalPages);
37 | }
38 | }
39 |
40 | return pages;
41 | };
42 |
43 | const handlePageClick = (page) => {
44 | if (page !== '...' && page !== currentPage) {
45 | onPageChange(page);
46 | }
47 | };
48 |
49 | return (
50 |
51 |
58 |
59 |
60 | {getPageNumbers().map((page, index) => (
61 |
69 | ))}
70 |
71 |
72 |
79 |
80 | );
81 | }
--------------------------------------------------------------------------------
/frontend/src/components/Login.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { auth } from '../services/api';
3 | import logoTransparent from '../assets/logotransparent.png';
4 |
5 | export default function Login({ onLogin }) {
6 | const [credentials, setCredentials] = useState({ username: '', password: '' });
7 | const [rememberMe, setRememberMe] = useState(false);
8 | const [loading, setLoading] = useState(false);
9 | const [error, setError] = useState('');
10 |
11 | const handleSubmit = async (e) => {
12 | e.preventDefault();
13 | setLoading(true);
14 | setError('');
15 |
16 | try {
17 | await auth.login(credentials.username, credentials.password, rememberMe);
18 | onLogin();
19 | } catch {
20 | setError('Invalid username or password');
21 | } finally {
22 | setLoading(false);
23 | }
24 | };
25 |
26 | return (
27 |
28 |
29 |
30 |

31 |
Seasonarr
32 |
33 |
Sign in to your account
34 |
68 |
69 |
70 | );
71 | }
--------------------------------------------------------------------------------
/frontend/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
3 | import { auth } from './services/api';
4 | import Login from './components/Login';
5 | import Register from './components/Register';
6 | import Dashboard from './components/Dashboard';
7 | import ShowDetail from './components/ShowDetail';
8 | import Settings from './components/Settings';
9 | import Activity from './components/Activity';
10 | import './App.css';
11 |
12 | function App() {
13 | const [isAuthenticated, setIsAuthenticated] = useState(false);
14 | const [isFirstRun, setIsFirstRun] = useState(false);
15 | const [loading, setLoading] = useState(true);
16 |
17 | useEffect(() => {
18 | checkFirstRunAndAuth();
19 | }, []);
20 |
21 | const checkFirstRunAndAuth = async () => {
22 | try {
23 | const firstRunResponse = await auth.checkFirstRun();
24 | setIsFirstRun(firstRunResponse.data.is_first_run);
25 |
26 | if (!firstRunResponse.data.is_first_run && auth.isAuthenticated()) {
27 | try {
28 | await auth.getMe();
29 | setIsAuthenticated(true);
30 | } catch {
31 | auth.logout();
32 | setIsAuthenticated(false);
33 | }
34 | }
35 | } catch (error) {
36 | console.error('Error checking first run status:', error);
37 | }
38 | setLoading(false);
39 | };
40 |
41 | const handleLogin = () => {
42 | setIsAuthenticated(true);
43 | };
44 |
45 | const handleRegister = () => {
46 | setIsFirstRun(false);
47 | setIsAuthenticated(true);
48 | };
49 |
50 | const handleLogout = () => {
51 | auth.logout();
52 | setIsAuthenticated(false);
53 | };
54 |
55 | if (loading) {
56 | return Loading...
;
57 | }
58 |
59 | if (isFirstRun) {
60 | return ;
61 | }
62 |
63 | return (
64 |
65 |
66 | {isAuthenticated ? (
67 |
68 | } />
69 | } />
70 | } />
71 | } />
72 | } />
73 |
74 | ) : (
75 |
76 | )}
77 |
78 |
79 | );
80 | }
81 |
82 | export default App;
83 |
--------------------------------------------------------------------------------
/backend/auth.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime, timedelta
2 | from typing import Optional
3 | from jose import JWTError, jwt
4 | from passlib.context import CryptContext
5 | from sqlalchemy.orm import Session
6 | from fastapi import Depends, HTTPException, status
7 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8 | import os
9 | from database import get_db
10 | from models import User
11 |
12 | SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
13 | ALGORITHM = "HS256"
14 | ACCESS_TOKEN_EXPIRE_MINUTES = 30
15 |
16 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
17 | security = HTTPBearer()
18 |
19 | def verify_password(plain_password, hashed_password):
20 | return pwd_context.verify(plain_password, hashed_password)
21 |
22 | def get_password_hash(password):
23 | return pwd_context.hash(password)
24 |
25 | def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
26 | to_encode = data.copy()
27 | if expires_delta:
28 | expire = datetime.utcnow() + expires_delta
29 | else:
30 | expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
31 | to_encode.update({"exp": expire})
32 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
33 | return encoded_jwt
34 |
35 | def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
36 | try:
37 | payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
38 | username: str = payload.get("sub")
39 | if username is None:
40 | raise HTTPException(
41 | status_code=status.HTTP_401_UNAUTHORIZED,
42 | detail="Could not validate credentials"
43 | )
44 | return username
45 | except JWTError:
46 | raise HTTPException(
47 | status_code=status.HTTP_401_UNAUTHORIZED,
48 | detail="Could not validate credentials"
49 | )
50 |
51 | def authenticate_user(db: Session, username: str, password: str):
52 | user = db.query(User).filter(User.username == username).first()
53 | if not user:
54 | return False
55 | if not verify_password(password, user.hashed_password):
56 | return False
57 | return user
58 |
59 | def get_current_user(username: str = Depends(verify_token), db: Session = Depends(get_db)):
60 | user = db.query(User).filter(User.username == username).first()
61 | if user is None:
62 | raise HTTPException(
63 | status_code=status.HTTP_401_UNAUTHORIZED,
64 | detail="User not found"
65 | )
66 | return user
--------------------------------------------------------------------------------
/frontend/src/components/Activity.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { sonarr, auth } from '../services/api';
4 | import ActivityHistory from './ActivityHistory';
5 | import SonarrSelector from './SonarrSelector';
6 | import logoTransparent from '../assets/logotransparent.png';
7 |
8 | export default function Activity() {
9 | const navigate = useNavigate();
10 | const [instances, setInstances] = useState([]);
11 | const [selectedInstance, setSelectedInstance] = useState(null);
12 | const [loading, setLoading] = useState(false);
13 |
14 | useEffect(() => {
15 | loadInstances();
16 | }, []);
17 |
18 | const loadInstances = async () => {
19 | setLoading(true);
20 | try {
21 | const response = await sonarr.getInstances();
22 | setInstances(response.data);
23 | if (response.data.length > 0) {
24 | setSelectedInstance(response.data[0]);
25 | }
26 | } catch (error) {
27 | console.error('Error loading instances:', error);
28 | } finally {
29 | setLoading(false);
30 | }
31 | };
32 |
33 | const handleInstanceChange = (instance) => {
34 | setSelectedInstance(instance);
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 | navigate('/')} style={{cursor: 'pointer'}}>
42 |

43 |
Seasonarr
44 |
45 |
46 |
51 |
57 |
63 |
69 |
70 |
71 |
72 |
73 |
Activity History
74 | {loading ? (
75 |
Loading instances...
76 | ) : (
77 |
78 | )}
79 |
80 |
81 |
82 | );
83 | }
--------------------------------------------------------------------------------
/frontend/src/components/Register.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { auth } from '../services/api';
3 | import logoTransparent from '../assets/logotransparent.png';
4 |
5 | export default function Register({ onRegister }) {
6 | const [credentials, setCredentials] = useState({ username: '', password: '', confirmPassword: '' });
7 | const [loading, setLoading] = useState(false);
8 | const [error, setError] = useState('');
9 |
10 | const handleSubmit = async (e) => {
11 | e.preventDefault();
12 | setLoading(true);
13 | setError('');
14 |
15 | if (credentials.password !== credentials.confirmPassword) {
16 | setError('Passwords do not match');
17 | setLoading(false);
18 | return;
19 | }
20 |
21 | if (credentials.password.length < 4) {
22 | setError('Password must be at least 4 characters');
23 | setLoading(false);
24 | return;
25 | }
26 |
27 | try {
28 | await auth.register(credentials.username, credentials.password);
29 | onRegister();
30 | } catch (err) {
31 | setError(err.response?.data?.detail || 'Registration failed');
32 | } finally {
33 | setLoading(false);
34 | }
35 | };
36 |
37 | return (
38 |
39 |
40 |
41 |

42 |
Welcome to Seasonarr
43 |
44 |
Create your admin account to get started
45 |
78 |
79 |
80 | );
81 | }
--------------------------------------------------------------------------------
/backend/schemas.py:
--------------------------------------------------------------------------------
1 | from pydantic import BaseModel, HttpUrl
2 | from typing import Optional, List, Dict, Any
3 | from datetime import datetime
4 |
5 | class UserLogin(BaseModel):
6 | username: str
7 | password: str
8 | remember_me: Optional[bool] = False
9 |
10 | class UserRegister(BaseModel):
11 | username: str
12 | password: str
13 |
14 | class UserResponse(BaseModel):
15 | id: int
16 | username: str
17 | created_at: datetime
18 |
19 | class Config:
20 | from_attributes = True
21 |
22 | class SonarrInstanceCreate(BaseModel):
23 | name: str
24 | url: str
25 | api_key: str
26 |
27 | class SonarrInstanceUpdate(BaseModel):
28 | name: Optional[str] = None
29 | url: Optional[str] = None
30 | api_key: Optional[str] = None
31 | is_active: Optional[bool] = None
32 |
33 | class SonarrInstanceResponse(BaseModel):
34 | id: int
35 | name: str
36 | url: str
37 | is_active: bool
38 | created_at: datetime
39 |
40 | class Config:
41 | from_attributes = True
42 |
43 | class ShowResponse(BaseModel):
44 | id: int
45 | title: str
46 | year: Optional[int] = None
47 | poster_url: Optional[str] = None
48 | status: str
49 | monitored: bool
50 | episode_count: int
51 | missing_episode_count: int
52 | seasons: List[dict] = []
53 | network: Optional[str] = None
54 | genres: List[str] = []
55 | runtime: Optional[int] = None
56 | certification: Optional[str] = None
57 |
58 | class SeasonItRequest(BaseModel):
59 | show_id: int
60 | season_number: Optional[int] = None
61 | instance_id: int
62 |
63 | class SearchSeasonPacksRequest(BaseModel):
64 | show_id: int
65 | season_number: int
66 | instance_id: int
67 |
68 | class DownloadReleaseRequest(BaseModel):
69 | release_guid: str
70 | show_id: int
71 | season_number: int
72 | instance_id: int
73 | indexer_id: int
74 |
75 | class ReleaseResponse(BaseModel):
76 | guid: str
77 | title: str
78 | size: int
79 | size_formatted: str
80 | seeders: int
81 | leechers: int
82 | age: int
83 | age_formatted: str
84 | quality: str
85 | quality_score: int
86 | indexer: str
87 | approved: bool
88 | indexer_flags: List[str]
89 | release_weight: int
90 |
91 | class ProgressUpdate(BaseModel):
92 | message: str
93 | progress: int
94 | status: str
95 |
96 | class UserSettingsResponse(BaseModel):
97 | disable_season_pack_check: bool
98 | require_deletion_confirmation: bool
99 | skip_episode_deletion: bool
100 | shows_per_page: int
101 | default_sort: str
102 | default_show_missing_only: bool
103 |
104 | class Config:
105 | from_attributes = True
106 |
107 | class UserSettingsUpdate(BaseModel):
108 | disable_season_pack_check: Optional[bool] = None
109 | require_deletion_confirmation: Optional[bool] = None
110 | skip_episode_deletion: Optional[bool] = None
111 | shows_per_page: Optional[int] = None
112 | default_sort: Optional[str] = None
113 | default_show_missing_only: Optional[bool] = None
114 |
115 | class NotificationCreate(BaseModel):
116 | title: str
117 | message: str
118 | notification_type: str = "info"
119 | message_type: str
120 | priority: str = "normal"
121 | persistent: bool = False
122 | extra_data: Optional[Dict[str, Any]] = None
123 |
124 | class NotificationResponse(BaseModel):
125 | id: int
126 | title: str
127 | message: str
128 | notification_type: str
129 | message_type: str
130 | priority: str
131 | persistent: bool
132 | read: bool
133 | extra_data: Optional[Dict[str, Any]]
134 | created_at: datetime
135 | read_at: Optional[datetime]
136 |
137 | class Config:
138 | from_attributes = True
139 |
140 | class NotificationUpdate(BaseModel):
141 | read: Optional[bool] = None
142 |
143 | class ActivityLogResponse(BaseModel):
144 | id: int
145 | action_type: str
146 | show_id: int
147 | show_title: str
148 | season_number: Optional[int]
149 | status: str
150 | message: Optional[str]
151 | error_details: Optional[str]
152 | created_at: datetime
153 | completed_at: Optional[datetime]
154 |
155 | class Config:
156 | from_attributes = True
--------------------------------------------------------------------------------
/backend/cache.py:
--------------------------------------------------------------------------------
1 | import time
2 | import json
3 | import logging
4 | from typing import Any, Optional
5 | from datetime import datetime, timedelta
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 | class SimpleCache:
10 | """Simple in-memory cache with TTL support"""
11 |
12 | def __init__(self):
13 | self._cache = {}
14 | self._last_cleanup = time.time()
15 | self._cleanup_interval = 300 # Cleanup every 5 minutes
16 |
17 | def get(self, key: str) -> Optional[Any]:
18 | """Get value from cache"""
19 | self._cleanup_expired()
20 |
21 | if key in self._cache:
22 | entry = self._cache[key]
23 | if entry['expires_at'] > time.time():
24 | logger.debug(f"Cache hit for key: {key}")
25 | return entry['data']
26 | else:
27 | # Expired entry
28 | del self._cache[key]
29 | logger.debug(f"Cache expired for key: {key}")
30 |
31 | logger.debug(f"Cache miss for key: {key}")
32 | return None
33 |
34 | def set(self, key: str, value: Any, ttl: int = 1800) -> None:
35 | """Set value in cache with TTL in seconds (default 30 minutes)"""
36 | self._cache[key] = {
37 | 'data': value,
38 | 'expires_at': time.time() + ttl,
39 | 'created_at': time.time()
40 | }
41 | logger.debug(f"Cache set for key: {key}, TTL: {ttl}s")
42 |
43 | def delete(self, key: str) -> bool:
44 | """Delete key from cache"""
45 | if key in self._cache:
46 | del self._cache[key]
47 | logger.debug(f"Cache deleted for key: {key}")
48 | return True
49 | return False
50 |
51 | def clear(self) -> None:
52 | """Clear all cache entries"""
53 | self._cache.clear()
54 | logger.info("Cache cleared")
55 |
56 | def _cleanup_expired(self) -> None:
57 | """Remove expired entries"""
58 | current_time = time.time()
59 |
60 | # Only cleanup if enough time has passed
61 | if current_time - self._last_cleanup < self._cleanup_interval:
62 | return
63 |
64 | expired_keys = []
65 | for key, entry in self._cache.items():
66 | if entry['expires_at'] <= current_time:
67 | expired_keys.append(key)
68 |
69 | for key in expired_keys:
70 | del self._cache[key]
71 |
72 | if expired_keys:
73 | logger.info(f"Cleaned up {len(expired_keys)} expired cache entries")
74 |
75 | self._last_cleanup = current_time
76 |
77 | def stats(self) -> dict:
78 | """Get cache statistics"""
79 | self._cleanup_expired()
80 |
81 | total_entries = len(self._cache)
82 | total_size = sum(len(json.dumps(entry['data'], default=str)) for entry in self._cache.values())
83 |
84 | return {
85 | 'total_entries': total_entries,
86 | 'total_size_bytes': total_size,
87 | 'cache_keys': list(self._cache.keys())
88 | }
89 |
90 | # Global cache instance
91 | cache = SimpleCache()
92 |
93 | def get_cache_key(prefix: str, *args) -> str:
94 | """Generate a consistent cache key"""
95 | key_parts = [str(prefix)] + [str(arg) for arg in args]
96 | return ":".join(key_parts)
97 |
98 | def cache_result(key: str, ttl: int = 1800):
99 | """Decorator to cache function results"""
100 | def decorator(func):
101 | def wrapper(*args, **kwargs):
102 | cached_value = cache.get(key)
103 | if cached_value is not None:
104 | return cached_value
105 |
106 | result = func(*args, **kwargs)
107 | cache.set(key, result, ttl)
108 | return result
109 | return wrapper
110 | return decorator
111 |
112 | async def async_cache_result(key: str, ttl: int = 1800):
113 | """Decorator to cache async function results"""
114 | def decorator(func):
115 | async def wrapper(*args, **kwargs):
116 | cached_value = cache.get(key)
117 | if cached_value is not None:
118 | return cached_value
119 |
120 | result = await func(*args, **kwargs)
121 | cache.set(key, result, ttl)
122 | return result
123 | return wrapper
124 | return decorator
--------------------------------------------------------------------------------
/frontend/src/components/AddSonarrModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { sonarr } from '../services/api';
3 |
4 | export default function AddSonarrModal({ isOpen, onClose, onSuccess }) {
5 | const [formData, setFormData] = useState({
6 | name: '',
7 | url: '',
8 | api_key: ''
9 | });
10 | const [loading, setLoading] = useState(false);
11 | const [error, setError] = useState('');
12 | const [testingConnection, setTestingConnection] = useState(false);
13 | const [testResult, setTestResult] = useState(null);
14 |
15 | const handleTestConnection = async () => {
16 | if (!formData.url.trim() || !formData.api_key.trim()) {
17 | setError('URL and API key are required for testing');
18 | return;
19 | }
20 |
21 | setTestingConnection(true);
22 | setError('');
23 | setTestResult(null);
24 |
25 | try {
26 | const response = await sonarr.testConnection({
27 | name: formData.name || 'Test Instance',
28 | url: formData.url,
29 | api_key: formData.api_key
30 | });
31 |
32 | if (response.data.success) {
33 | setTestResult({ success: true, message: 'Connection successful!' });
34 | } else {
35 | setTestResult({ success: false, message: 'Connection failed' });
36 | }
37 | } catch (err) {
38 | console.error('Error testing connection:', err);
39 | setTestResult({ success: false, message: 'Connection test failed' });
40 | } finally {
41 | setTestingConnection(false);
42 | // Clear test result after 5 seconds
43 | setTimeout(() => setTestResult(null), 5000);
44 | }
45 | };
46 |
47 | const handleSubmit = async (e) => {
48 | e.preventDefault();
49 | setLoading(true);
50 | setError('');
51 |
52 | try {
53 | await sonarr.createInstance(formData);
54 | onSuccess();
55 | onClose();
56 | setFormData({ name: '', url: '', api_key: '' });
57 | } catch (err) {
58 | setError(err.response?.data?.detail || 'Failed to add Sonarr instance');
59 | } finally {
60 | setLoading(false);
61 | }
62 | };
63 |
64 | if (!isOpen) return null;
65 |
66 | return (
67 |
68 |
e.stopPropagation()}>
69 |
70 |
Add Sonarr Instance
71 |
72 |
73 |
74 |
133 |
134 |
135 | );
136 | }
--------------------------------------------------------------------------------
/backend/models.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Boolean, Text, JSON, Index
2 | from sqlalchemy.orm import relationship
3 | from sqlalchemy.sql import func
4 | from database import Base
5 |
6 | class User(Base):
7 | __tablename__ = "users"
8 |
9 | id = Column(Integer, primary_key=True, index=True)
10 | username = Column(String, unique=True, index=True, nullable=False)
11 | hashed_password = Column(String, nullable=False)
12 | created_at = Column(DateTime(timezone=True), server_default=func.now())
13 |
14 | sonarr_instances = relationship("SonarrInstance", back_populates="owner")
15 | settings = relationship("UserSettings", back_populates="user", uselist=False)
16 |
17 | class SonarrInstance(Base):
18 | __tablename__ = "sonarr_instances"
19 |
20 | id = Column(Integer, primary_key=True, index=True)
21 | name = Column(String, nullable=False)
22 | url = Column(String, nullable=False)
23 | api_key = Column(String, nullable=False)
24 | owner_id = Column(Integer, ForeignKey("users.id"), index=True)
25 | is_active = Column(Boolean, default=True, index=True)
26 | created_at = Column(DateTime(timezone=True), server_default=func.now())
27 |
28 | owner = relationship("User", back_populates="sonarr_instances")
29 |
30 | __table_args__ = (
31 | Index('ix_sonarr_instances_owner_active', 'owner_id', 'is_active'),
32 | )
33 |
34 | class UserSettings(Base):
35 | __tablename__ = "user_settings"
36 |
37 | id = Column(Integer, primary_key=True, index=True)
38 | user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
39 | disable_season_pack_check = Column(Boolean, default=False)
40 | require_deletion_confirmation = Column(Boolean, default=False)
41 | skip_episode_deletion = Column(Boolean, default=False)
42 | shows_per_page = Column(Integer, default=35)
43 | default_sort = Column(String, default='title_asc')
44 | default_show_missing_only = Column(Boolean, default=True)
45 | created_at = Column(DateTime(timezone=True), server_default=func.now())
46 | updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
47 |
48 | user = relationship("User", back_populates="settings")
49 |
50 | class Notification(Base):
51 | __tablename__ = "notifications"
52 |
53 | id = Column(Integer, primary_key=True, index=True)
54 | user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
55 | title = Column(String, nullable=False)
56 | message = Column(Text, nullable=False)
57 | notification_type = Column(String, default="info") # info, success, warning, error
58 | message_type = Column(String, nullable=False) # notification, system_announcement, show_update, etc.
59 | priority = Column(String, default="normal") # low, normal, high
60 | persistent = Column(Boolean, default=False)
61 | read = Column(Boolean, default=False, index=True)
62 | extra_data = Column(JSON, nullable=True) # Additional data specific to notification type
63 | created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
64 | read_at = Column(DateTime(timezone=True), nullable=True)
65 |
66 | user = relationship("User", backref="notifications")
67 |
68 | __table_args__ = (
69 | Index('ix_notifications_user_read', 'user_id', 'read'),
70 | Index('ix_notifications_user_created', 'user_id', 'created_at'),
71 | )
72 |
73 | class ActivityLog(Base):
74 | __tablename__ = "activity_logs"
75 |
76 | id = Column(Integer, primary_key=True, index=True)
77 | user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
78 | instance_id = Column(Integer, ForeignKey("sonarr_instances.id"), nullable=False, index=True)
79 | action_type = Column(String, nullable=False, index=True) # season_it, manual_search, etc.
80 | show_id = Column(Integer, nullable=False)
81 | show_title = Column(String, nullable=False)
82 | season_number = Column(Integer, nullable=True) # null for all seasons
83 | status = Column(String, nullable=False, index=True) # success, error, in_progress
84 | message = Column(Text, nullable=True)
85 | error_details = Column(Text, nullable=True)
86 | extra_data = Column(JSON, nullable=True) # Additional data
87 | created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
88 | completed_at = Column(DateTime(timezone=True), nullable=True)
89 |
90 | user = relationship("User", backref="activity_logs")
91 | instance = relationship("SonarrInstance", backref="activity_logs")
92 |
93 | __table_args__ = (
94 | Index('ix_activity_logs_user_created', 'user_id', 'created_at'),
95 | Index('ix_activity_logs_user_instance', 'user_id', 'instance_id'),
96 | Index('ix_activity_logs_status_created', 'status', 'created_at'),
97 | )
--------------------------------------------------------------------------------
/frontend/src/services/api.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | const API_BASE_URL = '/api';
4 |
5 | const api = axios.create({
6 | baseURL: API_BASE_URL,
7 | });
8 |
9 | api.interceptors.request.use((config) => {
10 | const token = localStorage.getItem('token');
11 | if (token) {
12 | config.headers.Authorization = `Bearer ${token}`;
13 | }
14 | return config;
15 | });
16 |
17 | api.interceptors.response.use(
18 | (response) => response,
19 | (error) => {
20 | if (error.response?.status === 401) {
21 | localStorage.removeItem('token');
22 | window.location.href = '/login';
23 | }
24 | return Promise.reject(error);
25 | }
26 | );
27 |
28 | export const auth = {
29 | checkFirstRun: () => api.get('/setup/first-run'),
30 |
31 | register: async (username, password) => {
32 | const response = await api.post('/setup/register', { username, password });
33 | const { access_token } = response.data;
34 | localStorage.setItem('token', access_token);
35 | return response.data;
36 | },
37 |
38 | login: async (username, password, rememberMe = false) => {
39 | const response = await api.post('/login', { username, password, remember_me: rememberMe });
40 | const { access_token } = response.data;
41 | localStorage.setItem('token', access_token);
42 | return response.data;
43 | },
44 |
45 | logout: () => {
46 | localStorage.removeItem('token');
47 | },
48 |
49 | getMe: () => api.get('/me'),
50 |
51 | isAuthenticated: () => !!localStorage.getItem('token'),
52 | };
53 |
54 | export const sonarr = {
55 | getInstances: () => api.get('/sonarr'),
56 | createInstance: (data) => api.post('/sonarr', data),
57 | updateInstance: (id, data) => api.put(`/sonarr/${id}`, data),
58 | deleteInstance: (id) => api.delete(`/sonarr/${id}`),
59 | testConnection: (data) => api.post('/sonarr/test-connection', data),
60 | testExistingConnection: (id) => api.post(`/sonarr/${id}/test-connection`),
61 | getShows: (instanceId, page = 1, pageSize = 36, filters = {}) => {
62 | const params = new URLSearchParams({
63 | instance_id: instanceId,
64 | page,
65 | page_size: pageSize,
66 | });
67 |
68 | if (filters.search) params.append('search', filters.search);
69 | if (filters.status) params.append('status', filters.status);
70 | if (filters.monitored !== undefined) params.append('monitored', filters.monitored);
71 | if (filters.missing_episodes !== undefined) params.append('missing_episodes', filters.missing_episodes);
72 | if (filters.network) params.append('network', filters.network);
73 | if (filters.genres && filters.genres.length > 0) {
74 | filters.genres.forEach(genre => params.append('genres', genre));
75 | }
76 | if (filters.year_from !== undefined && filters.year_from !== '') params.append('year_from', filters.year_from);
77 | if (filters.year_to !== undefined && filters.year_to !== '') params.append('year_to', filters.year_to);
78 | if (filters.runtime_min !== undefined && filters.runtime_min !== '') params.append('runtime_min', filters.runtime_min);
79 | if (filters.runtime_max !== undefined && filters.runtime_max !== '') params.append('runtime_max', filters.runtime_max);
80 | if (filters.certification) params.append('certification', filters.certification);
81 |
82 | return api.get(`/shows?${params}`);
83 | },
84 | getFilterOptions: (instanceId) => {
85 | const params = new URLSearchParams({
86 | instance_id: instanceId
87 | });
88 | return api.get(`/shows/filter-options?${params}`);
89 | },
90 | getShowDetail: (showId, instanceId) => {
91 | const params = new URLSearchParams({
92 | instance_id: instanceId
93 | });
94 | return api.get(`/shows/${showId}?${params}`);
95 | },
96 | seasonIt: (showId, seasonNumber = null, instanceId = null) =>
97 | api.post('/season-it', { show_id: showId, season_number: seasonNumber, instance_id: instanceId }),
98 | searchSeasonPacks: (showId, seasonNumber, instanceId, signal) =>
99 | api.post('/search-season-packs', { show_id: showId, season_number: seasonNumber, instance_id: instanceId }, { signal }),
100 | downloadRelease: (releaseGuid, showId, seasonNumber, instanceId, indexerId) =>
101 | api.post('/download-release', { release_guid: releaseGuid, show_id: showId, season_number: seasonNumber, instance_id: instanceId, indexer_id: indexerId }),
102 | getActivityLogs: (instanceId = null, page = 1, pageSize = 20) => {
103 | const params = new URLSearchParams({
104 | page,
105 | page_size: pageSize
106 | });
107 | if (instanceId) params.append('instance_id', instanceId);
108 | return api.get(`/activity-logs?${params}`);
109 | },
110 | };
111 |
112 | export const settings = {
113 | getSettings: () => api.get('/settings'),
114 | updateSettings: (settingsData) => api.put('/settings', settingsData),
115 | purgeDatabase: () => api.delete('/purge-database'),
116 | };
117 |
118 | export default api;
--------------------------------------------------------------------------------
/frontend/src/components/ShowCard.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, memo, useCallback } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { sonarr, settings } from '../services/api';
4 |
5 | const ShowCard = memo(function ShowCard({ show, instanceId, isSelected, onSelectionChange, bulkMode, onEnterBulkMode }) {
6 | const navigate = useNavigate();
7 | const [isVisible, setIsVisible] = useState(false);
8 | const [imageLoaded, setImageLoaded] = useState(false);
9 | const imgRef = useRef(null);
10 |
11 | useEffect(() => {
12 | const observer = new IntersectionObserver(
13 | ([entry]) => {
14 | if (entry.isIntersecting) {
15 | setIsVisible(true);
16 | observer.disconnect();
17 | }
18 | },
19 | { threshold: 0.1 }
20 | );
21 |
22 | if (imgRef.current) {
23 | observer.observe(imgRef.current);
24 | }
25 |
26 | return () => observer.disconnect();
27 | }, []);
28 |
29 | const getStatusColor = (status) => {
30 | switch (status.toLowerCase()) {
31 | case 'continuing':
32 | return '#4CAF50';
33 | case 'ended':
34 | return '#f44336';
35 | default:
36 | return '#ff9800';
37 | }
38 | };
39 |
40 | const handleSeasonIt = useCallback(async (seasonNumber = null) => {
41 | try {
42 | // Check user settings for deletion confirmation
43 | const userSettings = await settings.getSettings();
44 | const requireConfirmation = userSettings.data.require_deletion_confirmation;
45 | const skipDeletion = userSettings.data.skip_episode_deletion;
46 |
47 | if (requireConfirmation && !skipDeletion) {
48 | const confirmMessage = seasonNumber
49 | ? `Are you sure you want to delete existing episodes from Season ${seasonNumber} of "${show.title}" and download the season pack?`
50 | : `Are you sure you want to delete existing episodes from all seasons of "${show.title}" and download season packs?`;
51 |
52 | if (!window.confirm(confirmMessage)) {
53 | return;
54 | }
55 | }
56 |
57 | await sonarr.seasonIt(show.id, seasonNumber, instanceId);
58 | } catch (error) {
59 | console.error('Season It failed:', error);
60 | }
61 | }, [show.id, show.title, instanceId]);
62 |
63 | const handleCardClick = useCallback(() => {
64 | if (bulkMode) {
65 | onSelectionChange(show.id);
66 | } else {
67 | // Store the instance ID in sessionStorage so ShowDetail can access it
68 | sessionStorage.setItem('selectedInstanceId', instanceId);
69 | navigate(`/show/${show.id}`);
70 | }
71 | }, [bulkMode, onSelectionChange, show.id, instanceId, navigate]);
72 |
73 | const handleCheckboxChange = useCallback((e) => {
74 | e.stopPropagation();
75 | if (!bulkMode) {
76 | onEnterBulkMode();
77 | }
78 | onSelectionChange(show.id);
79 | }, [bulkMode, onEnterBulkMode, onSelectionChange, show.id]);
80 |
81 | return (
82 |
83 |
84 | e.stopPropagation()}
89 | />
90 |
91 |
92 | {show.poster_url && isVisible ? (
93 |

setImageLoaded(true)}
97 | onError={() => setImageLoaded(true)}
98 | style={{
99 | opacity: imageLoaded ? 1 : 0,
100 | transition: 'opacity 0.3s ease'
101 | }}
102 | />
103 | ) : show.poster_url && !isVisible ? (
104 |
Loading...
105 | ) : (
106 |
No Image
107 | )}
108 |
109 |
110 |
111 |
112 |
{show.title} {show.year && `(${show.year})`}
113 |
114 |
115 |
116 | {show.status}
117 |
118 | {show.missing_episode_count === 0 ? (
119 | Seasoned
120 | ) : (
121 | Unseasoned
122 | )}
123 |
124 |
125 |
126 | Episodes: {show.episode_count}
127 | Missing: {show.missing_episode_count}
128 |
129 |
130 |
131 |
132 | {show.missing_episode_count > 0 && show.seasons && show.seasons.some(season =>
133 | season.missing_episode_count > 0 && season.monitored && !season.has_future_episodes
134 | ) && (
135 |
144 | )}
145 |
146 |
147 |
148 | );
149 | });
150 |
151 | export default ShowCard;
--------------------------------------------------------------------------------
/frontend/src/components/ActivityHistory.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { sonarr } from '../services/api';
3 |
4 | export default function ActivityHistory({ selectedInstance }) {
5 | const [activities, setActivities] = useState([]);
6 | const [loading, setLoading] = useState(false);
7 | const [page, setPage] = useState(1);
8 | const [hasMore, setHasMore] = useState(true);
9 |
10 | useEffect(() => {
11 | if (selectedInstance) {
12 | loadActivities();
13 | }
14 | }, [selectedInstance]);
15 |
16 | const loadActivities = async (pageNum = 1) => {
17 | if (!selectedInstance) return;
18 |
19 | setLoading(true);
20 | try {
21 | const response = await sonarr.getActivityLogs(selectedInstance.id, pageNum, 10);
22 | const newActivities = response.data;
23 |
24 | if (pageNum === 1) {
25 | setActivities(newActivities);
26 | } else {
27 | setActivities(prev => [...prev, ...newActivities]);
28 | }
29 |
30 | setHasMore(newActivities.length === 10);
31 | setPage(pageNum);
32 | } catch (error) {
33 | console.error('Error loading activities:', error);
34 | } finally {
35 | setLoading(false);
36 | }
37 | };
38 |
39 | const loadMore = () => {
40 | if (!loading && hasMore) {
41 | loadActivities(page + 1);
42 | }
43 | };
44 |
45 | const formatDate = (dateString) => {
46 | const date = new Date(dateString);
47 | return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {
48 | hour: '2-digit',
49 | minute: '2-digit'
50 | });
51 | };
52 |
53 | const getStatusIcon = (status) => {
54 | switch (status) {
55 | case 'success':
56 | return '✅';
57 | case 'error':
58 | return '❌';
59 | case 'in_progress':
60 | return '⏳';
61 | default:
62 | return '📝';
63 | }
64 | };
65 |
66 | const getStatusColor = (status) => {
67 | switch (status) {
68 | case 'success':
69 | return '#4CAF50';
70 | case 'error':
71 | return '#f44336';
72 | case 'in_progress':
73 | return '#ff9800';
74 | default:
75 | return '#999';
76 | }
77 | };
78 |
79 | if (!selectedInstance) {
80 | return (
81 |
82 |
Activity History
83 |
Select a Sonarr instance to view activity history.
84 |
85 | );
86 | }
87 |
88 | return (
89 |
90 | {loading && activities.length === 0 ? (
91 |
92 |
93 |
Loading activities...
94 |
95 | ) : activities.length === 0 ? (
96 |
97 |
📋
98 |
No Recent Activity
99 |
Activities will appear here when you use the "Season It!" feature.
100 |
Start by selecting shows with missing episodes and click "Season It!" to see your activity history.
101 |
102 | ) : (
103 |
104 | {activities.map((activity) => (
105 |
106 |
107 | {getStatusIcon(activity.status)}
108 |
109 |
110 |
111 |
112 |
113 |
{activity.show_title}
114 | {activity.season_number && (
115 | Season {activity.season_number}
116 | )}
117 |
118 |
119 | {formatDate(activity.created_at)}
120 |
121 |
122 |
123 |
124 |
{activity.message}
125 |
126 |
127 |
128 |
129 |
133 | {activity.status.toUpperCase()}
134 |
135 | {activity.completed_at && (
136 |
137 | Completed: {formatDate(activity.completed_at)}
138 |
139 | )}
140 |
141 |
142 |
143 | {activity.error_details && (
144 |
145 |
⚠️
146 |
147 |
Error Details:
148 |
{activity.error_details}
149 |
150 |
151 | )}
152 |
153 |
154 | ))}
155 |
156 | {hasMore && (
157 |
158 |
175 |
176 | )}
177 |
178 | )}
179 |
180 | );
181 | }
--------------------------------------------------------------------------------
/frontend/src/hooks/useWebSocket.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef, useCallback } from 'react';
2 |
3 | const INITIAL_RECONNECT_DELAY = 1000; // 1 second
4 | const MAX_RECONNECT_DELAY = 30000; // 30 seconds
5 | const RECONNECT_MULTIPLIER = 1.5;
6 | const MAX_RECONNECT_ATTEMPTS = 10;
7 |
8 | export function useWebSocket(userId) {
9 | const [connectionStatus, setConnectionStatus] = useState('disconnected');
10 | const [lastMessage, setLastMessage] = useState(null);
11 | const [reconnectAttempts, setReconnectAttempts] = useState(0);
12 |
13 | const wsRef = useRef(null);
14 | const reconnectTimeoutRef = useRef(null);
15 | const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY);
16 | const messageHandlersRef = useRef(new Map());
17 | const isManuallyClosedRef = useRef(false);
18 |
19 | // Get token from localStorage
20 | const getAuthToken = useCallback(() => {
21 | return localStorage.getItem('token');
22 | }, []);
23 |
24 | // Calculate reconnect delay with exponential backoff
25 | const getReconnectDelay = useCallback(() => {
26 | const delay = Math.min(
27 | reconnectDelayRef.current * Math.pow(RECONNECT_MULTIPLIER, reconnectAttempts),
28 | MAX_RECONNECT_DELAY
29 | );
30 | return delay + Math.random() * 1000; // Add jitter
31 | }, [reconnectAttempts]);
32 |
33 | // Connect to WebSocket
34 | const connect = useCallback(() => {
35 | if (!userId) return;
36 |
37 | const token = getAuthToken();
38 | if (!token) {
39 | console.warn('No auth token available for WebSocket connection');
40 | setConnectionStatus('error');
41 | return;
42 | }
43 |
44 | try {
45 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
46 | const wsUrl = `${protocol}//${window.location.host}/ws/${userId}?token=${encodeURIComponent(token)}`;
47 |
48 | setConnectionStatus('connecting');
49 | wsRef.current = new WebSocket(wsUrl);
50 |
51 | wsRef.current.onopen = () => {
52 | setConnectionStatus('connected');
53 | setReconnectAttempts(0);
54 | reconnectDelayRef.current = INITIAL_RECONNECT_DELAY;
55 | };
56 |
57 | wsRef.current.onmessage = (event) => {
58 | try {
59 | const data = JSON.parse(event.data);
60 | setLastMessage(data);
61 |
62 | // Handle ping messages
63 | if (data.type === 'ping') {
64 | // Send pong response
65 | if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
66 | wsRef.current.send(JSON.stringify({
67 | type: 'pong',
68 | timestamp: data.timestamp
69 | }));
70 | }
71 | return;
72 | }
73 |
74 | // Call registered message handlers
75 | const handlers = messageHandlersRef.current.get(data.type) || [];
76 | handlers.forEach(handler => {
77 | try {
78 | handler(data);
79 | } catch (error) {
80 | console.error('Error in message handler:', error);
81 | }
82 | });
83 |
84 | } catch (error) {
85 | console.error('Error parsing WebSocket message:', error);
86 | }
87 | };
88 |
89 | wsRef.current.onclose = (event) => {
90 | setConnectionStatus('disconnected');
91 |
92 | // Only attempt reconnection if not manually closed and within attempt limits
93 | if (!isManuallyClosedRef.current && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
94 | const delay = getReconnectDelay();
95 |
96 | setConnectionStatus('reconnecting');
97 | reconnectTimeoutRef.current = setTimeout(() => {
98 | setReconnectAttempts(prev => prev + 1);
99 | connect();
100 | }, delay);
101 | } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
102 | console.error('Max reconnection attempts reached');
103 | setConnectionStatus('error');
104 | }
105 | };
106 |
107 | wsRef.current.onerror = (error) => {
108 | console.error('WebSocket error:', error);
109 | setConnectionStatus('error');
110 | };
111 |
112 | } catch (error) {
113 | console.error('Error creating WebSocket connection:', error);
114 | setConnectionStatus('error');
115 | }
116 | }, [userId, getAuthToken, reconnectAttempts, getReconnectDelay]);
117 |
118 | // Disconnect WebSocket
119 | const disconnect = useCallback(() => {
120 | isManuallyClosedRef.current = true;
121 |
122 | if (reconnectTimeoutRef.current) {
123 | clearTimeout(reconnectTimeoutRef.current);
124 | reconnectTimeoutRef.current = null;
125 | }
126 |
127 | if (wsRef.current) {
128 | wsRef.current.close();
129 | wsRef.current = null;
130 | }
131 |
132 | setConnectionStatus('disconnected');
133 | }, []);
134 |
135 | // Send message
136 | const sendMessage = useCallback((message) => {
137 | if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
138 | wsRef.current.send(JSON.stringify(message));
139 | return true;
140 | }
141 | console.warn('WebSocket not connected, cannot send message');
142 | return false;
143 | }, []);
144 |
145 | // Register message handler
146 | const addMessageHandler = useCallback((messageType, handler) => {
147 | if (!messageHandlersRef.current.has(messageType)) {
148 | messageHandlersRef.current.set(messageType, []);
149 | }
150 | messageHandlersRef.current.get(messageType).push(handler);
151 |
152 | // Return cleanup function
153 | return () => {
154 | const handlers = messageHandlersRef.current.get(messageType);
155 | if (handlers) {
156 | const index = handlers.indexOf(handler);
157 | if (index > -1) {
158 | handlers.splice(index, 1);
159 | }
160 | if (handlers.length === 0) {
161 | messageHandlersRef.current.delete(messageType);
162 | }
163 | }
164 | };
165 | }, []);
166 |
167 | // Manual reconnect
168 | const reconnect = useCallback(() => {
169 | setReconnectAttempts(0);
170 | reconnectDelayRef.current = INITIAL_RECONNECT_DELAY;
171 | isManuallyClosedRef.current = false;
172 |
173 | if (wsRef.current) {
174 | wsRef.current.close();
175 | }
176 |
177 | setTimeout(connect, 100);
178 | }, [connect]);
179 |
180 | // Initialize connection
181 | useEffect(() => {
182 | if (userId) {
183 | isManuallyClosedRef.current = false;
184 | connect();
185 | }
186 |
187 | return () => {
188 | disconnect();
189 | };
190 | }, [userId, connect, disconnect]);
191 |
192 | // Cleanup on unmount
193 | useEffect(() => {
194 | return () => {
195 | if (reconnectTimeoutRef.current) {
196 | clearTimeout(reconnectTimeoutRef.current);
197 | }
198 | };
199 | }, []);
200 |
201 | return {
202 | connectionStatus,
203 | lastMessage,
204 | sendMessage,
205 | addMessageHandler,
206 | reconnect,
207 | disconnect,
208 | reconnectAttempts,
209 | isConnected: connectionStatus === 'connected'
210 | };
211 | }
212 |
213 | export default useWebSocket;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 | [](https://ghcr.io/d3v1l1989/seasonarr)
5 | [](https://github.com/d3v1l1989/seasonarr/releases)
6 | [](https://github.com/d3v1l1989/seasonarr/blob/main/LICENSE)
7 | [](https://github.com/d3v1l1989/seasonarr/stargazers)
8 | [](https://github.com/d3v1l1989/seasonarr/issues)
9 |
10 | An intelligent Sonarr companion that automatically finds and downloads season packs for your TV shows, replacing individual episodes with high-quality complete seasons.
11 |
12 |
13 | ## Features
14 |
15 | - **Automated Replacement**: Safely deletes existing episodes before downloading season packs
16 | - **Interactive Search**: Manual search and selection of season packs with quality scoring
17 | - **Real-time Progress**: Live WebSocket updates showing search and download progress
18 | - **User Authentication**: Secure access control with JWT-based authentication
19 | - **Multiple Sonarr Instances**: Individual Sonarr instance management
20 | - **Modern UI**: Clean, responsive interface with dark theme
21 | - **Mobile Friendly**: Works seamlessly on desktop and mobile devices
22 | - **Bulk Operations**: Process multiple shows or seasons simultaneously
23 | - **Activity Logging**: Comprehensive history of all operations and status
24 | - **Smart Notifications**: Real-time notifications for operations and updates
25 |
26 | ## Screenshots
27 |
28 | ### Main Dashboard
29 | 
30 | *Library overview with show grid, statistics, search functionality, and quick "Season It!" actions*
31 |
32 | ### Advanced Filtering
33 | 
34 | *Comprehensive filtering options including genres, networks, year ranges, and runtime filters*
35 |
36 | ### Real-time Progress
37 | 
38 | *Live progress updates during seasoning 🧂*
39 |
40 | ### Show Details
41 | 
42 | *Detailed show information with season breakdown and bulk "Season It All!" functionality*
43 |
44 | ## Prerequisites
45 |
46 | - **Docker** and **Docker Compose** installed
47 | - **Sonarr** instance(s) running and accessible
48 | - Network access between Seasonarr and Sonarr instances
49 |
50 | ## Quick Start
51 |
52 | ### Method 1: Docker Compose (Recommended)
53 |
54 | 1. **Create a docker-compose.yml file** (replace `sonarrNetwork` with your existing Docker network name):
55 | ```yaml
56 | services:
57 | seasonarr:
58 | image: ghcr.io/d3v1l1989/seasonarr:latest
59 | container_name: seasonarr
60 | restart: unless-stopped
61 | hostname: seasonarr
62 | ports:
63 | - "8000:8000"
64 | environment:
65 | - PUID=1000
66 | - PGID=1000
67 | - TZ=Etc/UTC
68 | - DATABASE_URL=sqlite:///./data/seasonarr.db
69 | - JWT_SECRET_KEY=change-this-to-a-secure-random-string
70 | volumes:
71 | - seasonarr_data:/app/data
72 | - /etc/localtime:/etc/localtime:ro
73 | networks:
74 | - sonarrNetwork
75 |
76 | volumes:
77 | seasonarr_data:
78 |
79 | networks:
80 | sonarrNetwork:
81 | external: true
82 | ```
83 |
84 | 2. **Start the application**:
85 | ```bash
86 | docker compose up -d
87 | ```
88 |
89 | 3. **Access Seasonarr**:
90 | - Open your browser to: `http://localhost:8000`
91 | - Complete the first-time setup to create your admin account
92 |
93 | ### Method 2: Standalone Docker
94 |
95 | ```bash
96 | # Create a data volume
97 | docker volume create seasonarr_data
98 |
99 | # Run the container
100 | docker run -d \
101 | --name seasonarr \
102 | -p 8000:8000 \
103 | -v seasonarr_data:/app/data \
104 | -e JWT_SECRET_KEY=your-secret-key-here \
105 | -e DATABASE_URL=sqlite:///./data/seasonarr.db \
106 | --restart unless-stopped \
107 | ghcr.io/d3v1l1989/seasonarr:latest
108 | ```
109 |
110 |
111 | ### First-Time Setup
112 |
113 | 1. **Access the application** at `http://localhost:8000`
114 | 2. **Create admin account** on the first-run setup page
115 | 3. **Add Sonarr instance(s)**:
116 | - Click the Sonarr selector dropdown
117 | - Add your Sonarr details:
118 | - **Name**: Friendly name (e.g., "Main Sonarr")
119 | - **URL**: Full Sonarr URL (e.g., `http://192.168.1.100:8989`)
120 | - **API Key**: Found in Sonarr Settings → General → Security
121 |
122 | ## Usage
123 |
124 | ### Basic Operations
125 |
126 | 1. **Browse Shows**: View all shows from your connected Sonarr instance(s)
127 | 2. **Season It!**: Click the season button to automatically process individual seasons
128 | 3. **Interactive Search**: Click the search button to manually browse and select season packs
129 | 4. **Season It All!**: Click the show button to process all monitored seasons
130 | 5. **Bulk Operations**: Use the bulk selector to process multiple items
131 |
132 | ### The "Season It!" Process
133 |
134 | 1. **Validation**: Confirms show has monitored seasons with missing episodes (skips incomplete seasons)
135 | 2. **User Confirmation**: Optional deletion confirmation dialog based on user settings
136 | 3. **Search**: Queries Sonarr for available season pack releases
137 | 4. **Availability Check**: Checks if there are available season packs before doing any episode deletions
138 | 5. **Safe Deletion**: Removes existing episode files (unless "Skip Episode Deletion" is enabled)
139 | 6. **Download**: Instructs Sonarr to search for and download the season pack
140 | 7. **Monitor**: Tracks progress with real-time WebSocket updates and poster display
141 |
142 | ### Advanced Features
143 |
144 | - **Smart Filtering**: Automatic detection of legitimate vs. fake releases
145 | - **Progress Tracking**: Real-time WebSocket updates with detailed progress
146 | - **Activity History**: Complete log of all operations and their outcomes
147 | - **Notification System**: In-app notifications for important events
148 | - **User Settings**: Customizable preferences and defaults
149 |
150 | ## License
151 |
152 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
153 |
154 | ## Support
155 |
156 | - **Issues**: Report bugs and feature requests on GitHub Issues
157 | - **Discussions**: Join the community discussions for help and ideas
158 |
159 | ### ☕ Enjoying Seasonarr?
160 |
161 | If you're finding this project useful and want to show some love, feel free to buy me a coffee! It helps keep the development going.
162 |
163 | [](https://ko-fi.com/d3v1l1989)
164 |
165 | ## Disclaimer
166 |
167 | This tool is designed to work with your existing legal media library. Users are responsible for ensuring compliance with applicable laws and terms of service of their indexers and download clients.
--------------------------------------------------------------------------------
/frontend/src/components/SearchResultsModal.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import { sonarr } from '../services/api';
3 |
4 | export default function SearchResultsModal({ show, seasonNumber, instanceId, onClose }) {
5 | const [releases, setReleases] = useState([]);
6 | const [loading, setLoading] = useState(true);
7 | const [error, setError] = useState(null);
8 | const [downloading, setDownloading] = useState(null);
9 | const abortControllerRef = useRef(null);
10 |
11 | // Auto-start search when modal opens
12 | useEffect(() => {
13 | handleSearch();
14 |
15 | // Cleanup function to cancel search when component unmounts
16 | return () => {
17 | if (abortControllerRef.current) {
18 | abortControllerRef.current.abort();
19 | }
20 | };
21 | }, []);
22 |
23 | const handleSearch = async () => {
24 | // Cancel any ongoing search
25 | if (abortControllerRef.current) {
26 | abortControllerRef.current.abort();
27 | }
28 |
29 | // Create new AbortController for this search
30 | abortControllerRef.current = new AbortController();
31 |
32 | setLoading(true);
33 | setError(null);
34 | try {
35 | const response = await sonarr.searchSeasonPacks(show.id, seasonNumber, instanceId, abortControllerRef.current.signal);
36 | setReleases(response.data.releases);
37 | } catch (err) {
38 | // Don't show error if the request was cancelled
39 | if (err.name === 'AbortError' || err.name === 'CanceledError') {
40 | console.log('Search cancelled by user');
41 | return;
42 | }
43 | console.error('Search failed:', err);
44 | setError('Failed to search for releases. Please try again.');
45 | } finally {
46 | setLoading(false);
47 | }
48 | };
49 |
50 | const handleDownload = async (release) => {
51 | setDownloading(release.guid);
52 | try {
53 | await sonarr.downloadRelease(release.guid, show.id, seasonNumber, instanceId, release.indexer_id);
54 | onClose();
55 | } catch (err) {
56 | console.error('Download failed:', err);
57 | setError('Failed to download release. Please try again.');
58 | } finally {
59 | setDownloading(null);
60 | }
61 | };
62 |
63 | const getQualityColor = (quality) => {
64 | // Color based on resolution
65 | if (quality.includes('2160p') || quality.includes('4K')) return '#9C27B0';
66 | if (quality.includes('1080p')) return '#4CAF50';
67 | if (quality.includes('720p')) return '#FF9800';
68 | if (quality.includes('480p')) return '#F44336';
69 | return '#757575';
70 | };
71 |
72 | const getQualityScoreColor = (score) => {
73 | // Color based on quality score (higher = better)
74 | if (score >= 80) return '#4CAF50'; // Green for high quality
75 | if (score >= 60) return '#FF9800'; // Orange for medium quality
76 | if (score >= 40) return '#F44336'; // Red for lower quality
77 | return '#757575'; // Gray for unknown/very low
78 | };
79 |
80 | const getSeedersColor = (seeders) => {
81 | if (seeders >= 10) return '#4CAF50';
82 | if (seeders >= 5) return '#FF9800';
83 | return '#F44336';
84 | };
85 |
86 | return (
87 |
88 |
e.stopPropagation()}>
89 |
90 |
91 | {/* Show banner */}
92 |
98 |
99 |
100 |

{
105 | e.target.style.display = 'none';
106 | }}
107 | />
108 |
109 |
{show.title}
110 |
Season {seasonNumber}
111 | {show.network &&
{show.network}}
112 |
113 |
114 |
115 |
116 |
117 |
118 | {loading && (
119 |
120 |
121 |
Searching for season packs...
122 |
123 | )}
124 |
125 | {error && (
126 |
127 |
{error}
128 |
131 |
132 | )}
133 |
134 | {!loading && !error && releases.length === 0 && (
135 |
136 |
No season packs found for this season.
137 |
140 |
141 | )}
142 |
143 | {releases.length > 0 && (
144 |
145 |
146 |
Found {releases.length} Season Pack{releases.length !== 1 ? 's' : ''}
147 |
148 |
149 |
150 | {releases.map((release) => (
151 |
152 |
153 |
154 |
{release.title}
155 |
156 |
160 | {release.quality}
161 |
162 |
166 | Score: {release.quality_score}
167 |
168 | {release.indexer}
169 |
170 |
171 |
172 |
173 |
174 | Size:
175 | {release.size_formatted}
176 |
177 |
178 | Seeds:
179 |
183 | {release.seeders}
184 |
185 |
186 |
187 | Peers:
188 | {release.leechers}
189 |
190 |
191 | Age:
192 | {release.age_formatted}
193 |
194 |
195 |
196 |
197 |
198 |
205 |
206 |
207 | ))}
208 |
209 |
210 | )}
211 |
212 |
213 |
214 | );
215 | }
--------------------------------------------------------------------------------
/backend/bulk_operation_manager.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import uuid
4 | from typing import Dict, List, Optional, Any, Callable
5 | from datetime import datetime
6 | from sqlalchemy.orm import Session
7 | from websocket_manager import manager
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 | class BulkOperationManager:
12 | def __init__(self):
13 | self.active_operations: Dict[str, 'BulkOperation'] = {}
14 | self.operation_history: List[Dict] = []
15 | self.max_history_size = 100
16 |
17 | def create_operation(self, user_id: int, operation_type: str, items: List[Dict],
18 | operation_func: Callable, description: str = None) -> str:
19 | """Create and register a new bulk operation"""
20 | operation_id = str(uuid.uuid4())
21 | operation = BulkOperation(
22 | operation_id=operation_id,
23 | user_id=user_id,
24 | operation_type=operation_type,
25 | items=items,
26 | operation_func=operation_func,
27 | description=description
28 | )
29 |
30 | self.active_operations[operation_id] = operation
31 | logger.info(f"Created bulk operation {operation_id} for user {user_id}: {operation_type}")
32 | return operation_id
33 |
34 | async def execute_operation(self, operation_id: str) -> Dict[str, Any]:
35 | """Execute a bulk operation"""
36 | if operation_id not in self.active_operations:
37 | raise ValueError(f"Operation {operation_id} not found")
38 |
39 | operation = self.active_operations[operation_id]
40 |
41 | try:
42 | result = await operation.execute()
43 |
44 | # Move to history
45 | self._move_to_history(operation)
46 |
47 | return result
48 | except Exception as e:
49 | logger.error(f"Error executing bulk operation {operation_id}: {e}")
50 | operation.mark_failed(str(e))
51 | self._move_to_history(operation)
52 | raise
53 |
54 | def cancel_operation(self, operation_id: str) -> bool:
55 | """Cancel an active bulk operation"""
56 | if operation_id not in self.active_operations:
57 | return False
58 |
59 | operation = self.active_operations[operation_id]
60 | operation.cancel()
61 | self._move_to_history(operation)
62 | return True
63 |
64 | def get_operation_status(self, operation_id: str) -> Optional[Dict]:
65 | """Get the current status of an operation"""
66 | if operation_id in self.active_operations:
67 | return self.active_operations[operation_id].get_status()
68 |
69 | # Check history
70 | for op in self.operation_history:
71 | if op['operation_id'] == operation_id:
72 | return op
73 |
74 | return None
75 |
76 | def get_user_operations(self, user_id: int) -> List[Dict]:
77 | """Get all operations for a user (active + recent history)"""
78 | operations = []
79 |
80 | # Active operations
81 | for op in self.active_operations.values():
82 | if op.user_id == user_id:
83 | operations.append(op.get_status())
84 |
85 | # Historical operations
86 | for op in self.operation_history:
87 | if op['user_id'] == user_id:
88 | operations.append(op)
89 |
90 | return sorted(operations, key=lambda x: x['created_at'], reverse=True)
91 |
92 | def _move_to_history(self, operation: 'BulkOperation'):
93 | """Move an operation to history and clean up active operations"""
94 | if operation.operation_id in self.active_operations:
95 | del self.active_operations[operation.operation_id]
96 |
97 | # Add to history
98 | self.operation_history.append(operation.get_status())
99 |
100 | # Trim history if too large
101 | if len(self.operation_history) > self.max_history_size:
102 | self.operation_history = self.operation_history[-self.max_history_size:]
103 |
104 | class BulkOperation:
105 | def __init__(self, operation_id: str, user_id: int, operation_type: str,
106 | items: List[Dict], operation_func: Callable, description: str = None):
107 | self.operation_id = operation_id
108 | self.user_id = user_id
109 | self.operation_type = operation_type
110 | self.items = items
111 | self.operation_func = operation_func
112 | self.description = description
113 |
114 | self.status = "pending"
115 | self.created_at = datetime.now()
116 | self.started_at = None
117 | self.completed_at = None
118 | self.cancelled = False
119 | self.error = None
120 |
121 | self.current_item = 0
122 | self.completed_items = []
123 | self.failed_items = []
124 |
125 | # Progress tracking
126 | self.overall_progress = 0
127 | self.current_item_progress = 0
128 |
129 | async def execute(self) -> Dict[str, Any]:
130 | """Execute the bulk operation"""
131 | if self.cancelled:
132 | raise Exception("Operation was cancelled")
133 |
134 | self.status = "running"
135 | self.started_at = datetime.now()
136 |
137 | try:
138 | # Send start notification
139 | await manager.send_bulk_operation_start(
140 | self.user_id,
141 | self.operation_id,
142 | self.operation_type,
143 | len(self.items),
144 | [item.get('name', f"Item {i+1}") for i, item in enumerate(self.items)],
145 | self.description
146 | )
147 |
148 | # Process each item
149 | for i, item in enumerate(self.items):
150 | if self.cancelled:
151 | self.status = "cancelled"
152 | break
153 |
154 | self.current_item = i + 1
155 | self.current_item_progress = 0
156 | item_name = item.get('name', f"Item {i+1}")
157 |
158 | # Update progress
159 | self.overall_progress = int((i / len(self.items)) * 100)
160 | poster_url = item.get('poster_url')
161 | await self._send_progress_update(item_name, poster_url=poster_url)
162 |
163 | try:
164 | # Execute operation for this item
165 | result = await self.operation_func(item, self._progress_callback)
166 |
167 | self.completed_items.append({
168 | 'name': item_name,
169 | 'id': item.get('id'),
170 | 'result': result
171 | })
172 |
173 | except Exception as e:
174 | logger.error(f"Error processing item {item_name}: {e}")
175 | self.failed_items.append({
176 | 'name': item_name,
177 | 'id': item.get('id'),
178 | 'error': str(e)
179 | })
180 |
181 | # Final progress update
182 | if not self.cancelled:
183 | self.overall_progress = 100
184 | self.status = "completed"
185 | self.completed_at = datetime.now()
186 |
187 | await manager.send_bulk_operation_complete(
188 | self.user_id,
189 | self.operation_id,
190 | self.operation_type,
191 | len(self.items),
192 | self.completed_items,
193 | self.failed_items
194 | )
195 |
196 | return {
197 | 'operation_id': self.operation_id,
198 | 'status': self.status,
199 | 'total_items': len(self.items),
200 | 'completed_items': self.completed_items,
201 | 'failed_items': self.failed_items,
202 | 'cancelled': self.cancelled
203 | }
204 |
205 | except Exception as e:
206 | self.mark_failed(str(e))
207 | raise
208 |
209 | async def _progress_callback(self, progress: int, message: str = None, poster_url: str = None):
210 | """Callback for individual item progress updates"""
211 | # Don't send progress updates if operation is cancelled
212 | if self.cancelled:
213 | return
214 |
215 | self.current_item_progress = progress
216 | item = self.items[self.current_item - 1]
217 | item_name = item.get('name') or item.get('title') or f"Item {self.current_item}"
218 | await self._send_progress_update(
219 | item_name,
220 | message,
221 | poster_url
222 | )
223 |
224 | async def _send_progress_update(self, item_name: str, message: str = None, poster_url: str = None):
225 | """Send progress update to WebSocket"""
226 | await manager.send_bulk_operation_update(
227 | self.user_id,
228 | self.operation_id,
229 | self.operation_type,
230 | self.overall_progress,
231 | self.current_item,
232 | len(self.items),
233 | item_name,
234 | self.current_item_progress,
235 | message,
236 | self.status,
237 | self.completed_items,
238 | self.failed_items,
239 | poster_url
240 | )
241 |
242 | def cancel(self):
243 | """Cancel the operation"""
244 | self.cancelled = True
245 | self.status = "cancelled"
246 | self.completed_at = datetime.now()
247 |
248 | def mark_failed(self, error: str):
249 | """Mark the operation as failed"""
250 | self.status = "failed"
251 | self.error = error
252 | self.completed_at = datetime.now()
253 |
254 | def get_status(self) -> Dict[str, Any]:
255 | """Get the current status of the operation"""
256 | return {
257 | 'operation_id': self.operation_id,
258 | 'user_id': self.user_id,
259 | 'operation_type': self.operation_type,
260 | 'status': self.status,
261 | 'created_at': self.created_at.isoformat(),
262 | 'started_at': self.started_at.isoformat() if self.started_at else None,
263 | 'completed_at': self.completed_at.isoformat() if self.completed_at else None,
264 | 'description': self.description,
265 | 'total_items': len(self.items),
266 | 'current_item': self.current_item,
267 | 'overall_progress': self.overall_progress,
268 | 'current_item_progress': self.current_item_progress,
269 | 'completed_items': self.completed_items,
270 | 'failed_items': self.failed_items,
271 | 'cancelled': self.cancelled,
272 | 'error': self.error
273 | }
274 |
275 | # Global instance
276 | bulk_operation_manager = BulkOperationManager()
--------------------------------------------------------------------------------
/frontend/src/components/SonarrInstanceManager.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { sonarr } from '../services/api';
3 |
4 | export default function SonarrInstanceManager() {
5 | const [instances, setInstances] = useState([]);
6 | const [loading, setLoading] = useState(true);
7 | const [editingInstance, setEditingInstance] = useState(null);
8 | const [testingConnection, setTestingConnection] = useState(null);
9 | const [message, setMessage] = useState('');
10 | const [messageType, setMessageType] = useState('info');
11 | const [deleteConfirm, setDeleteConfirm] = useState(null);
12 |
13 | useEffect(() => {
14 | loadInstances();
15 | }, []);
16 |
17 | const loadInstances = async () => {
18 | try {
19 | const response = await sonarr.getInstances();
20 | setInstances(response.data);
21 | } catch (error) {
22 | console.error('Error loading instances:', error);
23 | setMessage('Error loading Sonarr instances');
24 | } finally {
25 | setLoading(false);
26 | }
27 | };
28 |
29 | const handleEdit = (instance) => {
30 | setEditingInstance({
31 | id: instance.id,
32 | name: instance.name,
33 | url: instance.url,
34 | api_key: '', // Don't pre-fill API key for security
35 | originalInstance: instance
36 | });
37 | setMessage('');
38 | setMessageType('info');
39 | };
40 |
41 | const handleTestConnection = async (instance) => {
42 | if (!instance.name.trim() || !instance.url.trim() || !instance.api_key.trim()) {
43 | setMessage('Name, URL, and API key are required for testing');
44 | return;
45 | }
46 |
47 | setTestingConnection(instance.id || 'editing');
48 | setMessage('');
49 |
50 | try {
51 | const response = await sonarr.testConnection({
52 | name: instance.name,
53 | url: instance.url,
54 | api_key: instance.api_key
55 | });
56 |
57 | if (response.data.success) {
58 | setMessage('Connection successful!');
59 | setMessageType('success');
60 | } else {
61 | setMessage('Connection failed');
62 | setMessageType('error');
63 | }
64 | } catch (error) {
65 | console.error('Error testing connection:', error);
66 | setMessage('Connection test failed');
67 | setMessageType('error');
68 | } finally {
69 | setTestingConnection(null);
70 | setTimeout(() => {
71 | setMessage('');
72 | setMessageType('info');
73 | }, 5000);
74 | }
75 | };
76 |
77 | const handleTestExistingConnection = async (instance) => {
78 | setTestingConnection(instance.id);
79 | setMessage('');
80 |
81 | try {
82 | const response = await sonarr.testExistingConnection(instance.id);
83 |
84 | if (response.data.success) {
85 | setMessage('Connection successful!');
86 | setMessageType('success');
87 | } else {
88 | setMessage('Connection failed');
89 | setMessageType('error');
90 | }
91 | } catch (error) {
92 | console.error('Error testing connection:', error);
93 | setMessage('Connection test failed');
94 | setMessageType('error');
95 | } finally {
96 | setTestingConnection(null);
97 | setTimeout(() => {
98 | setMessage('');
99 | setMessageType('info');
100 | }, 5000);
101 | }
102 | };
103 |
104 | const handleCancelEdit = () => {
105 | setEditingInstance(null);
106 | setMessage('');
107 | setMessageType('info');
108 | };
109 |
110 | const handleSaveEdit = async () => {
111 | if (!editingInstance.name.trim() || !editingInstance.url.trim()) {
112 | setMessage('Name and URL are required');
113 | setMessageType('error');
114 | return;
115 | }
116 |
117 | try {
118 | const updateData = {
119 | name: editingInstance.name,
120 | url: editingInstance.url
121 | };
122 |
123 | // Only include API key if it was changed
124 | if (editingInstance.api_key.trim()) {
125 | updateData.api_key = editingInstance.api_key;
126 | }
127 |
128 | await sonarr.updateInstance(editingInstance.id, updateData);
129 | setMessage('Instance updated successfully!');
130 | setMessageType('success');
131 | setEditingInstance(null);
132 | loadInstances();
133 | setTimeout(() => {
134 | setMessage('');
135 | setMessageType('info');
136 | }, 3000);
137 | } catch (error) {
138 | console.error('Error updating instance:', error);
139 | setMessage(error.response?.data?.detail || 'Error updating instance');
140 | setMessageType('error');
141 | }
142 | };
143 |
144 | const handleDelete = (instance) => {
145 | setDeleteConfirm(instance);
146 | };
147 |
148 | const confirmDelete = async () => {
149 | if (!deleteConfirm) return;
150 |
151 | try {
152 | await sonarr.deleteInstance(deleteConfirm.id);
153 | setMessage('Instance deleted successfully!');
154 | setMessageType('success');
155 | loadInstances();
156 | setTimeout(() => {
157 | setMessage('');
158 | setMessageType('info');
159 | }, 3000);
160 | } catch (error) {
161 | console.error('Error deleting instance:', error);
162 | setMessage('Error deleting instance');
163 | setMessageType('error');
164 | } finally {
165 | setDeleteConfirm(null);
166 | }
167 | };
168 |
169 | const cancelDelete = () => {
170 | setDeleteConfirm(null);
171 | };
172 |
173 | if (loading) {
174 | return Loading Sonarr instances...
;
175 | }
176 |
177 | return (
178 |
179 |
Sonarr Instances
180 |
Manage your Sonarr server connections
181 |
182 | {message && (
183 |
184 | {message}
185 |
186 | )}
187 |
188 |
189 | {instances.length === 0 ? (
190 |
No Sonarr instances configured
191 | ) : (
192 | instances.map((instance) => (
193 |
194 | {editingInstance?.id === instance.id ? (
195 |
196 |
197 |
198 | setEditingInstance({...editingInstance, name: e.target.value})}
202 | className="form-input"
203 | />
204 |
205 |
206 |
207 | setEditingInstance({...editingInstance, url: e.target.value})}
211 | className="form-input"
212 | />
213 |
214 |
215 |
216 | setEditingInstance({...editingInstance, api_key: e.target.value})}
220 | className="form-input"
221 | placeholder="Enter new API key or leave blank"
222 | />
223 |
224 |
225 |
231 |
238 |
244 |
245 |
246 | ) : (
247 |
248 |
249 |
{instance.name}
250 |
251 |
257 |
263 |
269 |
270 |
271 |
272 |
URL: {instance.url}
273 |
Created: {new Date(instance.created_at).toLocaleDateString()}
274 |
Status: {instance.is_active ? 'Active' : 'Inactive'}
275 |
276 |
277 | )}
278 |
279 | ))
280 | )}
281 |
282 |
283 | {/* Delete Confirmation Modal */}
284 | {deleteConfirm && (
285 |
286 |
287 |
288 |
Delete Instance
289 |
290 |
291 |
Are you sure you want to delete the Sonarr instance
292 |
{deleteConfirm.name}
293 |
294 |
295 |
301 |
307 |
308 |
309 |
310 | )}
311 |
312 | );
313 | }
--------------------------------------------------------------------------------
/frontend/src/components/Settings.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { settings } from '../services/api';
4 | import logoTransparent from '../assets/logotransparent.png';
5 | import SonarrInstanceManager from './SonarrInstanceManager';
6 |
7 | export default function Settings() {
8 | const navigate = useNavigate();
9 | const [userSettings, setUserSettings] = useState({
10 | disable_season_pack_check: false,
11 | require_deletion_confirmation: false,
12 | skip_episode_deletion: false,
13 | shows_per_page: 36,
14 | default_sort: 'title_asc',
15 | default_show_missing_only: true,
16 | });
17 | const [loading, setLoading] = useState(true);
18 | const [saving, setSaving] = useState(false);
19 | const [message, setMessage] = useState('');
20 | const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
21 | const [purging, setPurging] = useState(false);
22 |
23 | useEffect(() => {
24 | loadSettings();
25 | }, []);
26 |
27 | const loadSettings = async () => {
28 | try {
29 | const response = await settings.getSettings();
30 | setUserSettings(response.data);
31 | } catch (error) {
32 | console.error('Error loading settings:', error);
33 | } finally {
34 | setLoading(false);
35 | }
36 | };
37 |
38 | const saveSettings = async () => {
39 | setSaving(true);
40 | setMessage('');
41 |
42 | try {
43 | await settings.updateSettings(userSettings);
44 | setMessage('Settings saved successfully!');
45 | setTimeout(() => setMessage(''), 3000);
46 | } catch (error) {
47 | console.error('Error saving settings:', error);
48 | setMessage('Error saving settings');
49 | } finally {
50 | setSaving(false);
51 | }
52 | };
53 |
54 | const handleSettingChange = (key, value) => {
55 | setUserSettings(prev => ({
56 | ...prev,
57 | [key]: value
58 | }));
59 | };
60 |
61 | const handlePurgeDatabase = async () => {
62 | setPurging(true);
63 | setMessage('');
64 |
65 | try {
66 | await settings.purgeDatabase();
67 | setMessage('Database purged successfully! All your data has been reset.');
68 | setShowPurgeConfirm(false);
69 |
70 | // Reload settings to show defaults
71 | await loadSettings();
72 |
73 | // Redirect to home after a short delay
74 | setTimeout(() => {
75 | navigate('/');
76 | }, 2000);
77 | } catch (error) {
78 | console.error('Error purging database:', error);
79 | setMessage('Error purging database. Please try again.');
80 | } finally {
81 | setPurging(false);
82 | }
83 | };
84 |
85 | if (loading) {
86 | return Loading settings...
;
87 | }
88 |
89 | return (
90 |
91 |
92 |
104 |
105 |
106 | {/* Sonarr Instance Management */}
107 |
108 |
109 | {/* Season Pack Processing */}
110 |
111 |
Season Pack Processing
112 |
113 |
114 |
115 |
Skip checking for available season packs during "Season It!" process
116 |
117 |
118 | handleSettingChange('disable_season_pack_check', e.target.checked)}
122 | className="toggle-switch"
123 | />
124 |
125 |
126 |
127 |
128 |
129 |
130 |
Show confirmation dialog before deleting existing episodes
131 |
132 |
133 | handleSettingChange('require_deletion_confirmation', e.target.checked)}
137 | className="toggle-switch"
138 | />
139 |
140 |
141 |
142 |
143 |
144 |
145 |
Download season packs without deleting individual episodes first
146 |
147 |
148 | handleSettingChange('skip_episode_deletion', e.target.checked)}
152 | className="toggle-switch"
153 | />
154 |
155 |
156 |
157 |
158 | {/* Display Preferences */}
159 |
160 |
Display Preferences
161 |
162 |
163 |
164 |
Number of shows to display per page
165 |
166 |
167 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
Default sorting when loading shows
187 |
188 |
189 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
Show only shows with missing episodes by default
209 |
210 |
211 | handleSettingChange('default_show_missing_only', e.target.checked)}
215 | className="toggle-switch"
216 | />
217 |
218 |
219 |
220 |
221 |
222 | {/* Hard Reset Section */}
223 |
224 |
Danger Zone
225 |
226 |
227 |
228 |
Permanently delete all your data including Sonarr instances, settings, notifications, and activity logs. This action cannot be undone.
229 |
230 |
231 |
238 |
239 |
240 |
241 |
242 |
243 |
244 |
251 | {message && (
252 |
253 | {message}
254 |
255 | )}
256 |
257 |
258 |
259 | {/* Purge Confirmation Modal */}
260 | {showPurgeConfirm && (
261 |
262 |
263 |
Confirm Hard Reset
264 |
Are you sure you want to purge all your data?
265 |
This will permanently delete:
266 |
267 | - All Sonarr instances
268 | - All settings and preferences
269 | - All notifications
270 | - All activity logs
271 |
272 |
This action cannot be undone!
273 |
274 |
281 |
288 |
289 |
290 |
291 | )}
292 |
293 | );
294 | }
--------------------------------------------------------------------------------
/frontend/src/components/EnhancedProgressBar.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import useWebSocket from '../hooks/useWebSocket';
3 |
4 | export default function EnhancedProgressBar({ userId }) {
5 | const [currentOperation, setCurrentOperation] = useState(null);
6 | const [isExpanded, setIsExpanded] = useState(false);
7 | const [isVisible, setIsVisible] = useState(false);
8 | const { addMessageHandler } = useWebSocket(userId);
9 |
10 | useEffect(() => {
11 | if (!userId) return;
12 |
13 | const cleanupHandlers = [];
14 |
15 | // Handle legacy progress updates
16 | cleanupHandlers.push(
17 | addMessageHandler('progress_update', (data) => {
18 | setCurrentOperation({
19 | type: 'single',
20 | message: data.message,
21 | progress: data.progress,
22 | status: data.status,
23 | timestamp: data.timestamp
24 | });
25 | setIsVisible(true);
26 |
27 | if (data.status === 'success' || data.status === 'error' || data.status === 'warning') {
28 | setTimeout(() => {
29 | setIsVisible(false);
30 | setCurrentOperation(null);
31 | }, 3000);
32 | }
33 | })
34 | );
35 |
36 | // Handle enhanced progress updates (new detailed progress with poster)
37 | cleanupHandlers.push(
38 | addMessageHandler('enhanced_progress_update', (data) => {
39 | setCurrentOperation({
40 | type: 'enhanced',
41 | message: data.message,
42 | progress: data.progress,
43 | status: data.status,
44 | timestamp: data.timestamp,
45 | show_title: data.show_title,
46 | operation_type: data.operation_type,
47 | current_step: data.current_step,
48 | poster_url: data.details?.poster_url
49 | });
50 | setIsVisible(true);
51 |
52 | if (data.status === 'success' || data.status === 'error' || data.status === 'warning') {
53 | setTimeout(() => {
54 | setIsVisible(false);
55 | setCurrentOperation(null);
56 | }, 4000);
57 | }
58 | })
59 | );
60 |
61 | // Handle bulk operation start
62 | cleanupHandlers.push(
63 | addMessageHandler('bulk_operation_start', (data) => {
64 | setCurrentOperation({
65 | type: 'bulk',
66 | operation_id: data.operation_id,
67 | operation_type: data.operation_type,
68 | total_items: data.total_items,
69 | items: data.items,
70 | message: data.message,
71 | status: 'starting',
72 | overall_progress: 0,
73 | current_item: 0,
74 | current_item_name: '',
75 | current_item_progress: 0,
76 | completed_items: [],
77 | failed_items: [],
78 | timestamp: data.timestamp
79 | });
80 | setIsVisible(true);
81 | })
82 | );
83 |
84 | // Handle bulk operation updates
85 | cleanupHandlers.push(
86 | addMessageHandler('bulk_operation_update', (data) => {
87 | setCurrentOperation(prev => ({
88 | ...prev,
89 | overall_progress: data.overall_progress,
90 | current_item: data.current_item,
91 | current_item_name: data.current_item_name,
92 | current_item_progress: data.current_item_progress,
93 | message: data.message,
94 | status: data.status,
95 | completed_items: data.completed_items,
96 | failed_items: data.failed_items,
97 | poster_url: data.poster_url,
98 | timestamp: data.timestamp
99 | }));
100 | setIsVisible(true);
101 | })
102 | );
103 |
104 | // Handle bulk operation completion
105 | cleanupHandlers.push(
106 | addMessageHandler('bulk_operation_complete', (data) => {
107 | setCurrentOperation(prev => ({
108 | ...prev,
109 | overall_progress: 100,
110 | status: data.status,
111 | message: data.message,
112 | completed_items: data.completed_items,
113 | failed_items: data.failed_items,
114 | success_count: data.success_count,
115 | failure_count: data.failure_count,
116 | timestamp: data.timestamp
117 | }));
118 | setIsVisible(true);
119 |
120 | // Auto-hide after completion
121 | setTimeout(() => {
122 | setIsVisible(false);
123 | setCurrentOperation(null);
124 | setIsExpanded(false);
125 | }, 5000);
126 | })
127 | );
128 |
129 | // Handle clear progress (for cancellations)
130 | cleanupHandlers.push(
131 | addMessageHandler('clear_progress', (data) => {
132 | // Immediately clear the progress bar
133 | setIsVisible(false);
134 | setCurrentOperation(null);
135 |
136 | // Optional: Show a brief cancellation message
137 | if (data.message) {
138 | setCurrentOperation({
139 | type: 'cancelled',
140 | message: data.message,
141 | progress: 0,
142 | status: 'cancelled',
143 | timestamp: Date.now()
144 | });
145 | setIsVisible(true);
146 |
147 | // Clear the cancellation message after 2 seconds
148 | setTimeout(() => {
149 | setIsVisible(false);
150 | setCurrentOperation(null);
151 | }, 2000);
152 | }
153 | })
154 | );
155 |
156 | return () => {
157 | cleanupHandlers.forEach(cleanup => cleanup());
158 | };
159 | }, [userId, addMessageHandler]);
160 |
161 | const getStatusColor = (status) => {
162 | switch (status) {
163 | case 'success':
164 | return '#4CAF50';
165 | case 'error':
166 | case 'failed':
167 | return '#f44336';
168 | case 'warning':
169 | return '#ff9800';
170 | case 'cancelled':
171 | return '#9e9e9e';
172 | default:
173 | return '#2196F3';
174 | }
175 | };
176 |
177 | const getStatusIcon = (status) => {
178 | switch (status) {
179 | case 'success':
180 | return '✓';
181 | case 'error':
182 | case 'failed':
183 | return '✗';
184 | case 'warning':
185 | return '⚠';
186 | case 'cancelled':
187 | return '⏹';
188 | default:
189 | return '⟳';
190 | }
191 | };
192 |
193 | const handleCancel = async () => {
194 | if (currentOperation?.type === 'bulk' && currentOperation.operation_id) {
195 | try {
196 | const response = await fetch(`/api/operations/${currentOperation.operation_id}/cancel`, {
197 | method: 'POST',
198 | headers: {
199 | 'Authorization': `Bearer ${localStorage.getItem('token')}`,
200 | 'Content-Type': 'application/json'
201 | }
202 | });
203 |
204 | if (response.ok) {
205 | // Update UI to show cancellation
206 | setCurrentOperation(prev => ({
207 | ...prev,
208 | status: 'cancelled',
209 | message: 'Operation cancelled by user'
210 | }));
211 |
212 | // Auto-dismiss after 2 seconds
213 | setTimeout(() => {
214 | setIsVisible(false);
215 | setCurrentOperation(null);
216 | }, 2000);
217 | } else {
218 | console.error('Failed to cancel operation');
219 | }
220 | } catch (error) {
221 | console.error('Error cancelling operation:', error);
222 | }
223 | }
224 | };
225 |
226 | if (!isVisible || !currentOperation) {
227 | return null;
228 | }
229 |
230 | const isBulkOperation = currentOperation.type === 'bulk';
231 | const isEnhancedOperation = currentOperation.type === 'enhanced';
232 |
233 | return (
234 |
235 | {/* Background poster for enhanced operations and bulk operations */}
236 | {(isEnhancedOperation || isBulkOperation) && currentOperation.poster_url && (
237 |
238 |

243 |
244 |
245 | )}
246 |
247 |
248 |
249 |
250 |
254 | {getStatusIcon(currentOperation.status)}
255 |
256 |
{currentOperation.message}
257 |
258 | {isBulkOperation && (
259 |
260 |
267 | {currentOperation.status === 'running' && (
268 |
275 | )}
276 |
277 | )}
278 |
279 |
280 |
289 |
290 |
291 |
292 | {isBulkOperation ? currentOperation.overall_progress : currentOperation.progress}%
293 |
294 |
295 | {isBulkOperation && (
296 |
297 | {currentOperation.current_item}/{currentOperation.total_items}
298 |
299 | )}
300 |
301 |
302 |
303 |
304 | {isBulkOperation && isExpanded && (
305 |
306 |
307 |
Current Item
308 |
309 |
{currentOperation.current_item_name}
310 | {currentOperation.current_item_progress > 0 && (
311 |
312 |
321 |
322 | {currentOperation.current_item_progress}%
323 |
324 |
325 | )}
326 |
327 |
328 |
329 | {currentOperation.completed_items && currentOperation.completed_items.length > 0 && (
330 |
331 |
Completed ({currentOperation.completed_items.length})
332 |
333 | {currentOperation.completed_items.map((item, index) => (
334 |
335 | ✓
336 | {item.name}
337 |
338 | ))}
339 |
340 |
341 | )}
342 |
343 | {currentOperation.failed_items && currentOperation.failed_items.length > 0 && (
344 |
345 |
Failed ({currentOperation.failed_items.length})
346 |
347 | {currentOperation.failed_items.map((item, index) => (
348 |
349 | ✗
350 | {item.name}
351 | {item.error && (
352 |
353 | {item.error.length > 50 ? `${item.error.substring(0, 50)}...` : item.error}
354 |
355 | )}
356 |
357 | ))}
358 |
359 |
360 | )}
361 |
362 | {currentOperation.success_count !== undefined && currentOperation.failure_count !== undefined && (
363 |
364 |
365 |
366 | ✓ {currentOperation.success_count} successful
367 |
368 |
369 | ✗ {currentOperation.failure_count} failed
370 |
371 |
372 |
373 | )}
374 |
375 | )}
376 |
377 | );
378 | }
--------------------------------------------------------------------------------
/frontend/src/components/ShowDetail.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useParams, useNavigate } from 'react-router-dom';
3 | import { sonarr, settings, auth } from '../services/api';
4 | import EnhancedProgressBar from './EnhancedProgressBar';
5 | import SearchResultsModal from './SearchResultsModal';
6 | import logoTransparent from '../assets/logotransparent.png';
7 |
8 | export default function ShowDetail() {
9 | const { showId } = useParams();
10 | const navigate = useNavigate();
11 | const [show, setShow] = useState(null);
12 | const [loading, setLoading] = useState(true);
13 | const [error, setError] = useState(null);
14 | const [expandedSeasons, setExpandedSeasons] = useState(new Set());
15 | const [instanceId, setInstanceId] = useState(null);
16 | const [user, setUser] = useState(null);
17 | const [searchModalOpen, setSearchModalOpen] = useState(false);
18 | const [searchModalSeason, setSearchModalSeason] = useState(null);
19 |
20 | useEffect(() => {
21 | // Get instance ID from sessionStorage or URL params
22 | const storedInstanceId = sessionStorage.getItem('selectedInstanceId');
23 | if (storedInstanceId) {
24 | setInstanceId(parseInt(storedInstanceId));
25 | }
26 | loadUser();
27 | }, []);
28 |
29 | useEffect(() => {
30 | if (instanceId) {
31 | loadShowDetail();
32 | }
33 | }, [showId, instanceId]);
34 |
35 | const loadUser = async () => {
36 | try {
37 | const response = await auth.getMe();
38 | setUser(response.data);
39 | } catch (error) {
40 | console.error('Error loading user:', error);
41 | }
42 | };
43 |
44 | const loadShowDetail = async () => {
45 | try {
46 | setLoading(true);
47 | const response = await sonarr.getShowDetail(showId, instanceId);
48 | setShow(response.data);
49 | } catch (error) {
50 | console.error('Error loading show detail:', error);
51 | setError('Failed to load show details');
52 | } finally {
53 | setLoading(false);
54 | }
55 | };
56 |
57 | const getStatusColor = (status) => {
58 | switch (status.toLowerCase()) {
59 | case 'continuing':
60 | return '#4CAF50';
61 | case 'ended':
62 | return '#f44336';
63 | default:
64 | return '#ff9800';
65 | }
66 | };
67 |
68 | const toggleSeason = (seasonNumber) => {
69 | const newExpanded = new Set(expandedSeasons);
70 | if (newExpanded.has(seasonNumber)) {
71 | newExpanded.delete(seasonNumber);
72 | } else {
73 | newExpanded.add(seasonNumber);
74 | }
75 | setExpandedSeasons(newExpanded);
76 | };
77 |
78 | const handleSeasonIt = async (seasonNumber = null) => {
79 | try {
80 | // Check user settings for deletion confirmation
81 | const userSettings = await settings.getSettings();
82 | const requireConfirmation = userSettings.data.require_deletion_confirmation;
83 | const skipDeletion = userSettings.data.skip_episode_deletion;
84 |
85 | if (requireConfirmation && !skipDeletion) {
86 | const confirmMessage = seasonNumber
87 | ? `Are you sure you want to delete existing episodes from Season ${seasonNumber} of "${show.title}" and download the season pack?`
88 | : `Are you sure you want to delete existing episodes from all seasons of "${show.title}" and download season packs?`;
89 |
90 | if (!window.confirm(confirmMessage)) {
91 | return;
92 | }
93 | }
94 |
95 | await sonarr.seasonIt(show.id, seasonNumber, instanceId);
96 | } catch (error) {
97 | console.error('Season It failed:', error);
98 | }
99 | };
100 |
101 | const handleInteractiveSearch = (seasonNumber) => {
102 | setSearchModalSeason(seasonNumber);
103 | setSearchModalOpen(true);
104 | };
105 |
106 | const handleCloseSearchModal = () => {
107 | setSearchModalOpen(false);
108 | setSearchModalSeason(null);
109 | };
110 |
111 | const formatDate = (dateString) => {
112 | if (!dateString) return '';
113 | return new Date(dateString).toLocaleDateString();
114 | };
115 |
116 | const getEpisodeStatus = (episode) => {
117 | if (!episode.monitored) return 'unmonitored';
118 | if (episode.hasFile) return 'downloaded';
119 | return 'missing';
120 | };
121 |
122 | const getEpisodeStatusColor = (status) => {
123 | switch (status) {
124 | case 'downloaded':
125 | return '#4CAF50';
126 | case 'missing':
127 | return '#f44336';
128 | case 'unmonitored':
129 | return '#666';
130 | default:
131 | return '#ff9800';
132 | }
133 | };
134 |
135 | if (loading) {
136 | return (
137 |
138 |
Loading show details...
139 |
140 | );
141 | }
142 |
143 | if (error) {
144 | return (
145 |
146 |
{error}
147 |
150 |
151 | );
152 | }
153 |
154 | if (!show) {
155 | return (
156 |
157 |
Show not found
158 |
161 |
162 | );
163 | }
164 |
165 | return (
166 |
167 |
168 |
171 |
navigate('/')} style={{cursor: 'pointer'}}>
172 |

173 |
Show Details
174 |
175 |
176 |
177 |
178 |
179 |
180 | {show.poster_url ? (
181 |

182 | ) : (
183 |
No Image
184 | )}
185 |
186 |
187 |
188 |
189 | {show.title} {show.year && `(${show.year})`}
190 |
191 |
192 |
193 |
194 |
195 | {show.status}
196 |
197 | {show.monitored && Monitored}
198 |
199 |
200 |
201 |
202 | Episodes:
203 | {show.episode_count}
204 |
205 |
206 | Missing:
207 | {show.missing_episode_count}
208 |
209 | {show.network && (
210 |
211 | Network:
212 | {show.network}
213 |
214 | )}
215 | {show.runtime && (
216 |
217 | Runtime:
218 | {show.runtime} min
219 |
220 | )}
221 |
222 |
223 | {show.genres && show.genres.length > 0 && (
224 |
225 |
Genres:
226 |
227 | {show.genres.map((genre, index) => (
228 | {genre}
229 | ))}
230 |
231 |
232 | )}
233 |
234 |
235 | {show.overview && (
236 |
237 |
Overview
238 |
{show.overview}
239 |
240 | )}
241 |
242 |
243 | {show.missing_episode_count > 0 && show.seasons && show.seasons.some(season =>
244 | season.missing_episode_count > 0 && season.monitored && !season.has_future_episodes
245 | ) && (
246 |
252 | )}
253 |
254 |
255 |
256 |
257 |
258 |
Seasons
259 | {show.seasons
260 | .filter(season => season.seasonNumber > 0)
261 | .map((season) => (
262 |
263 |
toggleSeason(season.seasonNumber)}
266 | >
267 |
268 |
269 | {expandedSeasons.has(season.seasonNumber) ? '▼' : '▶'}
270 |
271 |
Season {season.seasonNumber}
272 |
273 |
274 |
275 |
276 | {season.episodeCount} episodes
277 |
278 | {season.missing_episode_count > 0 ? (
279 |
280 | {season.missing_episode_count} missing
281 |
282 | ) : (
283 |
Complete
284 | )}
285 | {!season.monitored && (
286 |
Not Monitored
287 | )}
288 |
289 |
290 |
291 | {season.missing_episode_count > 0 && season.monitored && !season.has_future_episodes && (
292 | <>
293 |
302 |
311 | >
312 | )}
313 | {season.has_future_episodes && (
314 |
315 | ⏳ Season incomplete
316 |
317 | )}
318 |
319 |
320 |
321 | {expandedSeasons.has(season.seasonNumber) && (
322 |
323 | {season.episodes
324 | .sort((a, b) => a.episodeNumber - b.episodeNumber)
325 | .map((episode) => (
326 |
327 |
328 | {episode.episodeNumber}
329 |
330 |
331 |
332 | {episode.title || `Episode ${episode.episodeNumber}`}
333 |
334 |
335 | {episode.airDate && (
336 |
337 | {formatDate(episode.airDate)}
338 |
339 | )}
340 |
346 | {getEpisodeStatus(episode)}
347 |
348 |
349 |
350 |
351 | ))}
352 |
353 | )}
354 |
355 | ))}
356 |
357 |
358 |
359 | {user &&
}
360 |
361 | {searchModalOpen && (
362 |
368 | )}
369 |
370 | );
371 | }
--------------------------------------------------------------------------------
/backend/websocket_manager.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | from typing import Dict, List, Optional
4 | from fastapi import WebSocket
5 | import logging
6 | import time
7 | from datetime import datetime, timedelta
8 | from sqlalchemy.orm import Session
9 | from database import SessionLocal
10 | from models import Notification
11 | from collections import defaultdict, deque
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | class WebSocketConnection:
16 | """Enhanced WebSocket connection with metadata"""
17 | def __init__(self, websocket: WebSocket, user_id: int):
18 | self.websocket = websocket
19 | self.user_id = user_id
20 | self.connected_at = datetime.now()
21 | self.last_ping = time.time()
22 | self.is_alive = True
23 |
24 | class ConnectionManager:
25 | def __init__(self):
26 | self.active_connections: Dict[int, List[WebSocketConnection]] = {}
27 | self.ping_interval = 60 # seconds (increased from 30)
28 | self.ping_timeout = 120 # seconds (increased from 10)
29 | self._ping_task = None
30 |
31 | # Rate limiting
32 | self.rate_limits: Dict[int, deque] = defaultdict(lambda: deque())
33 | self.max_messages_per_minute = 60 # Max messages per user per minute
34 | self.rate_limit_window = 60 # seconds
35 |
36 | # Performance monitoring
37 | self.stats = {
38 | "total_messages_sent": 0,
39 | "total_connections": 0,
40 | "rate_limited_messages": 0,
41 | "errors": 0,
42 | "start_time": datetime.now()
43 | }
44 |
45 | self._start_ping_task()
46 |
47 | def _is_rate_limited(self, user_id: int) -> bool:
48 | """Check if user is rate limited"""
49 | now = time.time()
50 | user_timestamps = self.rate_limits[user_id]
51 |
52 | # Remove old timestamps outside the window
53 | while user_timestamps and user_timestamps[0] < now - self.rate_limit_window:
54 | user_timestamps.popleft()
55 |
56 | # Check if user has exceeded the limit
57 | if len(user_timestamps) >= self.max_messages_per_minute:
58 | self.stats["rate_limited_messages"] += 1
59 | return True
60 |
61 | # Add current timestamp
62 | user_timestamps.append(now)
63 | return False
64 |
65 | def _start_ping_task(self):
66 | """Start the ping task for connection health monitoring"""
67 | if self._ping_task is None:
68 | self._ping_task = asyncio.create_task(self._ping_connections())
69 |
70 | async def _ping_connections(self):
71 | """Send ping messages to all connections to check health"""
72 | while True:
73 | try:
74 | await asyncio.sleep(self.ping_interval)
75 | current_time = time.time()
76 |
77 | for user_id in list(self.active_connections.keys()):
78 | if user_id in self.active_connections:
79 | disconnected = []
80 | for connection in self.active_connections[user_id]:
81 | try:
82 | # Check if connection is stale
83 | if current_time - connection.last_ping > self.ping_timeout:
84 | logger.warning(f"Connection timeout for user {user_id}")
85 | disconnected.append(connection)
86 | continue
87 |
88 | # Send ping
89 | await connection.websocket.send_text(json.dumps({
90 | "type": "ping",
91 | "timestamp": current_time
92 | }))
93 | connection.last_ping = current_time
94 |
95 | except Exception as e:
96 | logger.error(f"Error pinging user {user_id}: {e}")
97 | disconnected.append(connection)
98 |
99 | # Clean up disconnected connections
100 | for connection in disconnected:
101 | self.disconnect(connection.websocket, user_id)
102 |
103 | except Exception as e:
104 | logger.error(f"Error in ping task: {e}")
105 |
106 | async def connect(self, websocket: WebSocket, user_id: int):
107 | await websocket.accept()
108 | if user_id not in self.active_connections:
109 | self.active_connections[user_id] = []
110 |
111 | connection = WebSocketConnection(websocket, user_id)
112 | self.active_connections[user_id].append(connection)
113 | self.stats["total_connections"] += 1
114 | logger.info(f"WebSocket connected for user {user_id} (total connections: {len(self.active_connections[user_id])})")
115 |
116 | # Send missed notifications (recent unread ones)
117 | await self._send_missed_notifications(user_id)
118 |
119 | def disconnect(self, websocket: WebSocket, user_id: int):
120 | if user_id in self.active_connections:
121 | # Find and remove the specific connection
122 | for connection in self.active_connections[user_id][:]:
123 | if connection.websocket == websocket:
124 | connection.is_alive = False
125 | self.active_connections[user_id].remove(connection)
126 | break
127 |
128 | # Clean up empty user entries
129 | if not self.active_connections[user_id]:
130 | del self.active_connections[user_id]
131 | logger.info(f"WebSocket disconnected for user {user_id}")
132 |
133 | async def send_personal_message(self, message: dict, user_id: int, bypass_rate_limit: bool = False):
134 | # Check rate limiting for non-system messages
135 | if not bypass_rate_limit and message.get("type") not in ["ping", "auth_status"]:
136 | if self._is_rate_limited(user_id):
137 | logger.warning(f"Rate limit exceeded for user {user_id}, dropping message: {message.get('type', 'unknown')}")
138 | return
139 |
140 | if user_id in self.active_connections:
141 | disconnected = []
142 | for connection in self.active_connections[user_id]:
143 | if not connection.is_alive:
144 | disconnected.append(connection)
145 | continue
146 |
147 | try:
148 | await connection.websocket.send_text(json.dumps(message))
149 | self.stats["total_messages_sent"] += 1
150 | except Exception as e:
151 | logger.error(f"Error sending message to user {user_id}: {e}")
152 | connection.is_alive = False
153 | disconnected.append(connection)
154 | self.stats["errors"] += 1
155 |
156 | # Clean up disconnected connections
157 | for connection in disconnected:
158 | self.disconnect(connection.websocket, user_id)
159 |
160 | async def send_progress_update(self, user_id: int, message: str, progress: int, status: str = "in_progress"):
161 | """Send progress update (backward compatibility)"""
162 | update = {
163 | "type": "progress_update",
164 | "message": message,
165 | "progress": progress,
166 | "status": status,
167 | "timestamp": datetime.now().isoformat()
168 | }
169 | await self.send_personal_message(update, user_id)
170 |
171 | async def send_enhanced_progress_update(self, user_id: int, show_title: str, operation_type: str,
172 | message: str, progress: int, status: str = "in_progress",
173 | current_step: str = None, total_steps: int = None,
174 | current_step_number: int = None, details: dict = None):
175 | """Send enhanced progress update with detailed information for regular operations"""
176 |
177 | # Create more detailed progress information
178 | enhanced_message = message
179 | if current_step:
180 | enhanced_message = f"{current_step}: {message}"
181 |
182 | update = {
183 | "type": "enhanced_progress_update",
184 | "operation_type": operation_type, # "season_it_single", "season_it_all", etc.
185 | "show_title": show_title,
186 | "message": enhanced_message,
187 | "progress": progress,
188 | "status": status,
189 | "current_step": current_step,
190 | "current_step_number": current_step_number,
191 | "total_steps": total_steps,
192 | "details": details or {},
193 | "timestamp": datetime.now().isoformat()
194 | }
195 | await self.send_personal_message(update, user_id)
196 |
197 | async def send_bulk_operation_update(self, user_id: int, operation_id: str, operation_type: str,
198 | overall_progress: int, current_item: int, total_items: int,
199 | current_item_name: str, current_item_progress: int = 0,
200 | message: str = None, status: str = "in_progress",
201 | completed_items: list = None, failed_items: list = None,
202 | poster_url: str = None):
203 | """Send bulk operation progress update with detailed information"""
204 |
205 | # Create a more descriptive message that prominently shows the current show title
206 | if message:
207 | # If there's a specific message, use it but prefix with show title
208 | main_message = f"🎬 {current_item_name}: {message}"
209 | else:
210 | # Default message with prominent show title
211 | if operation_type == "season_it_bulk":
212 | main_message = f"🧂 Season It! Processing '{current_item_name}' ({current_item}/{total_items})"
213 | else:
214 | main_message = f"🎬 Processing '{current_item_name}' ({current_item}/{total_items})"
215 |
216 | update = {
217 | "type": "bulk_operation_update",
218 | "operation_id": operation_id,
219 | "operation_type": operation_type, # "season_it_bulk", "search_bulk", etc.
220 | "overall_progress": overall_progress,
221 | "current_item": current_item,
222 | "total_items": total_items,
223 | "current_item_name": current_item_name,
224 | "current_item_progress": current_item_progress,
225 | "message": main_message,
226 | "status": status,
227 | "completed_items": completed_items or [],
228 | "failed_items": failed_items or [],
229 | "poster_url": poster_url,
230 | "timestamp": datetime.now().isoformat()
231 | }
232 | await self.send_personal_message(update, user_id)
233 |
234 | async def send_bulk_operation_start(self, user_id: int, operation_id: str, operation_type: str,
235 | total_items: int, items: list, message: str = None):
236 | """Send bulk operation start notification"""
237 |
238 | # Create a more descriptive start message
239 | if message:
240 | start_message = message
241 | else:
242 | if operation_type == "season_it_bulk":
243 | start_message = f"🧂 Starting Season It! for {total_items} show{'s' if total_items != 1 else ''}"
244 | else:
245 | start_message = f"🎬 Starting {operation_type} for {total_items} item{'s' if total_items != 1 else ''}"
246 |
247 | update = {
248 | "type": "bulk_operation_start",
249 | "operation_id": operation_id,
250 | "operation_type": operation_type,
251 | "total_items": total_items,
252 | "items": items, # List of item names/IDs
253 | "message": start_message,
254 | "timestamp": datetime.now().isoformat()
255 | }
256 | await self.send_personal_message(update, user_id)
257 |
258 | async def send_bulk_operation_complete(self, user_id: int, operation_id: str, operation_type: str,
259 | total_items: int, completed_items: list, failed_items: list,
260 | message: str = None, status: str = "success"):
261 | """Send bulk operation completion notification"""
262 | success_count = len(completed_items)
263 | failure_count = len(failed_items)
264 |
265 | if failure_count == 0:
266 | final_status = "success"
267 | if operation_type == "season_it_bulk":
268 | default_message = f"🎉 Season It! completed successfully for all {total_items} show{'s' if total_items != 1 else ''}!"
269 | else:
270 | default_message = f"✅ Successfully completed {operation_type} for all {total_items} item{'s' if total_items != 1 else ''}!"
271 | elif success_count == 0:
272 | final_status = "error"
273 | if operation_type == "season_it_bulk":
274 | default_message = f"❌ Season It! failed for all {total_items} show{'s' if total_items != 1 else ''}"
275 | else:
276 | default_message = f"❌ Failed to complete {operation_type} for all {total_items} item{'s' if total_items != 1 else ''}"
277 | else:
278 | final_status = "warning"
279 | if operation_type == "season_it_bulk":
280 | default_message = f"⚠️ Season It! completed: {success_count} successful, {failure_count} failed"
281 | else:
282 | default_message = f"⚠️ Completed {operation_type}: {success_count} successful, {failure_count} failed"
283 |
284 | update = {
285 | "type": "bulk_operation_complete",
286 | "operation_id": operation_id,
287 | "operation_type": operation_type,
288 | "total_items": total_items,
289 | "success_count": success_count,
290 | "failure_count": failure_count,
291 | "completed_items": completed_items,
292 | "failed_items": failed_items,
293 | "message": message or default_message,
294 | "status": final_status,
295 | "timestamp": datetime.now().isoformat()
296 | }
297 | await self.send_personal_message(update, user_id)
298 |
299 | async def send_notification(self, user_id: int, title: str, message: str, notification_type: str = "info", priority: str = "normal", persistent: bool = False, extra_data: dict = None):
300 | """Send general notification"""
301 | notification_data = {
302 | "type": "notification",
303 | "title": title,
304 | "message": message,
305 | "notification_type": notification_type, # info, success, warning, error
306 | "priority": priority, # low, normal, high
307 | "persistent": persistent, # whether notification stays until dismissed
308 | "timestamp": datetime.now().isoformat()
309 | }
310 |
311 | # Save to database
312 | await self._save_notification_to_db(
313 | user_id=user_id,
314 | title=title,
315 | message=message,
316 | notification_type=notification_type,
317 | message_type="notification",
318 | priority=priority,
319 | persistent=persistent,
320 | extra_data=extra_data
321 | )
322 |
323 | await self.send_personal_message(notification_data, user_id)
324 |
325 | async def send_show_update(self, user_id: int, show_id: int, action: str, details: dict = None):
326 | """Send show-related update"""
327 | update = {
328 | "type": "show_update",
329 | "show_id": show_id,
330 | "action": action, # downloaded, monitored, unmonitored, etc.
331 | "details": details or {},
332 | "timestamp": datetime.now().isoformat()
333 | }
334 | await self.send_personal_message(update, user_id)
335 |
336 | async def broadcast_message(self, message: dict, exclude_user_id: Optional[int] = None):
337 | """Send message to all connected users"""
338 | message["timestamp"] = datetime.now().isoformat()
339 |
340 | for user_id in list(self.active_connections.keys()):
341 | if exclude_user_id and user_id == exclude_user_id:
342 | continue
343 | await self.send_personal_message(message, user_id)
344 |
345 | async def send_system_announcement(self, title: str, message: str, announcement_type: str = "info"):
346 | """Send system-wide announcement"""
347 | announcement = {
348 | "type": "system_announcement",
349 | "title": title,
350 | "message": message,
351 | "announcement_type": announcement_type,
352 | "timestamp": datetime.now().isoformat()
353 | }
354 | await self.broadcast_message(announcement)
355 |
356 | async def _save_notification_to_db(self, user_id: int, title: str, message: str, notification_type: str, message_type: str, priority: str = "normal", persistent: bool = False, extra_data: dict = None):
357 | """Save notification to database"""
358 | try:
359 | db = SessionLocal()
360 | try:
361 | notification = Notification(
362 | user_id=user_id,
363 | title=title,
364 | message=message,
365 | notification_type=notification_type,
366 | message_type=message_type,
367 | priority=priority,
368 | persistent=persistent,
369 | extra_data=extra_data
370 | )
371 | db.add(notification)
372 | db.commit()
373 | logger.info(f"Saved notification to database for user {user_id}: {title}")
374 | finally:
375 | db.close()
376 | except Exception as e:
377 | logger.error(f"Error saving notification to database: {e}")
378 |
379 | async def _send_missed_notifications(self, user_id: int):
380 | """Send recent unread notifications to newly connected user"""
381 | try:
382 | db = SessionLocal()
383 | try:
384 | # Get recent unread notifications (last 24 hours)
385 | from datetime import timedelta
386 | cutoff_time = datetime.now() - timedelta(hours=24)
387 |
388 | notifications = db.query(Notification).filter(
389 | Notification.user_id == user_id,
390 | Notification.read == False,
391 | Notification.created_at >= cutoff_time
392 | ).order_by(Notification.created_at.desc()).limit(10).all()
393 |
394 | for notification in notifications:
395 | notification_data = {
396 | "type": notification.message_type,
397 | "id": notification.id,
398 | "title": notification.title,
399 | "message": notification.message,
400 | "notification_type": notification.notification_type,
401 | "priority": notification.priority,
402 | "persistent": notification.persistent,
403 | "timestamp": notification.created_at.isoformat(),
404 | "from_db": True # Indicate this is a missed notification
405 | }
406 |
407 | # Add extra_data if available
408 | if notification.extra_data:
409 | notification_data.update(notification.extra_data)
410 |
411 | await self.send_personal_message(notification_data, user_id)
412 |
413 | if notifications:
414 | logger.info(f"Sent {len(notifications)} missed notifications to user {user_id}")
415 |
416 | finally:
417 | db.close()
418 | except Exception as e:
419 | logger.error(f"Error sending missed notifications: {e}")
420 |
421 | def get_connection_stats(self) -> dict:
422 | """Get connection statistics and performance metrics"""
423 | active_connections = sum(len(connections) for connections in self.active_connections.values())
424 | uptime = datetime.now() - self.stats["start_time"]
425 |
426 | return {
427 | "active_users": len(self.active_connections),
428 | "active_connections": active_connections,
429 | "users_online": list(self.active_connections.keys()),
430 | "performance": {
431 | "total_messages_sent": self.stats["total_messages_sent"],
432 | "total_connections_made": self.stats["total_connections"],
433 | "rate_limited_messages": self.stats["rate_limited_messages"],
434 | "errors": self.stats["errors"],
435 | "uptime_seconds": int(uptime.total_seconds()),
436 | "messages_per_second": round(self.stats["total_messages_sent"] / max(uptime.total_seconds(), 1), 2)
437 | },
438 | "rate_limiting": {
439 | "max_messages_per_minute": self.max_messages_per_minute,
440 | "window_seconds": self.rate_limit_window,
441 | "current_rate_limited_users": len([uid for uid, timestamps in self.rate_limits.items() if len(timestamps) >= self.max_messages_per_minute])
442 | }
443 | }
444 |
445 | manager = ConnectionManager()
--------------------------------------------------------------------------------
/frontend/src/components/Dashboard.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useMemo, useCallback } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 | import { sonarr, auth, settings } from '../services/api';
4 | import ShowCard from './ShowCard';
5 | import SonarrSelector from './SonarrSelector';
6 | import EnhancedProgressBar from './EnhancedProgressBar';
7 | import AddSonarrModal from './AddSonarrModal';
8 | import Pagination from './Pagination';
9 | import logoTransparent from '../assets/logotransparent.png';
10 |
11 | export default function Dashboard() {
12 | const navigate = useNavigate();
13 | const [instances, setInstances] = useState([]);
14 | const [selectedInstance, setSelectedInstance] = useState(() => {
15 | const savedInstanceId = localStorage.getItem('seasonarr_selected_instance');
16 | return savedInstanceId ? { id: parseInt(savedInstanceId, 10) } : null;
17 | });
18 | const [shows, setShows] = useState([]);
19 | const [loading, setLoading] = useState(false);
20 | const [page, setPage] = useState(() => {
21 | const savedPage = localStorage.getItem('seasonarr_current_page');
22 | return savedPage ? parseInt(savedPage, 10) : 1;
23 | });
24 | const [totalPages, setTotalPages] = useState(1);
25 | const [user, setUser] = useState(null);
26 | const [showAddModal, setShowAddModal] = useState(false);
27 | const [filters, setFilters] = useState(() => {
28 | const savedFilters = localStorage.getItem('seasonarr_filters');
29 | if (savedFilters) {
30 | try {
31 | return JSON.parse(savedFilters);
32 | } catch (e) {
33 | console.warn('Failed to parse saved filters:', e);
34 | }
35 | }
36 | return {
37 | search: '',
38 | status: '',
39 | missing_episodes: true, // Default to showing missing episodes
40 | sort: 'title_asc', // Default to title A-Z
41 | network: '',
42 | genres: [],
43 | year_from: '',
44 | year_to: '',
45 | runtime_min: '',
46 | runtime_max: '',
47 | certification: ''
48 | };
49 | });
50 | const [userSettings, setUserSettings] = useState({
51 | shows_per_page: 35,
52 | default_sort: 'title_asc',
53 | default_show_missing_only: true
54 | });
55 | const [bulkMode, setBulkMode] = useState(false);
56 | const [selectedShows, setSelectedShows] = useState(new Set());
57 | const [filterOptions, setFilterOptions] = useState({
58 | networks: [],
59 | genres: [],
60 | certifications: [],
61 | year_range: { min: null, max: null },
62 | runtime_range: { min: null, max: null }
63 | });
64 | const [showAdvancedFilters, setShowAdvancedFilters] = useState(() => {
65 | const saved = localStorage.getItem('seasonarr_show_advanced_filters');
66 | return saved ? JSON.parse(saved) : false;
67 | });
68 | const [statistics, setStatistics] = useState({
69 | totalShows: 0,
70 | totalMissingEpisodes: 0,
71 | showsWithMissingEpisodes: 0,
72 | seasonsWithMissingEpisodes: 0
73 | });
74 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
75 |
76 | useEffect(() => {
77 | loadInstances();
78 | loadUser();
79 | loadSettings();
80 | }, []);
81 |
82 | // Close mobile menu when clicking outside
83 | useEffect(() => {
84 | const handleClickOutside = (event) => {
85 | if (isMobileMenuOpen && !event.target.closest('.dashboard-header')) {
86 | setIsMobileMenuOpen(false);
87 | }
88 | };
89 |
90 | document.addEventListener('click', handleClickOutside);
91 | return () => document.removeEventListener('click', handleClickOutside);
92 | }, [isMobileMenuOpen]);
93 |
94 | useEffect(() => {
95 | if (selectedInstance) {
96 | loadShows();
97 | loadFilterOptions();
98 | }
99 | // eslint-disable-next-line react-hooks/exhaustive-deps
100 | }, [selectedInstance, page, filters]);
101 |
102 | const loadUser = async () => {
103 | try {
104 | const response = await auth.getMe();
105 | setUser(response.data);
106 | } catch (error) {
107 | console.error('Error loading user:', error);
108 | }
109 | };
110 |
111 | const loadSettings = async () => {
112 | try {
113 | const response = await settings.getSettings();
114 | const loadedSettings = response.data;
115 | setUserSettings(loadedSettings);
116 |
117 | // Apply default settings only if no saved filters exist
118 | const savedFilters = localStorage.getItem('seasonarr_filters');
119 | if (!savedFilters) {
120 | const defaultFilters = {
121 | search: '',
122 | status: '',
123 | missing_episodes: loadedSettings.default_show_missing_only,
124 | sort: loadedSettings.default_sort,
125 | network: '',
126 | genres: [],
127 | year_from: '',
128 | year_to: '',
129 | runtime_min: '',
130 | runtime_max: '',
131 | certification: ''
132 | };
133 | setFilters(defaultFilters);
134 | localStorage.setItem('seasonarr_filters', JSON.stringify(defaultFilters));
135 | }
136 | } catch (error) {
137 | console.error('Error loading settings:', error);
138 | }
139 | };
140 |
141 | const loadInstances = async () => {
142 | try {
143 | const response = await sonarr.getInstances();
144 | setInstances(response.data);
145 |
146 | // Try to restore saved instance or use first available
147 | const savedInstanceId = localStorage.getItem('seasonarr_selected_instance');
148 | if (savedInstanceId) {
149 | const savedInstance = response.data.find(inst => inst.id === parseInt(savedInstanceId, 10));
150 | if (savedInstance) {
151 | setSelectedInstance(savedInstance);
152 | } else if (response.data.length > 0) {
153 | setSelectedInstance(response.data[0]);
154 | localStorage.setItem('seasonarr_selected_instance', response.data[0].id.toString());
155 | }
156 | } else if (response.data.length > 0) {
157 | setSelectedInstance(response.data[0]);
158 | localStorage.setItem('seasonarr_selected_instance', response.data[0].id.toString());
159 | }
160 | } catch (error) {
161 | console.error('Error loading instances:', error);
162 | }
163 | };
164 |
165 | const loadFilterOptions = async () => {
166 | if (!selectedInstance) return;
167 |
168 | try {
169 | const response = await sonarr.getFilterOptions(selectedInstance.id);
170 | setFilterOptions(response.data);
171 | } catch (error) {
172 | console.error('Error loading filter options:', error);
173 | }
174 | };
175 |
176 | const sortShows = (shows, sortOption) => {
177 | const sorted = [...shows];
178 |
179 | switch (sortOption) {
180 | case 'title_asc':
181 | return sorted.sort((a, b) => a.title.localeCompare(b.title));
182 | case 'title_desc':
183 | return sorted.sort((a, b) => b.title.localeCompare(a.title));
184 | case 'year_desc':
185 | return sorted.sort((a, b) => (b.year || 0) - (a.year || 0));
186 | case 'year_asc':
187 | return sorted.sort((a, b) => (a.year || 0) - (b.year || 0));
188 | case 'status':
189 | return sorted.sort((a, b) => a.status.localeCompare(b.status));
190 | case 'missing_desc':
191 | return sorted.sort((a, b) => b.missing_episode_count - a.missing_episode_count);
192 | case 'missing_asc':
193 | return sorted.sort((a, b) => a.missing_episode_count - b.missing_episode_count);
194 | default:
195 | return sorted;
196 | }
197 | };
198 |
199 | const loadShows = async () => {
200 | if (!selectedInstance) return;
201 |
202 | setLoading(true);
203 | try {
204 | const response = await sonarr.getShows(selectedInstance.id, page, userSettings.shows_per_page, filters);
205 | const sortedShows = sortShows(response.data.shows, filters.sort);
206 | setShows(sortedShows);
207 | setTotalPages(response.data.total_pages);
208 |
209 | // Update statistics based on current page data
210 | // For accurate stats, we need all shows, so let's load them separately
211 | loadStatistics();
212 | } catch (error) {
213 | console.error('Error loading shows:', error);
214 | } finally {
215 | setLoading(false);
216 | }
217 | };
218 |
219 | const loadStatistics = async () => {
220 | if (!selectedInstance) return;
221 |
222 | try {
223 | // Get all shows for statistics calculation
224 | const allShowsResponse = await sonarr.getShows(selectedInstance.id, 1, 10000, {});
225 | const allShows = allShowsResponse.data.shows;
226 |
227 | // Calculate seasons with missing episodes
228 | const seasonsWithMissingEpisodes = allShows.reduce((count, show) => {
229 | const seasonsWithMissing = show.seasons?.filter(season =>
230 | season.monitored && season.missing_episode_count > 0
231 | ).length || 0;
232 | return count + seasonsWithMissing;
233 | }, 0);
234 |
235 | // Calculate shows with missing episodes
236 | const showsWithMissingEpisodes = allShows.filter(show => show.missing_episode_count > 0).length;
237 |
238 | const stats = {
239 | totalShows: allShows.length,
240 | totalMissingEpisodes: allShows.reduce((sum, show) => sum + show.missing_episode_count, 0),
241 | showsWithMissingEpisodes: showsWithMissingEpisodes,
242 | seasonsWithMissingEpisodes: seasonsWithMissingEpisodes
243 | };
244 |
245 | setStatistics(stats);
246 | } catch (error) {
247 | console.error('Error loading statistics:', error);
248 | }
249 | };
250 |
251 | const handleInstanceChange = (instance) => {
252 | setSelectedInstance(instance);
253 | setPage(1);
254 | localStorage.setItem('seasonarr_selected_instance', instance.id.toString());
255 | localStorage.setItem('seasonarr_current_page', '1');
256 | };
257 |
258 | const handleFilterChange = useCallback((newFilters) => {
259 | setFilters(newFilters);
260 | setPage(1);
261 | localStorage.setItem('seasonarr_filters', JSON.stringify(newFilters));
262 | localStorage.setItem('seasonarr_current_page', '1');
263 | }, []);
264 |
265 | const handleAdvancedFilterChange = useCallback((filterType, value) => {
266 | const newFilters = { ...filters, [filterType]: value };
267 | setFilters(newFilters);
268 | setPage(1);
269 | localStorage.setItem('seasonarr_filters', JSON.stringify(newFilters));
270 | localStorage.setItem('seasonarr_current_page', '1');
271 | }, [filters]);
272 |
273 | const resetAllFilters = () => {
274 | const resetFilters = {
275 | search: '',
276 | status: '',
277 | missing_episodes: userSettings.default_show_missing_only,
278 | sort: userSettings.default_sort,
279 | network: '',
280 | genres: [],
281 | year_from: '',
282 | year_to: '',
283 | runtime_min: '',
284 | runtime_max: '',
285 | certification: ''
286 | };
287 | setFilters(resetFilters);
288 | setPage(1);
289 | localStorage.setItem('seasonarr_filters', JSON.stringify(resetFilters));
290 | localStorage.setItem('seasonarr_current_page', '1');
291 | };
292 |
293 | const handleSearchChange = (searchTerm) => {
294 | const newFilters = { ...filters, search: searchTerm };
295 | setFilters(newFilters);
296 | setPage(1);
297 | localStorage.setItem('seasonarr_filters', JSON.stringify(newFilters));
298 | localStorage.setItem('seasonarr_current_page', '1');
299 | };
300 |
301 | const handleAddSuccess = () => {
302 | loadInstances();
303 | };
304 |
305 | const handleSelectionChange = (showId) => {
306 | const newSelected = new Set(selectedShows);
307 | if (newSelected.has(showId)) {
308 | newSelected.delete(showId);
309 | } else {
310 | newSelected.add(showId);
311 | }
312 | setSelectedShows(newSelected);
313 |
314 | // Auto-exit bulk mode when no items are selected
315 | if (newSelected.size === 0 && bulkMode) {
316 | setBulkMode(false);
317 | }
318 | };
319 |
320 | const handleSelectAll = async () => {
321 | const allCurrentPageShows = new Set(shows.map(show => show.id));
322 | const hasAllCurrentPageSelected = [...allCurrentPageShows].every(id => selectedShows.has(id));
323 |
324 | if (hasAllCurrentPageSelected && selectedShows.size > shows.length) {
325 | // If all current page shows are selected and there are more selected (from other pages), deselect all
326 | setSelectedShows(new Set());
327 | setBulkMode(false);
328 | } else if (hasAllCurrentPageSelected) {
329 | // If only current page is selected, select all shows across all pages
330 | try {
331 | const response = await sonarr.getShows(selectedInstance.id, 1, 10000, filters);
332 | const allShows = response.data.shows;
333 | const allEligibleShows = allShows.filter(show => show.missing_episode_count > 0);
334 | setSelectedShows(new Set(allEligibleShows.map(show => show.id)));
335 | } catch (error) {
336 | console.error('Error loading all shows for selection:', error);
337 | // Fallback to current page only
338 | setSelectedShows(new Set(shows.map(show => show.id)));
339 | }
340 | } else {
341 | // Select all shows on current page
342 | setSelectedShows(new Set(shows.map(show => show.id)));
343 | }
344 | };
345 |
346 | const handleBulkSeasonIt = async () => {
347 | if (selectedShows.size === 0) return;
348 |
349 | try {
350 | // Check user settings for deletion confirmation
351 | const userSettingsResponse = await settings.getSettings();
352 | const requireConfirmation = userSettingsResponse.data.require_deletion_confirmation;
353 | const skipDeletion = userSettingsResponse.data.skip_episode_deletion;
354 |
355 | if (requireConfirmation && !skipDeletion) {
356 | const selectedShowsList = shows.filter(show => selectedShows.has(show.id));
357 | const showTitles = selectedShowsList.map(show => show.title).join(', ');
358 | const confirmMessage = `Are you sure you want to delete existing episodes from ${selectedShows.size} show(s) (${showTitles}) and download season packs?`;
359 |
360 | if (!window.confirm(confirmMessage)) {
361 | return;
362 | }
363 | }
364 |
365 | // Prepare show items for bulk operation
366 | // For all selected shows across pages, we need to fetch their data
367 | const selectedShowIds = Array.from(selectedShows);
368 | const showItems = selectedShowIds.map(showId => {
369 | const show = shows.find(s => s.id === showId);
370 | return {
371 | id: showId,
372 | name: show ? show.title : `Show ${showId}`,
373 | season_number: null, // null for all seasons
374 | poster_url: show ? show.poster_url : null,
375 | instance_id: selectedInstance.id
376 | };
377 | });
378 |
379 | // Call the new bulk Season It API
380 | const response = await fetch('/api/bulk-season-it', {
381 | method: 'POST',
382 | headers: {
383 | 'Content-Type': 'application/json',
384 | 'Authorization': `Bearer ${localStorage.getItem('token')}`
385 | },
386 | body: JSON.stringify({
387 | show_items: showItems
388 | })
389 | });
390 |
391 | if (!response.ok) {
392 | throw new Error(`Bulk Season It failed: ${response.statusText}`);
393 | }
394 |
395 | const result = await response.json();
396 | console.log('Bulk Season It started:', result);
397 |
398 | // Clear selection and exit bulk mode
399 | setSelectedShows(new Set());
400 | setBulkMode(false);
401 |
402 | } catch (error) {
403 | console.error('Bulk Season It failed:', error);
404 | alert(`Bulk Season It failed: ${error.message}`);
405 | }
406 | };
407 |
408 | const toggleBulkMode = () => {
409 | setBulkMode(!bulkMode);
410 | setSelectedShows(new Set());
411 | };
412 |
413 | const enterBulkMode = () => {
414 | setBulkMode(true);
415 | };
416 |
417 | // Memoize expensive calculations
418 | const eligibleShows = useMemo(() =>
419 | shows.filter(show => show.missing_episode_count > 0),
420 | [shows]
421 | );
422 |
423 | const selectedEligibleShows = useMemo(() =>
424 | Array.from(selectedShows).filter(showId =>
425 | eligibleShows.some(show => show.id === showId)
426 | ),
427 | [selectedShows, eligibleShows]
428 | );
429 |
430 | return (
431 |
432 |
433 |
434 |
435 |
navigate('/')} style={{cursor: 'pointer'}}>
436 |

437 |
Seasonarr
438 |
439 |
450 |
451 |
452 |
457 |
466 |
475 |
484 |
490 |
491 |
492 |
493 | {selectedInstance && (
494 |
495 |
Library Overview
496 |
497 |
498 |
{statistics.totalShows}
499 |
Total Shows
500 |
501 |
502 |
{statistics.totalMissingEpisodes}
503 |
Missing Episodes
504 |
505 |
506 |
{statistics.showsWithMissingEpisodes}
507 |
Shows with Missing Episodes
508 |
509 |
510 |
{statistics.seasonsWithMissingEpisodes}
511 |
Seasons with Missing Episodes
512 |
513 |
514 |
515 | )}
516 |
517 |
518 |
519 | handleSearchChange(e.target.value)}
524 | className="search-input"
525 | />
526 |
527 |
528 |
529 |
530 |
540 |
541 |
553 |
554 |
567 |
568 |
569 |
570 |
576 |
577 |
587 |
588 |
594 |
595 |
596 |
597 |
598 | {showAdvancedFilters && (
599 |
600 |
601 |
602 |
603 |
613 |
614 |
615 |
616 |
617 |
627 |
628 |
629 |
630 |
681 |
682 |
683 |
684 |
685 |
686 | {filterOptions.genres.map(genre => (
687 |
700 | ))}
701 |
702 |
703 |
704 |
705 | )}
706 |
707 | {bulkMode && eligibleShows.length > 0 && (
708 |
709 |
710 |
711 | {selectedShows.size} selected
712 | {selectedShows.size > shows.length ? ' (across all pages)' : ` of ${eligibleShows.length} on this page`}
713 |
714 |
731 |
732 |
733 |
740 |
741 |
742 | )}
743 |
744 |
{
748 | setPage(newPage);
749 | localStorage.setItem('seasonarr_current_page', newPage.toString());
750 | }}
751 | />
752 |
753 | {loading ? (
754 | Loading shows...
755 | ) : (
756 |
757 | {shows.map((show) => (
758 |
767 | ))}
768 |
769 | )}
770 |
771 | {
775 | setPage(newPage);
776 | localStorage.setItem('seasonarr_current_page', newPage.toString());
777 | }}
778 | />
779 |
780 |
781 |
782 |
setShowAddModal(false)}
785 | onSuccess={handleAddSuccess}
786 | />
787 |
788 | {user && }
789 |
790 | );
791 | }
--------------------------------------------------------------------------------