├── 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 |
13 | 18 |
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 | Seasonarr 31 |

Seasonarr

32 |
33 |

Sign in to your account

34 |
35 |
36 | setCredentials({ ...credentials, username: e.target.value })} 41 | required 42 | /> 43 |
44 |
45 | setCredentials({ ...credentials, password: e.target.value })} 50 | required 51 | /> 52 |
53 |
54 | 62 |
63 | {error &&
{error}
} 64 | 67 |
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 | Seasonarr 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 | Seasonarr 42 |

Welcome to Seasonarr

43 |
44 |

Create your admin account to get started

45 |
46 |
47 | setCredentials({ ...credentials, username: e.target.value })} 52 | required 53 | /> 54 |
55 |
56 | setCredentials({ ...credentials, password: e.target.value })} 61 | required 62 | /> 63 |
64 |
65 | setCredentials({ ...credentials, confirmPassword: e.target.value })} 70 | required 71 | /> 72 |
73 | {error &&
{error}
} 74 | 77 |
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 |
75 |
76 | 77 | setFormData({ ...formData, name: e.target.value })} 82 | required 83 | /> 84 |
85 | 86 |
87 | 88 | setFormData({ ...formData, url: e.target.value })} 93 | required 94 | /> 95 |
96 | 97 |
98 | 99 | setFormData({ ...formData, api_key: e.target.value })} 104 | required 105 | /> 106 |
107 | 108 | {error &&
{error}
} 109 | 110 | {testResult && ( 111 |
112 | {testResult.message} 113 |
114 | )} 115 | 116 |
117 | 120 | 128 | 131 |
132 |
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 | {show.title} 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 | Seasonarr Logo 3 | 4 | [![Docker Image](https://img.shields.io/badge/docker-ghcr.io-blue?style=flat-square&logo=docker)](https://ghcr.io/d3v1l1989/seasonarr) 5 | [![GitHub release](https://img.shields.io/github/release/d3v1l1989/seasonarr?style=flat-square)](https://github.com/d3v1l1989/seasonarr/releases) 6 | [![License](https://img.shields.io/github/license/d3v1l1989/seasonarr?style=flat-square)](https://github.com/d3v1l1989/seasonarr/blob/main/LICENSE) 7 | [![GitHub stars](https://img.shields.io/github/stars/d3v1l1989/seasonarr?style=flat-square)](https://github.com/d3v1l1989/seasonarr/stargazers) 8 | [![GitHub issues](https://img.shields.io/github/issues/d3v1l1989/seasonarr?style=flat-square)](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 | ![Main Dashboard](assets/screenshots/UI.png) 30 | *Library overview with show grid, statistics, search functionality, and quick "Season It!" actions* 31 | 32 | ### Advanced Filtering 33 | ![Advanced Filters](assets/screenshots/filters.png) 34 | *Comprehensive filtering options including genres, networks, year ranges, and runtime filters* 35 | 36 | ### Real-time Progress 37 | ![Progress Notification](assets/screenshots/notification.png) 38 | *Live progress updates during seasoning 🧂* 39 | 40 | ### Show Details 41 | ![Show Details](assets/screenshots/showdetails.png) 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 | [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](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 | {show.title} { 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 |
93 |
navigate('/')} style={{cursor: 'pointer'}}> 94 | Seasonarr 95 |

Settings

96 |
97 | 103 |
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 | {currentOperation.show_title 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 |
281 |
288 |
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 |
313 |
320 |
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 | Seasonarr 173 |

Show Details

174 |
175 |
176 | 177 |
178 |
179 |
180 | {show.poster_url ? ( 181 | {show.title} 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 | Seasonarr 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 |
631 |
632 | 633 |
634 | handleAdvancedFilterChange('year_from', e.target.value ? parseInt(e.target.value) : '')} 639 | min={filterOptions.year_range.min} 640 | max={filterOptions.year_range.max} 641 | className="range-input" 642 | /> 643 | to 644 | handleAdvancedFilterChange('year_to', e.target.value ? parseInt(e.target.value) : '')} 649 | min={filterOptions.year_range.min} 650 | max={filterOptions.year_range.max} 651 | className="range-input" 652 | /> 653 |
654 |
655 | 656 |
657 | 658 |
659 | handleAdvancedFilterChange('runtime_min', e.target.value ? parseInt(e.target.value) : '')} 664 | min={filterOptions.runtime_range.min} 665 | max={filterOptions.runtime_range.max} 666 | className="range-input" 667 | /> 668 | to 669 | handleAdvancedFilterChange('runtime_max', e.target.value ? parseInt(e.target.value) : '')} 674 | min={filterOptions.runtime_range.min} 675 | max={filterOptions.runtime_range.max} 676 | className="range-input" 677 | /> 678 |
679 |
680 |
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 | } --------------------------------------------------------------------------------