mirror of
https://github.com/acedanger/shell.git
synced 2026-03-24 23:41:50 -07:00
Compare commits
17 Commits
5b31d616f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d64041ff | ||
|
|
70ec810f58 | ||
|
|
9196879b6c | ||
|
|
dc8d35f593 | ||
|
|
c3af84b3e6 | ||
|
|
edae8513d1 | ||
|
|
8e7e22a1a3 | ||
|
|
2bae9bc6ce | ||
|
|
9bb99aecbf | ||
|
|
ddaa641668 | ||
|
|
b47f58fad7 | ||
|
|
4ce77211b5 | ||
|
|
e3b89032d4 | ||
|
|
06ee3dd672 | ||
|
|
07d9cc825f | ||
|
|
6760b74b97 | ||
|
|
33b1594bdf |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ dotfiles/my-aliases.zsh
|
|||||||
|
|
||||||
# Compiled binaries
|
# Compiled binaries
|
||||||
tui/tui
|
tui/tui
|
||||||
|
plex/DBRepair.sh
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ Usage Examples:
|
|||||||
1. List all running containers:
|
1. List all running containers:
|
||||||
$ dm list
|
$ dm list
|
||||||
|
|
||||||
2. Update a specific project (pulls latest images and recreates containers):
|
2. List only stacks with updates available:
|
||||||
|
$ dm list --update
|
||||||
|
|
||||||
|
3. Update a specific project (pulls latest images and recreates containers):
|
||||||
$ dm update media-server
|
$ dm update media-server
|
||||||
|
|
||||||
3. Stop all projects (with confirmation prompt):
|
3. Stop all projects (with confirmation prompt):
|
||||||
@@ -171,7 +174,7 @@ def run_command(cmd, cwd, capture_output=False):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def list_containers(projects):
|
def list_containers(projects, show_updates_only=False):
|
||||||
"""List running containers for all projects."""
|
"""List running containers for all projects."""
|
||||||
table = Table(title="Docker Containers", box=box.ROUNDED)
|
table = Table(title="Docker Containers", box=box.ROUNDED)
|
||||||
table.add_column("Project", style="cyan", no_wrap=True)
|
table.add_column("Project", style="cyan", no_wrap=True)
|
||||||
@@ -186,6 +189,11 @@ def list_containers(projects):
|
|||||||
|
|
||||||
found_any = False
|
found_any = False
|
||||||
for name, path in sorted(projects.items()):
|
for name, path in sorted(projects.items()):
|
||||||
|
|
||||||
|
# Buffer for project rows
|
||||||
|
project_rows = []
|
||||||
|
project_has_update = False
|
||||||
|
|
||||||
# Get running container names
|
# Get running container names
|
||||||
cmd = ["docker", "compose", "ps", "--format", "json"]
|
cmd = ["docker", "compose", "ps", "--format", "json"]
|
||||||
res = run_command(cmd, path, capture_output=True)
|
res = run_command(cmd, path, capture_output=True)
|
||||||
@@ -204,8 +212,6 @@ def list_containers(projects):
|
|||||||
if not c_name:
|
if not c_name:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
found_any = True
|
|
||||||
|
|
||||||
# Get version and update status
|
# Get version and update status
|
||||||
version = "unknown"
|
version = "unknown"
|
||||||
update_status = ""
|
update_status = ""
|
||||||
@@ -214,7 +220,7 @@ def list_containers(projects):
|
|||||||
norm_image = image
|
norm_image = image
|
||||||
tag = "latest"
|
tag = "latest"
|
||||||
|
|
||||||
# Remove tag if present (heuristic: split on last colon, if right side has no slashes)
|
# Remove tag if present
|
||||||
if ":" in norm_image:
|
if ":" in norm_image:
|
||||||
base, sep, t = norm_image.rpartition(":")
|
base, sep, t = norm_image.rpartition(":")
|
||||||
if sep and "/" not in t:
|
if sep and "/" not in t:
|
||||||
@@ -231,27 +237,21 @@ def list_containers(projects):
|
|||||||
if norm_image in diun_info:
|
if norm_image in diun_info:
|
||||||
image_tags = diun_info[norm_image]
|
image_tags = diun_info[norm_image]
|
||||||
|
|
||||||
# Only proceed if we have info for this specific tag
|
|
||||||
if tag in image_tags:
|
if tag in image_tags:
|
||||||
info = image_tags[tag]
|
info = image_tags[tag]
|
||||||
# Try to get version from Diun labels first
|
|
||||||
version = get_image_version(info.get("labels", {}))
|
version = get_image_version(info.get("labels", {}))
|
||||||
|
|
||||||
# Check for updates
|
|
||||||
# First get the Image ID of the running container
|
|
||||||
inspect_id_cmd = ["docker", "inspect", c_name, "--format", "{{.Image}}"]
|
inspect_id_cmd = ["docker", "inspect", c_name, "--format", "{{.Image}}"]
|
||||||
inspect_id_res = run_command(inspect_id_cmd, path, capture_output=True)
|
inspect_id_res = run_command(inspect_id_cmd, path, capture_output=True)
|
||||||
|
|
||||||
if inspect_id_res and inspect_id_res.returncode == 0:
|
if inspect_id_res and inspect_id_res.returncode == 0:
|
||||||
image_id = inspect_id_res.stdout.strip()
|
image_id = inspect_id_res.stdout.strip()
|
||||||
|
|
||||||
# Now inspect the Image ID to get RepoDigests
|
|
||||||
inspect_digest_cmd = ["docker", "inspect", image_id, "--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}"]
|
inspect_digest_cmd = ["docker", "inspect", image_id, "--format", "{{if .RepoDigests}}{{index .RepoDigests 0}}{{end}}"]
|
||||||
inspect_digest_res = run_command(inspect_digest_cmd, path, capture_output=True)
|
inspect_digest_res = run_command(inspect_digest_cmd, path, capture_output=True)
|
||||||
|
|
||||||
if inspect_digest_res and inspect_digest_res.returncode == 0:
|
if inspect_digest_res and inspect_digest_res.returncode == 0:
|
||||||
running_digest_full = inspect_digest_res.stdout.strip()
|
running_digest_full = inspect_digest_res.stdout.strip()
|
||||||
# running_digest is like name@sha256:hash
|
|
||||||
if "@" in running_digest_full:
|
if "@" in running_digest_full:
|
||||||
running_digest = running_digest_full.split("@")[1]
|
running_digest = running_digest_full.split("@")[1]
|
||||||
latest_digest = info.get("digest", "")
|
latest_digest = info.get("digest", "")
|
||||||
@@ -259,6 +259,7 @@ def list_containers(projects):
|
|||||||
if latest_digest:
|
if latest_digest:
|
||||||
if running_digest != latest_digest:
|
if running_digest != latest_digest:
|
||||||
update_status = "[bold red]Update Available[/bold red]"
|
update_status = "[bold red]Update Available[/bold red]"
|
||||||
|
project_has_update = True
|
||||||
else:
|
else:
|
||||||
update_status = "[green]Up to Date[/green]"
|
update_status = "[green]Up to Date[/green]"
|
||||||
else:
|
else:
|
||||||
@@ -266,7 +267,6 @@ def list_containers(projects):
|
|||||||
else:
|
else:
|
||||||
update_status = "[dim]Not Monitored[/dim]"
|
update_status = "[dim]Not Monitored[/dim]"
|
||||||
|
|
||||||
# If version is still unknown, try to get from running container labels
|
|
||||||
if version in ("latest", "unknown"):
|
if version in ("latest", "unknown"):
|
||||||
inspect_cmd = ["docker", "inspect", c_name, "--format", "{{json .Config.Labels}}"]
|
inspect_cmd = ["docker", "inspect", c_name, "--format", "{{json .Config.Labels}}"]
|
||||||
inspect_res = run_command(inspect_cmd, path, capture_output=True)
|
inspect_res = run_command(inspect_cmd, path, capture_output=True)
|
||||||
@@ -277,15 +277,27 @@ def list_containers(projects):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
table.add_row(name, c_name, state, image, version, update_status)
|
project_rows.append((name, c_name, state, image, version, update_status))
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# If not hiding, OR if update, add to table
|
||||||
|
if not show_updates_only or project_has_update:
|
||||||
|
if project_rows:
|
||||||
|
if found_any:
|
||||||
|
table.add_section()
|
||||||
|
found_any = True
|
||||||
|
for row in project_rows:
|
||||||
|
table.add_row(*row)
|
||||||
|
|
||||||
if found_any:
|
if found_any:
|
||||||
console.print(table)
|
console.print(table)
|
||||||
else:
|
else:
|
||||||
console.print("[yellow]No running containers found in managed projects.[/yellow]")
|
if show_updates_only:
|
||||||
|
console.print("[green]No updates available for running containers.[/green]")
|
||||||
|
else:
|
||||||
|
console.print("[yellow]No running containers found in managed projects.[/yellow]")
|
||||||
|
|
||||||
|
|
||||||
def is_project_running(path):
|
def is_project_running(path):
|
||||||
@@ -544,7 +556,8 @@ def main():
|
|||||||
dest="command", help="Command to execute")
|
dest="command", help="Command to execute")
|
||||||
|
|
||||||
# List command configuration
|
# List command configuration
|
||||||
subparsers.add_parser("list", help="List running containers")
|
list_parser = subparsers.add_parser("list", help="List running containers")
|
||||||
|
list_parser.add_argument("--update", action="store_true", help="Show only stacks that have updates available")
|
||||||
|
|
||||||
# Describe command configuration
|
# Describe command configuration
|
||||||
describe_parser = subparsers.add_parser("describe", help="Show details of a project's containers")
|
describe_parser = subparsers.add_parser("describe", help="Show details of a project's containers")
|
||||||
@@ -596,7 +609,7 @@ def main():
|
|||||||
|
|
||||||
# Dispatch commands
|
# Dispatch commands
|
||||||
if args.command == "list":
|
if args.command == "list":
|
||||||
list_containers(projects)
|
list_containers(projects, show_updates_only=args.update)
|
||||||
elif args.command == "describe":
|
elif args.command == "describe":
|
||||||
describe_project(projects, args.project)
|
describe_project(projects, args.project)
|
||||||
elif args.command == "volumes":
|
elif args.command == "volumes":
|
||||||
@@ -636,4 +649,7 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
try:
|
||||||
|
main()
|
||||||
|
finally:
|
||||||
|
console.show_cursor(True)
|
||||||
|
|||||||
18
pangolin/setup_backup_update_manager.sh
Executable file
18
pangolin/setup_backup_update_manager.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Define the list of script names
|
||||||
|
scripts=("backup-update-master.sh" "backup-ba-up-ma.sh" "config-ba-up-ma.sh" "cron-ba-up-ma.sh" "update-ba-up-ma.sh" "utils-ba-up-ma.sh")
|
||||||
|
|
||||||
|
# Define the base URL for the Gist raw files
|
||||||
|
base_url="https://raw.githubusercontent.com/hhftechnology/pangolin-backup-update/refs/heads/main"
|
||||||
|
|
||||||
|
# Download each script
|
||||||
|
for script in "${scripts[@]}"; do
|
||||||
|
curl -o "$script" "$base_url/$script"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Make all .sh files executable
|
||||||
|
chmod +x *.sh
|
||||||
|
|
||||||
|
# Run the master script
|
||||||
|
./backup-update-master.sh
|
||||||
3
plex/.gitignore
vendored
Normal file
3
plex/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# Plex Database Corruption Resolution Summary
|
|
||||||
|
|
||||||
## ✅ ISSUE RESOLVED: Auto-Repair Cycle Causing Corruption
|
|
||||||
|
|
||||||
### Root Cause Identified
|
|
||||||
The primary cause of your Plex database corruption was an **aggressive auto-repair schedule** running every 30 minutes via cron:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# PROBLEMATIC (FIXED):
|
|
||||||
*/30 * * * * /home/acedanger/shell/plex/backup-plex.sh --check-integrity --auto-repair
|
|
||||||
```
|
|
||||||
|
|
||||||
This caused:
|
|
||||||
- 48+ service stops/starts per day
|
|
||||||
- WAL file manipulation conflicts
|
|
||||||
- Repair cascading failures
|
|
||||||
- Race conditions during service transitions
|
|
||||||
|
|
||||||
### ✅ Changes Applied
|
|
||||||
|
|
||||||
#### 1. **Fixed Crontab Schedule**
|
|
||||||
- **Before**: Auto-repair every 30 minutes + daily backup with auto-repair
|
|
||||||
- **After**:
|
|
||||||
- Daily read-only integrity check (6 AM)
|
|
||||||
- Daily backup with auto-repair **disabled** (4:15 AM)
|
|
||||||
- Manual repair intervention required
|
|
||||||
|
|
||||||
#### 2. **Disabled Auto-Repair Default**
|
|
||||||
- Changed `backup-plex.sh` default from `AUTO_REPAIR=true` to `AUTO_REPAIR=false`
|
|
||||||
- Prevents automatic repair loops that were causing corruption
|
|
||||||
|
|
||||||
#### 3. **Created Consolidated Management Tool**
|
|
||||||
- New script: `plex-db-manager.sh`
|
|
||||||
- Safe, read-only integrity checking
|
|
||||||
- Manual repair intervention (currently disabled for safety)
|
|
||||||
- Proper service management with synchronization
|
|
||||||
|
|
||||||
#### 4. **Database Status** ✅
|
|
||||||
Current check shows: **ALL DATABASES HEALTHY**
|
|
||||||
- Main database: integrity check PASSED
|
|
||||||
- Blobs database: integrity check PASSED
|
|
||||||
|
|
||||||
## 📋 Script Redundancy Analysis
|
|
||||||
|
|
||||||
### Scripts with Overlapping Functionality
|
|
||||||
|
|
||||||
1. **`plex.sh`** - Service management + basic repair
|
|
||||||
2. **`backup-plex.sh`** - Backup + auto-repair logic
|
|
||||||
3. **`plex-database-repair.sh`** - Dedicated repair functions
|
|
||||||
4. **`recover-plex-database.sh`** - Advanced recovery methods
|
|
||||||
5. **`nuclear-plex-recovery.sh`** - Nuclear recovery
|
|
||||||
6. **`restore-plex.sh`** - Backup restoration
|
|
||||||
|
|
||||||
### Consolidation Recommendations
|
|
||||||
|
|
||||||
#### Keep Active:
|
|
||||||
- **`backup-plex.sh`** - Primary backup (with auto-repair disabled)
|
|
||||||
- **`plex-db-manager.sh`** - New consolidated management tool
|
|
||||||
- **`plex.sh`** - Basic service management
|
|
||||||
- **`nuclear-plex-recovery.sh`** - Last resort recovery
|
|
||||||
|
|
||||||
#### Consider Deprecating:
|
|
||||||
- **`plex-database-repair.sh`** - Functionality moved to `plex-db-manager.sh`
|
|
||||||
- **`recover-plex-database.sh`** - Similar functionality in other scripts
|
|
||||||
- **`restore-plex.sh`** - Basic functionality covered elsewhere
|
|
||||||
|
|
||||||
## 🛡️ Prevention Measures Implemented
|
|
||||||
|
|
||||||
### 1. **Conservative Backup Schedule**
|
|
||||||
```bash
|
|
||||||
# Read-only check (daily at 6 AM)
|
|
||||||
0 6 * * * backup-plex.sh --check-integrity --disable-auto-repair
|
|
||||||
|
|
||||||
# Backup without auto-repair (daily at 4:15 AM)
|
|
||||||
15 4 * * * backup-plex.sh --non-interactive --disable-auto-repair
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **Manual Intervention Required**
|
|
||||||
- No automatic repairs unless explicitly requested
|
|
||||||
- All repair operations require manual approval
|
|
||||||
- Comprehensive logging for audit trail
|
|
||||||
|
|
||||||
### 3. **Safe Service Management**
|
|
||||||
- Proper service stop/start synchronization
|
|
||||||
- Extended timeouts for clean shutdowns
|
|
||||||
- Race condition prevention
|
|
||||||
|
|
||||||
## 📊 Expected Improvements
|
|
||||||
|
|
||||||
1. **Stability**: Eliminated 47 daily service interruptions
|
|
||||||
2. **Reliability**: No more auto-repair corruption loops
|
|
||||||
3. **Performance**: Reduced I/O load on database files
|
|
||||||
4. **Maintainability**: Centralized database management
|
|
||||||
|
|
||||||
## 🔧 Usage Going Forward
|
|
||||||
|
|
||||||
### Regular Monitoring:
|
|
||||||
```bash
|
|
||||||
# Check database health (safe, read-only)
|
|
||||||
./plex-db-manager.sh check
|
|
||||||
```
|
|
||||||
|
|
||||||
### If Issues Detected:
|
|
||||||
```bash
|
|
||||||
# View detailed logs
|
|
||||||
tail -f /home/acedanger/shell/plex/logs/plex-backup-$(date +%Y-%m-%d).log
|
|
||||||
|
|
||||||
# Manual repair (when re-enabled)
|
|
||||||
./plex-db-manager.sh repair
|
|
||||||
```
|
|
||||||
|
|
||||||
### Emergency Recovery:
|
|
||||||
```bash
|
|
||||||
# Only if all else fails
|
|
||||||
sudo ./nuclear-plex-recovery.sh --auto
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚠️ Critical Notes
|
|
||||||
|
|
||||||
1. **Auto-repair is temporarily disabled** until stability is confirmed
|
|
||||||
2. **Manual intervention required** for any database issues
|
|
||||||
3. **Monitor logs closely** for the next week to ensure stability
|
|
||||||
4. **Backup integrity** should improve significantly
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Date Fixed**: June 21, 2025
|
|
||||||
**Issue**: 30-minute auto-repair cycle causing database corruption
|
|
||||||
**Resolution**: Disabled aggressive auto-repair, implemented safe backup schedule
|
|
||||||
**Status**: ✅ RESOLVED - Databases currently healthy
|
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
# Critical operations use explicit error checking instead of automatic exit
|
# Critical operations use explicit error checking instead of automatic exit
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Performance tracking variables (removed unused variables)
|
# Performance tracking variables (removed unused variables)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
# Color codes
|
# Color codes
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Plex database path
|
# Plex database path
|
||||||
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
set +e
|
set +e
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|||||||
@@ -55,12 +55,12 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Test configuration
|
# Test configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -49,13 +49,13 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
MAGENTA='\033[0;35m'
|
MAGENTA=$'\033[0;35m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -58,14 +58,15 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
PLEX_SQLITE="/usr/lib/plexmediaserver/Plex SQLite"
|
||||||
PLEX_USER="plex"
|
PLEX_USER="plex"
|
||||||
PLEX_GROUP="plex"
|
PLEX_GROUP="plex"
|
||||||
BACKUP_TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
BACKUP_TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||||
@@ -244,27 +245,66 @@ restore_from_backup() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to verify restored databases
|
# Function to verify restored databases (structural + FTS)
|
||||||
verify_databases() {
|
verify_databases() {
|
||||||
print_status "$YELLOW" "Verifying restored databases..."
|
print_status "$YELLOW" "Verifying restored databases..."
|
||||||
|
|
||||||
# Check main database
|
# Use Plex's bundled SQLite for ICU compatibility; fall back to system sqlite3
|
||||||
if sqlite3 "${PLEX_DB_DIR}/com.plexapp.plugins.library.db" "PRAGMA integrity_check;" | grep -q "ok"; then
|
local sqlite_bin="sqlite3"
|
||||||
print_status "$GREEN" "Main database integrity check: PASSED"
|
if [[ -x "$PLEX_SQLITE" ]]; then
|
||||||
else
|
sqlite_bin="$PLEX_SQLITE"
|
||||||
print_status "$RED" "Main database integrity check: FAILED"
|
|
||||||
return 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check blobs database
|
local overall_ok=true
|
||||||
if sqlite3 "${PLEX_DB_DIR}/com.plexapp.plugins.library.blobs.db" "PRAGMA integrity_check;" | grep -q "ok"; then
|
|
||||||
print_status "$GREEN" "Blobs database integrity check: PASSED"
|
for db_file in \
|
||||||
|
"${PLEX_DB_DIR}/com.plexapp.plugins.library.db" \
|
||||||
|
"${PLEX_DB_DIR}/com.plexapp.plugins.library.blobs.db"; do
|
||||||
|
|
||||||
|
local db_name
|
||||||
|
db_name=$(basename "$db_file")
|
||||||
|
|
||||||
|
if [[ ! -f "$db_file" ]]; then
|
||||||
|
print_status "$RED" "$db_name: NOT FOUND"
|
||||||
|
overall_ok=false
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Structural integrity
|
||||||
|
local result
|
||||||
|
result=$("$sqlite_bin" "$db_file" "PRAGMA integrity_check;" 2>&1)
|
||||||
|
if [[ "$result" == "ok" ]]; then
|
||||||
|
print_status "$GREEN" "$db_name structural integrity: PASSED"
|
||||||
|
else
|
||||||
|
print_status "$RED" "$db_name structural integrity: FAILED"
|
||||||
|
overall_ok=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# FTS index integrity
|
||||||
|
local fts_tables
|
||||||
|
fts_tables=$("$sqlite_bin" "$db_file" \
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%fts%';" 2>/dev/null) || true
|
||||||
|
if [[ -n "$fts_tables" ]]; then
|
||||||
|
while IFS= read -r table; do
|
||||||
|
[[ -z "$table" ]] && continue
|
||||||
|
local fts_result
|
||||||
|
fts_result=$("$sqlite_bin" "$db_file" \
|
||||||
|
"INSERT INTO ${table}(${table}) VALUES('integrity-check');" 2>&1) || true
|
||||||
|
if [[ -n "$fts_result" ]]; then
|
||||||
|
print_status "$RED" "$db_name FTS index '$table': DAMAGED"
|
||||||
|
overall_ok=false
|
||||||
|
fi
|
||||||
|
done <<< "$fts_tables"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$overall_ok" == true ]]; then
|
||||||
|
print_status "$GREEN" "All database integrity checks passed!"
|
||||||
|
return 0
|
||||||
else
|
else
|
||||||
print_status "$RED" "Blobs database integrity check: FAILED"
|
print_status "$RED" "One or more database checks failed!"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_status "$GREEN" "All database integrity checks passed!"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to fix ownership issues
|
# Function to fix ownership issues
|
||||||
|
|||||||
@@ -34,15 +34,15 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
readonly RED='\033[0;31m'
|
readonly RED=$'\033[0;31m'
|
||||||
readonly GREEN='\033[0;32m'
|
readonly GREEN=$'\033[0;32m'
|
||||||
readonly YELLOW='\033[1;33m'
|
readonly YELLOW=$'\033[1;33m'
|
||||||
readonly BLUE='\033[0;34m'
|
readonly BLUE=$'\033[0;34m'
|
||||||
readonly CYAN='\033[0;36m'
|
readonly CYAN=$'\033[0;36m'
|
||||||
readonly WHITE='\033[1;37m'
|
readonly WHITE=$'\033[1;37m'
|
||||||
readonly BOLD='\033[1m'
|
readonly BOLD=$'\033[1m'
|
||||||
readonly DIM='\033[2m'
|
readonly DIM=$'\033[2m'
|
||||||
readonly RESET='\033[0m'
|
readonly RESET=$'\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
readonly PLEX_SERVICE="plexmediaserver"
|
readonly PLEX_SERVICE="plexmediaserver"
|
||||||
@@ -56,6 +56,251 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|||||||
readonly SCRIPT_DIR
|
readonly SCRIPT_DIR
|
||||||
readonly LOG_FILE="$SCRIPT_DIR/logs/db-manager-$(date +%Y%m%d_%H%M%S).log"
|
readonly LOG_FILE="$SCRIPT_DIR/logs/db-manager-$(date +%Y%m%d_%H%M%S).log"
|
||||||
|
|
||||||
|
# DBRepair.sh location — searched in order: script dir, database dir, /usr/local/bin
|
||||||
|
readonly DBREPAIR_INSTALL_PATH="${SCRIPT_DIR}/DBRepair.sh"
|
||||||
|
readonly DBREPAIR_GITHUB_API="https://api.github.com/repos/ChuckPa/PlexDBRepair/releases"
|
||||||
|
readonly DBREPAIR_DOWNLOAD_BASE="https://github.com/ChuckPa/PlexDBRepair/releases/download"
|
||||||
|
|
||||||
|
find_dbrepair() {
|
||||||
|
local candidates=(
|
||||||
|
"${SCRIPT_DIR}/DBRepair.sh"
|
||||||
|
"${PLEX_DB_DIR}/DBRepair.sh"
|
||||||
|
"/usr/local/bin/DBRepair.sh"
|
||||||
|
)
|
||||||
|
for path in "${candidates[@]}"; do
|
||||||
|
if [[ -x "$path" ]]; then
|
||||||
|
echo "$path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest non-beta release tag from GitHub
|
||||||
|
_dbrepair_latest_release_tag() {
|
||||||
|
local tag
|
||||||
|
tag=$(curl -fsSL "${DBREPAIR_GITHUB_API}" 2>/dev/null \
|
||||||
|
| grep -Eo '"tag_name"\s*:\s*"[^"]+"' \
|
||||||
|
| head -n 1 \
|
||||||
|
| sed 's/"tag_name"\s*:\s*"//;s/"//')
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get currently installed DBRepair version
|
||||||
|
_dbrepair_installed_version() {
|
||||||
|
local bin="$1"
|
||||||
|
grep -oP 'Version\s+v\K[0-9.]+' "$bin" 2>/dev/null | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install or update DBRepair.sh
|
||||||
|
install_or_update_dbrepair() {
|
||||||
|
log_message "Checking DBRepair (ChuckPa/PlexDBRepair)..."
|
||||||
|
|
||||||
|
local latest_tag
|
||||||
|
if ! latest_tag=$(_dbrepair_latest_release_tag); then
|
||||||
|
log_error "Failed to query GitHub for the latest release"
|
||||||
|
log_warning "Check your internet connection or try: https://github.com/ChuckPa/PlexDBRepair/releases"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_message "Latest stable release: ${latest_tag}"
|
||||||
|
|
||||||
|
local dbrepair_bin
|
||||||
|
if dbrepair_bin=$(find_dbrepair); then
|
||||||
|
local installed_ver
|
||||||
|
installed_ver=$(_dbrepair_installed_version "$dbrepair_bin")
|
||||||
|
local remote_ver
|
||||||
|
remote_ver=$(echo "$latest_tag" | sed 's/^v//')
|
||||||
|
|
||||||
|
if [[ -n "$installed_ver" ]]; then
|
||||||
|
log_message "Installed version: v${installed_ver} at ${dbrepair_bin}"
|
||||||
|
if [[ "$installed_ver" == "$remote_ver" ]]; then
|
||||||
|
log_success "DBRepair is already up to date (v${installed_ver})"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_warning "Update available: v${installed_ver} -> ${latest_tag}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warning "Installed at ${dbrepair_bin} (version unknown), will update"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_message "DBRepair not found — installing to ${DBREPAIR_INSTALL_PATH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local download_url="${DBREPAIR_DOWNLOAD_BASE}/${latest_tag}/DBRepair.sh"
|
||||||
|
log_message "Downloading ${download_url}"
|
||||||
|
if curl -fsSL -o "${DBREPAIR_INSTALL_PATH}" "$download_url"; then
|
||||||
|
chmod +x "${DBREPAIR_INSTALL_PATH}"
|
||||||
|
log_success "DBRepair ${latest_tag} installed to ${DBREPAIR_INSTALL_PATH}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Download failed"
|
||||||
|
rm -f "${DBREPAIR_INSTALL_PATH}" 2>/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Suggest installing DBRepair when errors are found and it's not available
|
||||||
|
_hint_install_dbrepair() {
|
||||||
|
if ! find_dbrepair >/dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
log_warning "DBRepair is NOT installed. It can fix most database issues automatically."
|
||||||
|
echo -e " ${CYAN}Install it now: $(basename "$0") install-dbrepair${RESET}"
|
||||||
|
echo -e " ${CYAN}Then repair: $(basename "$0") repair${RESET}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# List and manage database backup files
|
||||||
|
list_db_backups() {
|
||||||
|
local db_dir="$PLEX_DB_DIR"
|
||||||
|
local -a backup_files=()
|
||||||
|
local -a backup_paths=()
|
||||||
|
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
backup_paths+=("$entry")
|
||||||
|
done < <(sudo find "$db_dir" -maxdepth 1 \( \
|
||||||
|
-name '*-BACKUP-*' -o \
|
||||||
|
-name '*-BKUP-*' -o \
|
||||||
|
-name '*.backup.*' -o \
|
||||||
|
-name '*recovery*' -o \
|
||||||
|
-name 'corrupted-*' -o \
|
||||||
|
-name '*-BLOATED-*' \
|
||||||
|
\) -print0 2>/dev/null | sort -z)
|
||||||
|
|
||||||
|
if [[ ${#backup_paths[@]} -eq 0 ]]; then
|
||||||
|
log_message "No database backup files found in the Plex database directory"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${BOLD}${WHITE} # Type Size Created Name${RESET}"
|
||||||
|
echo -e " --- ------------- ----------- ------------------------ ------------------------------------"
|
||||||
|
|
||||||
|
local idx=0
|
||||||
|
for entry in "${backup_paths[@]}"; do
|
||||||
|
((idx++))
|
||||||
|
local name
|
||||||
|
name=$(basename "$entry")
|
||||||
|
|
||||||
|
local type_label
|
||||||
|
if [[ "$name" == *-BACKUP-* || "$name" == *-BKUP-* ]]; then
|
||||||
|
type_label="DBRepair"
|
||||||
|
elif [[ "$name" == *-BLOATED-* ]]; then
|
||||||
|
type_label="Bloated"
|
||||||
|
elif [[ "$name" == *.backup.* ]]; then
|
||||||
|
type_label="Script"
|
||||||
|
elif [[ "$name" == corrupted-* ]]; then
|
||||||
|
type_label="Corrupted"
|
||||||
|
elif [[ "$name" == *recovery* ]]; then
|
||||||
|
type_label="Recovery"
|
||||||
|
else
|
||||||
|
type_label="Other"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local size
|
||||||
|
if [[ -d "$entry" ]]; then
|
||||||
|
size=$(sudo du -sh "$entry" 2>/dev/null | cut -f1)
|
||||||
|
type_label="${type_label}/dir"
|
||||||
|
else
|
||||||
|
size=$(sudo stat --printf='%s' "$entry" 2>/dev/null)
|
||||||
|
if [[ -n "$size" ]]; then
|
||||||
|
size=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||||
|
else
|
||||||
|
size="?"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local created
|
||||||
|
created=$(sudo stat --printf='%y' "$entry" 2>/dev/null | cut -d. -f1)
|
||||||
|
[[ -z "$created" ]] && created="unknown"
|
||||||
|
|
||||||
|
printf " ${WHITE}%-3s${RESET} ${CYAN}%-13s${RESET} ${YELLOW}%-11s${RESET} %-24s %s\n" \
|
||||||
|
"$idx" "$type_label" "$size" "$created" "$name"
|
||||||
|
|
||||||
|
backup_files+=("$entry")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e " --- ------------- ----------- ------------------------ ------------------------------------"
|
||||||
|
echo -e " Total: ${idx} backup file(s)\n"
|
||||||
|
|
||||||
|
_BACKUP_LIST=("${backup_files[@]}")
|
||||||
|
_BACKUP_COUNT=$idx
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive backup deletion
|
||||||
|
delete_db_backups_interactive() {
|
||||||
|
list_db_backups
|
||||||
|
if [[ ${_BACKUP_COUNT:-0} -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}Enter backup number(s) to delete (comma-separated), or 'q' to cancel:${RESET} "
|
||||||
|
read -r selection
|
||||||
|
|
||||||
|
if [[ "$selection" == "q" || -z "$selection" ]]; then
|
||||||
|
log_message "Cancelled"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
IFS=',' read -ra nums <<< "$selection"
|
||||||
|
local deleted=0
|
||||||
|
for num in "${nums[@]}"; do
|
||||||
|
num=$(echo "$num" | tr -d ' ')
|
||||||
|
if ! [[ "$num" =~ ^[0-9]+$ ]] || (( num < 1 || num > _BACKUP_COUNT )); then
|
||||||
|
log_error "Invalid selection: $num (skipping)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local target="${_BACKUP_LIST[$((num-1))]}"
|
||||||
|
local target_name
|
||||||
|
target_name=$(basename "$target")
|
||||||
|
echo -e "${YELLOW}Delete ${target_name}? [y/N]:${RESET} "
|
||||||
|
read -r confirm
|
||||||
|
if [[ "${confirm,,}" == "y" ]]; then
|
||||||
|
if [[ -d "$target" ]]; then
|
||||||
|
sudo rm -rf "$target"
|
||||||
|
else
|
||||||
|
sudo rm -f "$target"
|
||||||
|
fi
|
||||||
|
log_success "Deleted: $target_name"
|
||||||
|
((deleted++))
|
||||||
|
else
|
||||||
|
log_message "Skipped: $target_name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
log_message "Deleted $deleted backup(s)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete backup by name/pattern
|
||||||
|
delete_db_backup_by_name() {
|
||||||
|
local pattern="$1"
|
||||||
|
local db_dir="$PLEX_DB_DIR"
|
||||||
|
local found=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
local name
|
||||||
|
name=$(basename "$entry")
|
||||||
|
echo -e "${YELLOW}Delete ${name}? [y/N]:${RESET} "
|
||||||
|
read -r confirm
|
||||||
|
if [[ "${confirm,,}" == "y" ]]; then
|
||||||
|
if [[ -d "$entry" ]]; then
|
||||||
|
sudo rm -rf "$entry"
|
||||||
|
else
|
||||||
|
sudo rm -f "$entry"
|
||||||
|
fi
|
||||||
|
log_success "Deleted: $name"
|
||||||
|
((found++))
|
||||||
|
fi
|
||||||
|
done < <(sudo find "$db_dir" -maxdepth 1 -name "*${pattern}*" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
log_error "No backups matching '${pattern}' found"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
log_message "Deleted $found file(s)"
|
||||||
|
}
|
||||||
|
|
||||||
# Create log directory
|
# Create log directory
|
||||||
mkdir -p "$SCRIPT_DIR/logs"
|
mkdir -p "$SCRIPT_DIR/logs"
|
||||||
|
|
||||||
@@ -210,26 +455,54 @@ check_database_integrity() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run integrity check
|
# Run structural integrity check
|
||||||
local integrity_result
|
local integrity_result
|
||||||
integrity_result=$(sudo "$PLEX_SQLITE" "$db_file" "PRAGMA integrity_check;" 2>&1)
|
integrity_result=$(sudo "$PLEX_SQLITE" "$db_file" "PRAGMA integrity_check;" 2>&1)
|
||||||
local check_exit_code=$?
|
local check_exit_code=$?
|
||||||
|
|
||||||
if [[ $check_exit_code -ne 0 ]]; then
|
if [[ $check_exit_code -ne 0 && -z "$integrity_result" ]]; then
|
||||||
log_error "Failed to run integrity check on $db_name"
|
log_error "Failed to open database: $db_name (exit code $check_exit_code)"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if echo "$integrity_result" | grep -q "^ok$"; then
|
local struct_ok=true
|
||||||
log_success "Database integrity check passed: $db_name"
|
if [[ "$integrity_result" == "ok" ]]; then
|
||||||
return 0
|
log_success "Structural integrity check passed: $db_name"
|
||||||
else
|
else
|
||||||
log_warning "Database integrity issues detected in $db_name:"
|
struct_ok=false
|
||||||
echo "$integrity_result" | while IFS= read -r line; do
|
log_warning "Structural integrity issues detected in $db_name:"
|
||||||
|
echo "$integrity_result" | head -n 10 | while IFS= read -r line; do
|
||||||
log_warning " $line"
|
log_warning " $line"
|
||||||
done
|
done
|
||||||
return 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# FTS (Full-Text Search) index integrity check
|
||||||
|
# Standard PRAGMA integrity_check does NOT detect FTS corruption.
|
||||||
|
local fts_ok=true
|
||||||
|
local fts_tables
|
||||||
|
fts_tables=$(sudo "$PLEX_SQLITE" "$db_file" \
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%fts%';" 2>/dev/null) || true
|
||||||
|
|
||||||
|
if [[ -n "$fts_tables" ]]; then
|
||||||
|
log_message "Checking FTS (Full-Text Search) indexes in $db_name..."
|
||||||
|
while IFS= read -r table; do
|
||||||
|
[[ -z "$table" ]] && continue
|
||||||
|
local fts_result
|
||||||
|
fts_result=$(sudo "$PLEX_SQLITE" "$db_file" \
|
||||||
|
"INSERT INTO ${table}(${table}) VALUES('integrity-check');" 2>&1) || true
|
||||||
|
if [[ -n "$fts_result" ]]; then
|
||||||
|
fts_ok=false
|
||||||
|
log_warning "FTS index '${table}' — DAMAGED: $fts_result"
|
||||||
|
else
|
||||||
|
log_success "FTS index '${table}' — OK"
|
||||||
|
fi
|
||||||
|
done <<< "$fts_tables"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$struct_ok" == true && "$fts_ok" == true ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check all databases
|
# Check all databases
|
||||||
@@ -254,26 +527,35 @@ check_all_databases() {
|
|||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
log_warning "Found integrity issues in $issues database(s)"
|
log_warning "Found integrity issues in $issues database(s)"
|
||||||
|
_hint_install_dbrepair
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
show_help() {
|
show_help() {
|
||||||
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}$(basename "$0")${RESET} ${YELLOW}<command>${RESET} ${DIM}[options]${RESET}"
|
local script
|
||||||
|
script=$(basename "$0")
|
||||||
|
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${script}${RESET} ${YELLOW}<command>${RESET} ${DIM}[options]${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}${WHITE}Commands:${RESET}"
|
echo -e "${BOLD}${WHITE}Commands:${RESET}"
|
||||||
echo -e " ${GREEN}${BOLD}check${RESET} Read-only database integrity check"
|
printf " ${GREEN}${BOLD}%-18s${RESET} %s\n" "check" "Read-only database integrity check"
|
||||||
echo -e " ${YELLOW}${BOLD}repair${RESET} Interactive database repair"
|
printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair" "Interactive database repair"
|
||||||
echo -e " ${YELLOW}${BOLD}repair --gentle${RESET} Gentle repair methods only"
|
printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair --gentle" "Gentle repair methods only"
|
||||||
echo -e " ${RED}${BOLD}repair --force${RESET} Aggressive repair methods"
|
printf " ${RED}${BOLD}%-18s${RESET} %s\n" "repair --force" "Aggressive repair methods"
|
||||||
echo -e " ${RED}${BOLD}nuclear${RESET} Nuclear recovery (replace from backup)"
|
printf " ${RED}${BOLD}%-18s${RESET} %s\n" "nuclear" "Nuclear recovery (replace from backup)"
|
||||||
echo -e " ${CYAN}${BOLD}help${RESET} Show this help message"
|
printf " ${CYAN}${BOLD}%-18s${RESET} %s\n" "backups" "List and manage database backup files"
|
||||||
|
printf " ${GREEN}${BOLD}%-18s${RESET} %s\n" "install-dbrepair" "Install or update DBRepair tool"
|
||||||
|
printf " ${CYAN}${BOLD}%-18s${RESET} %s\n" "help" "Show this help message"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}${WHITE}Examples:${RESET}"
|
echo -e "${BOLD}${WHITE}Examples:${RESET}"
|
||||||
echo -e " ${DIM}$(basename "$0") check # Safe integrity check${RESET}"
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} check" "Safe integrity check"
|
||||||
echo -e " ${DIM}$(basename "$0") repair # Interactive repair${RESET}"
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} repair" "Interactive repair"
|
||||||
echo -e " ${DIM}$(basename "$0") repair --gentle # Minimal repair only${RESET}"
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} repair --gentle" "Minimal repair only"
|
||||||
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups" "List DB backups"
|
||||||
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups delete" "Interactive backup deletion"
|
||||||
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups delete --name foo" "Delete by name pattern"
|
||||||
|
printf " ${DIM}%-46s # %s${RESET}\n" "${script} install-dbrepair" "Install/update DBRepair"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}${YELLOW}⚠️ WARNING:${RESET} Always run ${CYAN}check${RESET} first before attempting repairs!"
|
echo -e "${BOLD}${YELLOW}⚠️ WARNING:${RESET} Always run ${CYAN}check${RESET} first before attempting repairs!"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -315,20 +597,72 @@ main() {
|
|||||||
;;
|
;;
|
||||||
|
|
||||||
"repair")
|
"repair")
|
||||||
echo -e "${RED}${BOLD}⚠️ REPAIR FUNCTIONALITY TEMPORARILY DISABLED${RESET}"
|
print_header
|
||||||
echo -e "${YELLOW}Database repairs are disabled until corruption issues are resolved.${RESET}"
|
check_prerequisites
|
||||||
echo -e "${CYAN}Use the individual repair scripts if manual intervention is needed:${RESET}"
|
|
||||||
echo -e " ${DIM}- plex-database-repair.sh${RESET}"
|
local dbrepair_bin
|
||||||
echo -e " ${DIM}- recover-plex-database.sh${RESET}"
|
if dbrepair_bin=$(find_dbrepair); then
|
||||||
echo -e " ${DIM}- nuclear-plex-recovery.sh${RESET}"
|
log_success "Found DBRepair.sh: $dbrepair_bin"
|
||||||
exit 2
|
log_message "Running: stop → auto (check + repair + reindex + FTS rebuild) → start → exit"
|
||||||
|
if sudo "$dbrepair_bin" stop auto start exit; then
|
||||||
|
log_success "DBRepair automatic repair completed successfully"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log_error "DBRepair automatic repair failed"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}${BOLD}⚠️ DBRepair.sh NOT FOUND${RESET}"
|
||||||
|
echo -e "${YELLOW}You can install it automatically:${RESET}"
|
||||||
|
echo -e " ${CYAN}$(basename "$0") install-dbrepair${RESET}"
|
||||||
|
echo -e "${YELLOW}Then re-run: $(basename "$0") repair${RESET}"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
"nuclear")
|
"nuclear")
|
||||||
echo -e "${RED}${BOLD}⚠️ NUCLEAR RECOVERY TEMPORARILY DISABLED${RESET}"
|
print_header
|
||||||
echo -e "${YELLOW}Nuclear recovery is disabled until corruption issues are resolved.${RESET}"
|
check_prerequisites
|
||||||
echo -e "${CYAN}Use nuclear-plex-recovery.sh directly if absolutely necessary.${RESET}"
|
|
||||||
exit 2
|
echo -e "\n${RED}${BOLD}⚠️ WARNING: NUCLEAR RECOVERY ⚠️${RESET}"
|
||||||
|
echo -e "${RED}This replaces your Plex database with the best available PMS backup!${RESET}"
|
||||||
|
echo -e "${YELLOW}All changes since the backup was created will be lost.${RESET}\n"
|
||||||
|
|
||||||
|
echo -e "${CYAN}Type 'YES' to proceed: ${RESET}"
|
||||||
|
read -r confirmation
|
||||||
|
if [[ "$confirmation" != "YES" ]]; then
|
||||||
|
log_message "Nuclear recovery cancelled by user"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local dbrepair_bin
|
||||||
|
if dbrepair_bin=$(find_dbrepair); then
|
||||||
|
log_success "Found DBRepair.sh: $dbrepair_bin"
|
||||||
|
log_message "Running: stop → replace → reindex → start → exit"
|
||||||
|
if sudo "$dbrepair_bin" stop replace reindex start exit; then
|
||||||
|
log_success "Nuclear recovery (replace from backup) completed"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log_error "Nuclear recovery failed"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fallback to dedicated nuclear script
|
||||||
|
local nuclear_script="${SCRIPT_DIR}/nuclear-plex-recovery.sh"
|
||||||
|
if [[ -x "$nuclear_script" ]]; then
|
||||||
|
log_message "DBRepair not found, falling back to nuclear-plex-recovery.sh"
|
||||||
|
if sudo "$nuclear_script" --auto; then
|
||||||
|
log_success "Nuclear recovery completed"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log_error "Nuclear recovery failed"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "Neither DBRepair.sh nor nuclear-plex-recovery.sh found"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
"help"|"--help"|"-h")
|
"help"|"--help"|"-h")
|
||||||
@@ -336,6 +670,24 @@ main() {
|
|||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
"install-dbrepair"|"update-dbrepair"|"dbrepair")
|
||||||
|
print_header
|
||||||
|
install_or_update_dbrepair
|
||||||
|
;;
|
||||||
|
|
||||||
|
"backups"|"backup-list")
|
||||||
|
print_header
|
||||||
|
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
|
||||||
|
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
|
||||||
|
delete_db_backup_by_name "$4"
|
||||||
|
else
|
||||||
|
delete_db_backups_interactive
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
list_db_backups
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
print_header
|
print_header
|
||||||
log_error "Unknown command: $1"
|
log_error "Unknown command: $1"
|
||||||
|
|||||||
720
plex/plex.sh
720
plex/plex.sh
@@ -57,24 +57,285 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# 🎨 Color definitions for sexy output
|
# 🎨 Color definitions for sexy output
|
||||||
readonly RED='\033[0;31m'
|
readonly RED=$'\033[0;31m'
|
||||||
readonly GREEN='\033[0;32m'
|
readonly GREEN=$'\033[0;32m'
|
||||||
readonly YELLOW='\033[1;33m'
|
readonly YELLOW=$'\033[1;33m'
|
||||||
readonly BLUE='\033[0;34m'
|
readonly BLUE=$'\033[0;34m'
|
||||||
readonly PURPLE='\033[0;35m'
|
readonly PURPLE=$'\033[0;35m'
|
||||||
readonly CYAN='\033[0;36m'
|
readonly CYAN=$'\033[0;36m'
|
||||||
readonly WHITE='\033[1;37m'
|
readonly WHITE=$'\033[1;37m'
|
||||||
readonly BOLD='\033[1m'
|
readonly BOLD=$'\033[1m'
|
||||||
readonly DIM='\033[2m'
|
readonly DIM=$'\033[2m'
|
||||||
readonly RESET='\033[0m'
|
readonly RESET=$'\033[0m'
|
||||||
|
|
||||||
# 🔧 Configuration
|
# 🔧 Configuration
|
||||||
readonly PLEX_SERVICE="plexmediaserver"
|
readonly PLEX_SERVICE="plexmediaserver"
|
||||||
|
readonly PLEX_SQLITE="/usr/lib/plexmediaserver/Plex SQLite"
|
||||||
|
readonly PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
SCRIPT_NAME="$(basename "$0")"
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
readonly SCRIPT_NAME
|
readonly SCRIPT_NAME
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
readonly SCRIPT_DIR
|
readonly SCRIPT_DIR
|
||||||
|
|
||||||
|
# DBRepair.sh location — searched in order: script dir, database dir, /usr/local/bin
|
||||||
|
readonly DBREPAIR_INSTALL_PATH="${SCRIPT_DIR}/DBRepair.sh"
|
||||||
|
readonly DBREPAIR_GITHUB_API="https://api.github.com/repos/ChuckPa/PlexDBRepair/releases"
|
||||||
|
readonly DBREPAIR_DOWNLOAD_BASE="https://github.com/ChuckPa/PlexDBRepair/releases/download"
|
||||||
|
|
||||||
|
find_dbrepair() {
|
||||||
|
local candidates=(
|
||||||
|
"${SCRIPT_DIR}/DBRepair.sh"
|
||||||
|
"${PLEX_DB_DIR}/DBRepair.sh"
|
||||||
|
"/usr/local/bin/DBRepair.sh"
|
||||||
|
)
|
||||||
|
for path in "${candidates[@]}"; do
|
||||||
|
if [[ -x "$path" ]]; then
|
||||||
|
echo "$path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the latest non-beta release tag from GitHub
|
||||||
|
_dbrepair_latest_release_tag() {
|
||||||
|
# Fetch releases, filter out pre-releases & drafts, grab the first tag_name
|
||||||
|
local tag
|
||||||
|
tag=$(curl -fsSL "${DBREPAIR_GITHUB_API}" 2>/dev/null \
|
||||||
|
| grep -Eo '"tag_name"\s*:\s*"[^"]+"' \
|
||||||
|
| head -n 1 \
|
||||||
|
| sed 's/"tag_name"\s*:\s*"//;s/"//')
|
||||||
|
if [[ -z "$tag" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get currently installed DBRepair version (from its own version output)
|
||||||
|
_dbrepair_installed_version() {
|
||||||
|
local bin="$1"
|
||||||
|
# DBRepair prints "Version vX.YY.ZZ" near the top when run interactively;
|
||||||
|
# we can also grep the script file itself for the version string.
|
||||||
|
grep -oP 'Version\s+v\K[0-9.]+' "$bin" 2>/dev/null | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install or update DBRepair.sh
|
||||||
|
install_or_update_dbrepair() {
|
||||||
|
print_status "${INFO}" "Checking DBRepair (ChuckPa/PlexDBRepair)..." "${BLUE}"
|
||||||
|
|
||||||
|
# Determine latest non-beta release
|
||||||
|
local latest_tag
|
||||||
|
if ! latest_tag=$(_dbrepair_latest_release_tag); then
|
||||||
|
print_status "${CROSS}" "Failed to query GitHub for the latest release" "${RED}"
|
||||||
|
print_status "${INFO}" "Check your internet connection or try: https://github.com/ChuckPa/PlexDBRepair/releases" "${YELLOW}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
print_status "${INFO}" "Latest stable release: ${latest_tag}" "${BLUE}"
|
||||||
|
|
||||||
|
# Check if already installed
|
||||||
|
local dbrepair_bin
|
||||||
|
if dbrepair_bin=$(find_dbrepair); then
|
||||||
|
local installed_ver
|
||||||
|
installed_ver=$(_dbrepair_installed_version "$dbrepair_bin")
|
||||||
|
local remote_ver
|
||||||
|
remote_ver=$(echo "$latest_tag" | sed 's/^v//')
|
||||||
|
|
||||||
|
if [[ -n "$installed_ver" ]]; then
|
||||||
|
print_status "${INFO}" "Installed version: v${installed_ver} at ${dbrepair_bin}" "${BLUE}"
|
||||||
|
if [[ "$installed_ver" == "$remote_ver" ]]; then
|
||||||
|
print_status "${CHECKMARK}" "DBRepair is already up to date (v${installed_ver})" "${GREEN}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
print_status "${INFO}" "Update available: v${installed_ver} -> ${latest_tag}" "${YELLOW}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "${INFO}" "Installed at ${dbrepair_bin} (version unknown), will update" "${YELLOW}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "${INFO}" "DBRepair not found — installing to ${DBREPAIR_INSTALL_PATH}" "${BLUE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download
|
||||||
|
local download_url="${DBREPAIR_DOWNLOAD_BASE}/${latest_tag}/DBRepair.sh"
|
||||||
|
print_status "${INFO}" "Downloading ${download_url}" "${BLUE}"
|
||||||
|
if curl -fsSL -o "${DBREPAIR_INSTALL_PATH}" "$download_url"; then
|
||||||
|
chmod +x "${DBREPAIR_INSTALL_PATH}"
|
||||||
|
print_status "${CHECKMARK}" "DBRepair ${latest_tag} installed to ${DBREPAIR_INSTALL_PATH}" "${GREEN}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
print_status "${CROSS}" "Download failed" "${RED}"
|
||||||
|
rm -f "${DBREPAIR_INSTALL_PATH}" 2>/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Suggest installing DBRepair when errors are found and it's not available
|
||||||
|
_hint_install_dbrepair() {
|
||||||
|
if ! find_dbrepair >/dev/null 2>&1; then
|
||||||
|
echo ""
|
||||||
|
print_status "${INFO}" "DBRepair is NOT installed. It can fix most database issues automatically." "${YELLOW}"
|
||||||
|
echo -e "${DIM}${CYAN} Install it now: ${SCRIPT_NAME} install-dbrepair${RESET}"
|
||||||
|
echo -e "${DIM}${CYAN} Then repair: ${SCRIPT_NAME} repair${RESET}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📦 List and manage database backup files
|
||||||
|
# Covers: DBRepair backups (-BACKUP-*, -BKUP-*), script backups (*.backup.*),
|
||||||
|
# corrupted dirs (corrupted-*), recovery files (*recovery*)
|
||||||
|
list_db_backups() {
|
||||||
|
local db_dir="$PLEX_DB_DIR"
|
||||||
|
local -a backup_files=()
|
||||||
|
local -a backup_paths=()
|
||||||
|
|
||||||
|
# Collect all backup-like files and dirs
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
backup_paths+=("$entry")
|
||||||
|
done < <(sudo find "$db_dir" -maxdepth 1 \( \
|
||||||
|
-name '*-BACKUP-*' -o \
|
||||||
|
-name '*-BKUP-*' -o \
|
||||||
|
-name '*.backup.*' -o \
|
||||||
|
-name '*recovery*' -o \
|
||||||
|
-name 'corrupted-*' -o \
|
||||||
|
-name '*-BLOATED-*' \
|
||||||
|
\) -print0 2>/dev/null | sort -z)
|
||||||
|
|
||||||
|
if [[ ${#backup_paths[@]} -eq 0 ]]; then
|
||||||
|
print_status "${INFO}" "No database backup files found in the Plex database directory" "${YELLOW}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${BOLD}${WHITE} # Type Size Created Name${RESET}"
|
||||||
|
echo -e "${DIM}${CYAN} --- ------------- ----------- ------------------------ ------------------------------------${RESET}"
|
||||||
|
|
||||||
|
local idx=0
|
||||||
|
for entry in "${backup_paths[@]}"; do
|
||||||
|
((idx++))
|
||||||
|
local name
|
||||||
|
name=$(basename "$entry")
|
||||||
|
|
||||||
|
# Determine type label
|
||||||
|
local type_label
|
||||||
|
if [[ "$name" == *-BACKUP-* || "$name" == *-BKUP-* ]]; then
|
||||||
|
type_label="DBRepair"
|
||||||
|
elif [[ "$name" == *-BLOATED-* ]]; then
|
||||||
|
type_label="Bloated"
|
||||||
|
elif [[ "$name" == *.backup.* ]]; then
|
||||||
|
type_label="Script"
|
||||||
|
elif [[ "$name" == corrupted-* ]]; then
|
||||||
|
type_label="Corrupted"
|
||||||
|
elif [[ "$name" == *recovery* ]]; then
|
||||||
|
type_label="Recovery"
|
||||||
|
else
|
||||||
|
type_label="Other"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Size (human-readable)
|
||||||
|
local size
|
||||||
|
if [[ -d "$entry" ]]; then
|
||||||
|
size=$(sudo du -sh "$entry" 2>/dev/null | cut -f1)
|
||||||
|
type_label="${type_label}/dir"
|
||||||
|
else
|
||||||
|
size=$(sudo stat --printf='%s' "$entry" 2>/dev/null)
|
||||||
|
if [[ -n "$size" ]]; then
|
||||||
|
size=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||||
|
else
|
||||||
|
size="?"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Created date
|
||||||
|
local created
|
||||||
|
created=$(sudo stat --printf='%y' "$entry" 2>/dev/null | cut -d. -f1)
|
||||||
|
[[ -z "$created" ]] && created="unknown"
|
||||||
|
|
||||||
|
printf " ${WHITE}%-3s${RESET} ${CYAN}%-13s${RESET} ${YELLOW}%-11s${RESET} ${DIM}%-24s${RESET} %s\n" \
|
||||||
|
"$idx" "$type_label" "$size" "$created" "$name"
|
||||||
|
|
||||||
|
backup_files+=("$entry")
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${DIM}${CYAN} --- ------------- ----------- ------------------------ ------------------------------------${RESET}"
|
||||||
|
echo -e " ${DIM}Total: ${idx} backup file(s)${RESET}\n"
|
||||||
|
|
||||||
|
# Store for use by delete function
|
||||||
|
_BACKUP_LIST=("${backup_files[@]}")
|
||||||
|
_BACKUP_COUNT=$idx
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive backup deletion
|
||||||
|
delete_db_backups_interactive() {
|
||||||
|
list_db_backups
|
||||||
|
if [[ ${_BACKUP_COUNT:-0} -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}Enter backup number(s) to delete (comma-separated), or 'q' to cancel:${RESET} "
|
||||||
|
read -r selection
|
||||||
|
|
||||||
|
if [[ "$selection" == "q" || -z "$selection" ]]; then
|
||||||
|
print_status "${INFO}" "Cancelled" "${YELLOW}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse comma-separated numbers
|
||||||
|
IFS=',' read -ra nums <<< "$selection"
|
||||||
|
local deleted=0
|
||||||
|
for num in "${nums[@]}"; do
|
||||||
|
num=$(echo "$num" | tr -d ' ')
|
||||||
|
if ! [[ "$num" =~ ^[0-9]+$ ]] || (( num < 1 || num > _BACKUP_COUNT )); then
|
||||||
|
print_status "${CROSS}" "Invalid selection: $num (skipping)" "${RED}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local target="${_BACKUP_LIST[$((num-1))]}"
|
||||||
|
local target_name
|
||||||
|
target_name=$(basename "$target")
|
||||||
|
echo -e "${YELLOW}Delete ${target_name}? [y/N]:${RESET} "
|
||||||
|
read -r confirm
|
||||||
|
if [[ "${confirm,,}" == "y" ]]; then
|
||||||
|
if [[ -d "$target" ]]; then
|
||||||
|
sudo rm -rf "$target"
|
||||||
|
else
|
||||||
|
sudo rm -f "$target"
|
||||||
|
fi
|
||||||
|
print_status "${CHECKMARK}" "Deleted: $target_name" "${GREEN}"
|
||||||
|
((deleted++))
|
||||||
|
else
|
||||||
|
print_status "${INFO}" "Skipped: $target_name" "${YELLOW}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
print_status "${INFO}" "Deleted $deleted backup(s)" "${BLUE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete backup by name/pattern (for scripted use)
|
||||||
|
delete_db_backup_by_name() {
|
||||||
|
local pattern="$1"
|
||||||
|
local db_dir="$PLEX_DB_DIR"
|
||||||
|
local found=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' entry; do
|
||||||
|
local name
|
||||||
|
name=$(basename "$entry")
|
||||||
|
echo -e "${YELLOW}Delete ${name}? [y/N]:${RESET} "
|
||||||
|
read -r confirm
|
||||||
|
if [[ "${confirm,,}" == "y" ]]; then
|
||||||
|
if [[ -d "$entry" ]]; then
|
||||||
|
sudo rm -rf "$entry"
|
||||||
|
else
|
||||||
|
sudo rm -f "$entry"
|
||||||
|
fi
|
||||||
|
print_status "${CHECKMARK}" "Deleted: $name" "${GREEN}"
|
||||||
|
((found++))
|
||||||
|
fi
|
||||||
|
done < <(sudo find "$db_dir" -maxdepth 1 -name "*${pattern}*" -print0 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ $found -eq 0 ]]; then
|
||||||
|
print_status "${CROSS}" "No backups matching '${pattern}' found" "${RED}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
print_status "${INFO}" "Deleted $found file(s)" "${BLUE}"
|
||||||
|
}
|
||||||
|
|
||||||
# 🎭 ASCII symbols for compatible output
|
# 🎭 ASCII symbols for compatible output
|
||||||
readonly CHECKMARK="[✓]"
|
readonly CHECKMARK="[✓]"
|
||||||
readonly CROSS="[✗]"
|
readonly CROSS="[✗]"
|
||||||
@@ -114,218 +375,229 @@ show_loading() {
|
|||||||
printf "\r%s%s %s %s%s\n" "${CYAN}" "${HOURGLASS}" "${message}" "${CHECKMARK}" "${RESET}"
|
printf "\r%s%s %s %s%s\n" "${CYAN}" "${HOURGLASS}" "${message}" "${CHECKMARK}" "${RESET}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# 🔧 Enhanced function to repair database issues
|
# 🔧 Repair database using ChuckPa/DBRepair when available, else manual steps
|
||||||
|
# DBRepair: https://github.com/ChuckPa/PlexDBRepair
|
||||||
repair_database() {
|
repair_database() {
|
||||||
print_status "${INFO}" "Attempting to repair Plex database..." "${BLUE}"
|
print_status "${INFO}" "Attempting to repair Plex database..." "${BLUE}"
|
||||||
|
|
||||||
local db_dir="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
local main_db="$PLEX_DB_DIR/com.plexapp.plugins.library.db"
|
||||||
local main_db="$db_dir/com.plexapp.plugins.library.db"
|
|
||||||
local backup_db="$db_dir/com.plexapp.plugins.library.db.backup.$(date +%Y%m%d_%H%M%S)"
|
|
||||||
local corrupted_dir="$db_dir/corrupted-$(date +%Y%m%d_%H%M%S)"
|
|
||||||
|
|
||||||
if [[ ! -f "$main_db" ]]; then
|
if [[ ! -f "$main_db" ]]; then
|
||||||
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------- Try DBRepair.sh (preferred) ----------
|
||||||
|
local dbrepair_bin
|
||||||
|
if dbrepair_bin=$(find_dbrepair); then
|
||||||
|
print_status "${CHECKMARK}" "Found DBRepair.sh: $dbrepair_bin" "${GREEN}"
|
||||||
|
print_status "${INFO}" "Running: stop → auto (check + repair + reindex + FTS rebuild) → start → exit" "${BLUE}"
|
||||||
|
|
||||||
|
# DBRepair supports scripted mode — pass commands directly
|
||||||
|
if sudo "$dbrepair_bin" stop auto start exit; then
|
||||||
|
print_status "${CHECKMARK}" "DBRepair automatic repair completed successfully!" "${GREEN}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
print_status "${CROSS}" "DBRepair automatic repair failed (exit code $?)" "${RED}"
|
||||||
|
print_status "${INFO}" "Falling back to manual repair steps..." "${YELLOW}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "${INFO}" "DBRepair.sh not found — using manual repair" "${YELLOW}"
|
||||||
|
echo -e "${DIM}${CYAN} For better repairs, install DBRepair:${RESET}"
|
||||||
|
echo -e "${DIM}${CYAN} wget -O ${SCRIPT_DIR}/DBRepair.sh https://github.com/ChuckPa/PlexDBRepair/releases/latest/download/DBRepair.sh${RESET}"
|
||||||
|
echo -e "${DIM}${CYAN} chmod +x ${SCRIPT_DIR}/DBRepair.sh${RESET}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- Manual fallback (same approach DBRepair uses internally) ----------
|
||||||
|
# Prefer Plex's bundled SQLite for ICU compatibility
|
||||||
|
local sqlite_bin="sqlite3"
|
||||||
|
if [[ -x "$PLEX_SQLITE" ]]; then
|
||||||
|
sqlite_bin="$PLEX_SQLITE"
|
||||||
|
fi
|
||||||
|
|
||||||
# Stop Plex service first
|
# Stop Plex service first
|
||||||
print_status "${INFO}" "Stopping Plex service..." "${BLUE}"
|
print_status "${INFO}" "Stopping Plex service..." "${BLUE}"
|
||||||
sudo systemctl stop "$PLEX_SERVICE" 2>/dev/null || true
|
sudo systemctl stop "$PLEX_SERVICE" 2>/dev/null || true
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
# Check if critical tables exist
|
# Step 1: WAL checkpoint
|
||||||
print_status "${INFO}" "Checking database structure..." "${BLUE}"
|
if [[ -f "${main_db}-wal" ]]; then
|
||||||
local has_metadata_table=false
|
print_status "${INFO}" "Checkpointing WAL journal..." "${BLUE}"
|
||||||
if sudo -u plex sqlite3 "$main_db" "SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_items';" 2>/dev/null | grep -q metadata_items; then
|
sudo -u plex "$sqlite_bin" "$main_db" "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
|
||||||
has_metadata_table=true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$has_metadata_table" == "false" ]]; then
|
# Step 2: Export → import (the core of DBRepair's repair/optimize)
|
||||||
print_status "${CROSS}" "Critical table 'metadata_items' is missing! Database is severely corrupted." "${RED}"
|
local ts
|
||||||
print_status "${INFO}" "Attempting recovery from available backups..." "${YELLOW}"
|
ts=$(date +%Y%m%d_%H%M%S)
|
||||||
|
local sql_dump="/tmp/plex_dump_${ts}.sql"
|
||||||
|
local new_db="/tmp/plex_repaired_${ts}.db"
|
||||||
|
local backup_db="$PLEX_DB_DIR/com.plexapp.plugins.library.db-BACKUP-${ts}"
|
||||||
|
|
||||||
# Find the best recovery candidate
|
print_status "${INFO}" "Exporting database to SQL..." "${BLUE}"
|
||||||
local recovery_db=""
|
if sudo -u plex "$sqlite_bin" "$main_db" ".dump" | sudo -u plex tee "$sql_dump" >/dev/null 2>&1; then
|
||||||
local recovery_candidates=(
|
print_status "${CHECKMARK}" "SQL export completed ($(du -sh "$sql_dump" | cut -f1))" "${GREEN}"
|
||||||
"$db_dir/com.plexapp.plugins.library.db.recovery-"*
|
else
|
||||||
"$db_dir/com.plexapp.plugins.library.db.20"*
|
print_status "${CROSS}" "SQL export failed — database may be too damaged for this method" "${RED}"
|
||||||
)
|
rm -f "$sql_dump" 2>/dev/null
|
||||||
|
print_status "${INFO}" "Install DBRepair.sh for more robust repair capabilities" "${YELLOW}"
|
||||||
for candidate in "${recovery_candidates[@]}"; do
|
|
||||||
if [[ -f "$candidate" && "$candidate" != *"tmp"* && "$candidate" != *"empty"* ]]; then
|
|
||||||
# Test if this candidate has the metadata_items table
|
|
||||||
if sudo -u plex sqlite3 "$candidate" "SELECT name FROM sqlite_master WHERE type='table' AND name='metadata_items';" 2>/dev/null | grep -q metadata_items; then
|
|
||||||
recovery_db="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -n "$recovery_db" ]]; then
|
|
||||||
print_status "${CHECKMARK}" "Found recovery database: $(basename "$recovery_db")" "${GREEN}"
|
|
||||||
|
|
||||||
# Move corrupted database to backup location
|
|
||||||
print_status "${INFO}" "Moving corrupted database to backup location..." "${BLUE}"
|
|
||||||
sudo mkdir -p "$corrupted_dir"
|
|
||||||
sudo mv "$main_db" "$corrupted_dir/"
|
|
||||||
sudo mv "$main_db-shm" "$corrupted_dir/" 2>/dev/null || true
|
|
||||||
sudo mv "$main_db-wal" "$corrupted_dir/" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Copy recovery database as new main database
|
|
||||||
print_status "${INFO}" "Restoring database from recovery file..." "${BLUE}"
|
|
||||||
if sudo cp "$recovery_db" "$main_db"; then
|
|
||||||
sudo chown plex:plex "$main_db"
|
|
||||||
sudo chmod 644 "$main_db"
|
|
||||||
print_status "${CHECKMARK}" "Database restored successfully!" "${GREEN}"
|
|
||||||
|
|
||||||
# Verify the restored database
|
|
||||||
print_status "${INFO}" "Verifying restored database..." "${BLUE}"
|
|
||||||
local integrity_result
|
|
||||||
integrity_result=$(sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" 2>&1)
|
|
||||||
|
|
||||||
if echo "$integrity_result" | grep -q "ok"; then
|
|
||||||
print_status "${CHECKMARK}" "Restored database integrity verified!" "${GREEN}"
|
|
||||||
return 0
|
|
||||||
elif echo "$integrity_result" | grep -q "no such collation sequence: icu"; then
|
|
||||||
print_status "${CROSS}" "ICU collation sequence issue detected!" "${YELLOW}"
|
|
||||||
print_status "${INFO}" "Attempting ICU-aware recovery..." "${BLUE}"
|
|
||||||
|
|
||||||
# Try ICU-aware recovery script
|
|
||||||
local icu_script="${SCRIPT_DIR}/icu-aware-recovery.sh"
|
|
||||||
if [[ -f "$icu_script" ]]; then
|
|
||||||
if "$icu_script" --auto; then
|
|
||||||
print_status "${CHECKMARK}" "ICU-aware recovery completed!" "${GREEN}"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "ICU-aware recovery failed!" "${RED}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_status "${INFO}" "ICU recovery script not found, trying manual fix..." "${YELLOW}"
|
|
||||||
|
|
||||||
# Try to recreate database without ICU dependencies
|
|
||||||
local temp_db="/tmp/plex_temp_$(date +%Y%m%d_%H%M%S).db"
|
|
||||||
print_status "${INFO}" "Attempting to dump and recreate database..." "${BLUE}"
|
|
||||||
|
|
||||||
if sudo -u plex sqlite3 "$recovery_db" ".dump" | grep -v "COLLATE icu_" | sudo -u plex sqlite3 "$temp_db"; then
|
|
||||||
print_status "${INFO}" "Database dump successful, replacing main database..." "${BLUE}"
|
|
||||||
sudo mv "$temp_db" "$main_db"
|
|
||||||
sudo chown plex:plex "$main_db"
|
|
||||||
sudo chmod 644 "$main_db"
|
|
||||||
|
|
||||||
# Verify the recreated database
|
|
||||||
if sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
|
|
||||||
print_status "${CHECKMARK}" "Database recreated successfully without ICU!" "${GREEN}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up temp file if it exists
|
|
||||||
sudo rm -f "$temp_db" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_status "${CROSS}" "Failed to resolve ICU collation issues!" "${RED}"
|
|
||||||
return 1
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "Restored database failed integrity check!" "${RED}"
|
|
||||||
print_status "${INFO}" "Integrity check result:" "${YELLOW}"
|
|
||||||
echo -e "${DIM}${YELLOW} $integrity_result${RESET}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "Failed to restore database!" "${RED}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "No valid recovery databases found!" "${RED}"
|
|
||||||
print_status "${INFO}" "Available options:" "${YELLOW}"
|
|
||||||
echo -e "${DIM}${YELLOW} 1. Check manual backups in /mnt/share/media/backups/plex/${RESET}"
|
|
||||||
echo -e "${DIM}${YELLOW} 2. Let Plex rebuild database (will lose all metadata)${RESET}"
|
|
||||||
echo -e "${DIM}${YELLOW} 3. Run: sudo rm '$main_db' && sudo systemctl start plexmediaserver${RESET}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create backup of current database
|
|
||||||
print_status "${INFO}" "Creating backup of current database..." "${BLUE}"
|
|
||||||
if ! sudo cp "$main_db" "$backup_db"; then
|
|
||||||
print_status "${CROSS}" "Failed to create database backup!" "${RED}"
|
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_status "${CHECKMARK}" "Database backed up to: $backup_db" "${GREEN}"
|
print_status "${INFO}" "Importing into fresh database..." "${BLUE}"
|
||||||
|
if sudo cat "$sql_dump" | sudo -u plex "$sqlite_bin" "$new_db" 2>/dev/null; then
|
||||||
# Try to vacuum the database
|
print_status "${CHECKMARK}" "Import into fresh database succeeded" "${GREEN}"
|
||||||
print_status "${INFO}" "Running VACUUM on database..." "${BLUE}"
|
|
||||||
if sudo -u plex sqlite3 "$main_db" "VACUUM;" 2>/dev/null; then
|
|
||||||
print_status "${CHECKMARK}" "Database VACUUM completed successfully" "${GREEN}"
|
|
||||||
|
|
||||||
# Test integrity again
|
|
||||||
if sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
|
|
||||||
print_status "${CHECKMARK}" "Database integrity restored!" "${GREEN}"
|
|
||||||
print_status "${INFO}" "You can now try starting Plex again" "${BLUE}"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "Database still corrupted after VACUUM" "${RED}"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
print_status "${CROSS}" "VACUUM operation failed" "${RED}"
|
print_status "${CROSS}" "Import failed" "${RED}"
|
||||||
|
rm -f "$sql_dump" "$new_db" 2>/dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
rm -f "$sql_dump" 2>/dev/null
|
||||||
|
|
||||||
|
# Step 3: Verify repaired database (structural + FTS)
|
||||||
|
print_status "${INFO}" "Verifying repaired database..." "${BLUE}"
|
||||||
|
local verify_out
|
||||||
|
verify_out=$(sudo -u plex "$sqlite_bin" "$new_db" "PRAGMA integrity_check;" 2>&1)
|
||||||
|
if [[ "$verify_out" != "ok" ]]; then
|
||||||
|
print_status "${CROSS}" "Repaired database failed integrity check" "${RED}"
|
||||||
|
rm -f "$new_db" 2>/dev/null
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Try reindex as last resort
|
# Step 4: Reindex (rebuilds all indexes INCLUDING FTS)
|
||||||
print_status "${INFO}" "Attempting REINDEX operation..." "${BLUE}"
|
print_status "${INFO}" "Rebuilding indexes (including FTS)..." "${BLUE}"
|
||||||
if sudo -u plex sqlite3 "$main_db" "REINDEX;" 2>/dev/null; then
|
sudo -u plex "$sqlite_bin" "$new_db" "REINDEX;" 2>/dev/null || true
|
||||||
print_status "${CHECKMARK}" "Database REINDEX completed" "${GREEN}"
|
|
||||||
|
|
||||||
# Test integrity one more time
|
# Rebuild FTS index content explicitly
|
||||||
if sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
|
local fts_tables
|
||||||
print_status "${CHECKMARK}" "Database integrity restored after REINDEX!" "${GREEN}"
|
fts_tables=$(sudo -u plex "$sqlite_bin" "$new_db" \
|
||||||
return 0
|
"SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%fts%';" 2>/dev/null) || true
|
||||||
|
if [[ -n "$fts_tables" ]]; then
|
||||||
|
while IFS= read -r table; do
|
||||||
|
[[ -z "$table" ]] && continue
|
||||||
|
print_status "${INFO}" "Rebuilding FTS index: $table" "${BLUE}"
|
||||||
|
sudo -u plex "$sqlite_bin" "$new_db" \
|
||||||
|
"INSERT INTO ${table}(${table}) VALUES('rebuild');" 2>/dev/null || true
|
||||||
|
done <<< "$fts_tables"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 5: Swap databases
|
||||||
|
print_status "${INFO}" "Backing up old database and activating repaired copy..." "${BLUE}"
|
||||||
|
sudo cp "$main_db" "$backup_db"
|
||||||
|
sudo mv "$new_db" "$main_db"
|
||||||
|
sudo chown plex:plex "$main_db"
|
||||||
|
sudo chmod 644 "$main_db"
|
||||||
|
# Remove stale journals for the old database
|
||||||
|
sudo rm -f "${main_db}-shm" "${main_db}-wal" 2>/dev/null || true
|
||||||
|
|
||||||
|
print_status "${CHECKMARK}" "Database repaired and activated. Backup at: $(basename "$backup_db")" "${GREEN}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 🔍 Function to check FTS (Full-Text Search) index integrity
|
||||||
|
# Standard PRAGMA integrity_check does NOT detect FTS corruption.
|
||||||
|
# This is the exact class of damage shown in the user's screenshot.
|
||||||
|
check_fts_integrity() {
|
||||||
|
local db_file="$1"
|
||||||
|
local sqlite_bin="${2:-sqlite3}" # Use Plex SQLite if available
|
||||||
|
local issues=0
|
||||||
|
|
||||||
|
# Discover FTS tables dynamically from sqlite_master
|
||||||
|
local fts_tables
|
||||||
|
fts_tables=$(sudo -u plex "$sqlite_bin" "$db_file" \
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND sql LIKE '%fts%';" 2>/dev/null) || return 0
|
||||||
|
|
||||||
|
if [[ -z "$fts_tables" ]]; then
|
||||||
|
return 0 # No FTS tables — nothing to check
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_status "${INFO}" "Checking FTS (Full-Text Search) indexes..." "${BLUE}"
|
||||||
|
|
||||||
|
while IFS= read -r table; do
|
||||||
|
[[ -z "$table" ]] && continue
|
||||||
|
local result
|
||||||
|
result=$(sudo -u plex "$sqlite_bin" "$db_file" \
|
||||||
|
"INSERT INTO ${table}(${table}) VALUES('integrity-check');" 2>&1) || true
|
||||||
|
if [[ -n "$result" ]]; then
|
||||||
|
print_status "${CROSS}" "FTS index '${table}' — DAMAGED" "${RED}"
|
||||||
|
echo -e "${DIM}${YELLOW} $result${RESET}"
|
||||||
|
((issues++))
|
||||||
|
else
|
||||||
|
print_status "${CHECKMARK}" "FTS index '${table}' — OK" "${GREEN}"
|
||||||
fi
|
fi
|
||||||
|
done <<< "$fts_tables"
|
||||||
|
|
||||||
|
if (( issues > 0 )); then
|
||||||
|
print_status "${CROSS}" "FTS integrity check complete. $issues index(es) damaged." "${RED}"
|
||||||
|
print_status "${INFO}" "Run: ${SCRIPT_NAME} repair (uses DBRepair reindex to rebuild FTS)" "${YELLOW}"
|
||||||
|
return 1
|
||||||
fi
|
fi
|
||||||
|
return 0
|
||||||
print_status "${CROSS}" "All repair attempts failed" "${RED}"
|
|
||||||
print_status "${INFO}" "Manual intervention required. Consider:" "${YELLOW}"
|
|
||||||
echo -e "${DIM}${YELLOW} 1. Restore from external backup using restore-plex.sh${RESET}"
|
|
||||||
echo -e "${DIM}${YELLOW} 2. Use nuclear recovery: ./nuclear-plex-recovery.sh${RESET}"
|
|
||||||
echo -e "${DIM}${YELLOW} 3. Check corrupted database moved to: $corrupted_dir${RESET}"
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 🔍 Function to check database integrity
|
# 🔍 Function to check database integrity
|
||||||
check_database_integrity() {
|
check_database_integrity() {
|
||||||
print_status "${INFO}" "Checking database integrity..." "${BLUE}"
|
print_status "${INFO}" "Checking database integrity..." "${BLUE}"
|
||||||
|
|
||||||
local db_dir="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
local main_db="$PLEX_DB_DIR/com.plexapp.plugins.library.db"
|
||||||
local main_db="$db_dir/com.plexapp.plugins.library.db"
|
|
||||||
local repair_script="${SCRIPT_DIR}/plex-database-repair.sh"
|
|
||||||
|
|
||||||
if [[ ! -f "$main_db" ]]; then
|
if [[ ! -f "$main_db" ]]; then
|
||||||
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use shared repair script for integrity checking if available
|
# Prefer Plex's bundled SQLite for ICU compatibility
|
||||||
if [[ -f "$repair_script" ]]; then
|
local sqlite_bin="sqlite3"
|
||||||
if "$repair_script" check "$main_db" >/dev/null 2>&1; then
|
if [[ -x "$PLEX_SQLITE" ]]; then
|
||||||
print_status "${CHECKMARK}" "Database integrity check passed" "${GREEN}"
|
sqlite_bin="$PLEX_SQLITE"
|
||||||
return 0
|
fi
|
||||||
else
|
|
||||||
print_status "${CROSS}" "Database integrity check failed!" "${RED}"
|
|
||||||
print_status "${INFO}" "Consider running database repair: plex repair" "${YELLOW}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Fallback to basic sqlite3 check
|
|
||||||
if ! sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" >/dev/null 2>&1; then
|
|
||||||
print_status "${CROSS}" "Database integrity check failed!" "${RED}"
|
|
||||||
print_status "${INFO}" "Consider running database repair or restore from backup" "${YELLOW}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_status "${CHECKMARK}" "Database integrity check passed" "${GREEN}"
|
# Clean up stale WAL/SHM journals left by non-clean shutdowns.
|
||||||
|
if [[ -f "${main_db}-wal" ]]; then
|
||||||
|
print_status "${INFO}" "WAL journal found — attempting checkpoint before integrity check..." "${BLUE}"
|
||||||
|
if ! sudo -u plex "$sqlite_bin" "$main_db" "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null; then
|
||||||
|
print_status "${INFO}" "WAL checkpoint failed (non-critical, continuing check)" "${YELLOW}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Standard structural integrity check ---
|
||||||
|
local integrity_output
|
||||||
|
local sqlite_exit_code=0
|
||||||
|
integrity_output=$(sudo -u plex "$sqlite_bin" "$main_db" "PRAGMA integrity_check;" 2>&1) || sqlite_exit_code=$?
|
||||||
|
|
||||||
|
local struct_ok=true
|
||||||
|
|
||||||
|
if [[ "$integrity_output" == "ok" ]]; then
|
||||||
|
print_status "${CHECKMARK}" "Database structural integrity check passed" "${GREEN}"
|
||||||
|
elif [[ $sqlite_exit_code -ne 0 && -z "$integrity_output" ]]; then
|
||||||
|
print_status "${CROSS}" "sqlite3 failed to open the database (exit code $sqlite_exit_code)" "${RED}"
|
||||||
|
print_status "${INFO}" "Check file permissions and ensure Plex is fully stopped" "${YELLOW}"
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
struct_ok=false
|
||||||
|
print_status "${CROSS}" "Database structural integrity check reported issues:" "${RED}"
|
||||||
|
echo "$integrity_output" | head -n 5 | while IFS= read -r line; do
|
||||||
|
echo -e "${DIM}${YELLOW} $line${RESET}"
|
||||||
|
done
|
||||||
|
local total_lines
|
||||||
|
total_lines=$(echo "$integrity_output" | wc -l)
|
||||||
|
if (( total_lines > 5 )); then
|
||||||
|
echo -e "${DIM}${YELLOW} ... and $((total_lines - 5)) more issue(s)${RESET}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- FTS index integrity (the most common unreported corruption) ---
|
||||||
|
local fts_ok=true
|
||||||
|
if ! check_fts_integrity "$main_db" "$sqlite_bin"; then
|
||||||
|
fts_ok=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$struct_ok" == true && "$fts_ok" == true ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
print_status "${INFO}" "Consider running database repair: ${SCRIPT_NAME} repair" "${YELLOW}"
|
||||||
|
_hint_install_dbrepair
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# <20>🚀 Enhanced start function
|
# <20>🚀 Enhanced start function
|
||||||
@@ -341,11 +613,12 @@ start_plex() {
|
|||||||
# Reset any failed state first
|
# Reset any failed state first
|
||||||
sudo systemctl reset-failed "$PLEX_SERVICE" 2>/dev/null || true
|
sudo systemctl reset-failed "$PLEX_SERVICE" 2>/dev/null || true
|
||||||
|
|
||||||
# Check database integrity before starting
|
# Check database integrity before starting (warn only — don't block startup).
|
||||||
|
# Many "failures" are benign WAL journal leftovers that Plex resolves on its own.
|
||||||
if ! check_database_integrity; then
|
if ! check_database_integrity; then
|
||||||
print_status "${CROSS}" "Database integrity issues detected. Service may fail to start." "${RED}"
|
print_status "${INFO}" "Database integrity issues detected — starting Plex anyway (it may self-repair)." "${YELLOW}"
|
||||||
echo -e "${DIM}${YELLOW} Try: sudo systemctl stop plexmediaserver && sudo -u plex sqlite3 /var/lib/plexmediaserver/Library/Application\ Support/Plex\ Media\ Server/Plug-in\ Support/Databases/com.plexapp.plugins.library.db 'VACUUM;'${RESET}"
|
echo -e "${DIM}${YELLOW} If Plex fails to start, run: ${SCRIPT_NAME} repair${RESET}"
|
||||||
return 1
|
_hint_install_dbrepair
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_status "${INFO}" "Attempting to start service..." "${BLUE}"
|
print_status "${INFO}" "Attempting to start service..." "${BLUE}"
|
||||||
@@ -502,25 +775,57 @@ show_help() {
|
|||||||
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${SCRIPT_NAME}${RESET} ${YELLOW}<command>${RESET}"
|
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${SCRIPT_NAME}${RESET} ${YELLOW}<command>${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}${WHITE}Available Commands:${RESET}"
|
echo -e "${BOLD}${WHITE}Available Commands:${RESET}"
|
||||||
echo -e " ${GREEN}${BOLD}start${RESET} ${ROCKET} Start Plex Media Server"
|
printf " ${GREEN}${BOLD}%-18s${RESET} %s %s\n" "start" "${ROCKET}" "Start Plex Media Server"
|
||||||
echo -e " ${YELLOW}${BOLD}stop${RESET} ${STOP_SIGN} Stop Plex Media Server"
|
printf " ${YELLOW}${BOLD}%-18s${RESET} %s %s\n" "stop" "${STOP_SIGN}" "Stop Plex Media Server"
|
||||||
echo -e " ${BLUE}${BOLD}restart${RESET} ${RECYCLE} Restart Plex Media Server"
|
printf " ${BLUE}${BOLD}%-18s${RESET} %s %s\n" "restart" "${RECYCLE}" "Restart Plex Media Server"
|
||||||
echo -e " ${CYAN}${BOLD}status${RESET} ${INFO} Show detailed service status"
|
printf " ${CYAN}${BOLD}%-18s${RESET} %s %s\n" "status" "${INFO}" "Show detailed service status"
|
||||||
echo -e " ${PURPLE}${BOLD}scan${RESET} ${SPARKLES} Library scanner operations"
|
printf " ${PURPLE}${BOLD}%-18s${RESET} %s %s\n" "scan" "${SPARKLES}" "Library scanner operations"
|
||||||
echo -e " ${RED}${BOLD}repair${RESET} [!] Repair database corruption issues"
|
printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "repair" "[!]" "Repair database corruption issues"
|
||||||
echo -e " ${RED}${BOLD}nuclear${RESET} [!!] Nuclear database recovery (last resort)"
|
printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "nuclear" "[!!]" "Nuclear database recovery (last resort)"
|
||||||
echo -e " ${PURPLE}${BOLD}help${RESET} [*] Show this help message"
|
printf " ${CYAN}${BOLD}%-18s${RESET} %s %s\n" "backups" "[#]" "List and manage database backup files"
|
||||||
|
printf " ${GREEN}${BOLD}%-18s${RESET} %s %s\n" "install-dbrepair" "[+]" "Install or update DBRepair tool"
|
||||||
|
printf " ${WHITE}${BOLD}%-18s${RESET} %s %s\n" "tui" "[>]" "Launch interactive TUI dashboard"
|
||||||
|
printf " ${PURPLE}${BOLD}%-18s${RESET} %s %s\n" "help" "${HOURGLASS}" "Show this help message"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${DIM}${WHITE}Examples:${RESET}"
|
echo -e "${DIM}${WHITE}Examples:${RESET}"
|
||||||
echo -e " ${DIM}${SCRIPT_NAME} start # Start the Plex service${RESET}"
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} start" "Start the Plex service"
|
||||||
echo -e " ${DIM}${SCRIPT_NAME} status # Show current status${RESET}"
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} status" "Show current status"
|
||||||
echo -e " ${DIM}${SCRIPT_NAME} scan # Launch library scanner interface${RESET}"
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} scan" "Launch library scanner interface"
|
||||||
echo -e " ${DIM}${SCRIPT_NAME} repair # Fix database issues${RESET}"
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} repair" "Fix database issues"
|
||||||
echo -e " ${DIM}${SCRIPT_NAME} nuclear # Complete database replacement${RESET}"
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} nuclear" "Complete database replacement"
|
||||||
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups" "List and manage DB backups"
|
||||||
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups delete" "Interactive backup deletion"
|
||||||
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups delete --name foo" "Delete by name pattern"
|
||||||
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} install-dbrepair" "Install/update DBRepair"
|
||||||
|
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} tui" "Launch full TUI dashboard"
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 📚 Function to launch library scanner
|
# <EFBFBD>️ Function to launch the Python TUI dashboard
|
||||||
|
launch_tui() {
|
||||||
|
local venv_dir="${SCRIPT_DIR}/.venv"
|
||||||
|
local tui_app="${SCRIPT_DIR}/tui/app.py"
|
||||||
|
|
||||||
|
if [[ ! -f "$tui_app" ]]; then
|
||||||
|
print_status "${CROSS}" "TUI application not found: $tui_app" "${RED}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create venv and install dependencies if needed
|
||||||
|
if [[ ! -d "$venv_dir" ]]; then
|
||||||
|
print_status "${INFO}" "Setting up Python environment (first run)..." "${BLUE}"
|
||||||
|
if ! python3 -m venv "$venv_dir" 2>/dev/null; then
|
||||||
|
print_status "${CROSS}" "Failed to create Python venv. Install python3-venv." "${RED}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
"${venv_dir}/bin/pip" install --quiet textual
|
||||||
|
print_status "${CHECKMARK}" "Python environment ready" "${GREEN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${venv_dir}/bin/python3" "$tui_app"
|
||||||
|
}
|
||||||
|
|
||||||
|
# <20>📚 Function to launch library scanner
|
||||||
launch_scanner() {
|
launch_scanner() {
|
||||||
print_status "${SPARKLES}" "Launching Plex Library Scanner..." "${PURPLE}"
|
print_status "${SPARKLES}" "Launching Plex Library Scanner..." "${PURPLE}"
|
||||||
|
|
||||||
@@ -684,6 +989,23 @@ main() {
|
|||||||
"nuclear"|"nuke")
|
"nuclear"|"nuke")
|
||||||
nuclear_recovery
|
nuclear_recovery
|
||||||
;;
|
;;
|
||||||
|
"install-dbrepair"|"update-dbrepair"|"dbrepair")
|
||||||
|
install_or_update_dbrepair
|
||||||
|
;;
|
||||||
|
"tui"|"dashboard"|"ui")
|
||||||
|
launch_tui
|
||||||
|
;;
|
||||||
|
"backups"|"backup-list")
|
||||||
|
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
|
||||||
|
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
|
||||||
|
delete_db_backup_by_name "$4"
|
||||||
|
else
|
||||||
|
delete_db_backups_interactive
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
list_db_backups
|
||||||
|
fi
|
||||||
|
;;
|
||||||
"help"|"--help"|"-h")
|
"help"|"--help"|"-h")
|
||||||
show_help
|
show_help
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -44,19 +44,21 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# 🎨 Color definitions for styled output
|
# 🎨 Color definitions for styled output
|
||||||
readonly RED='\033[0;31m'
|
readonly RED=$'\033[0;31m'
|
||||||
readonly GREEN='\033[0;32m'
|
readonly GREEN=$'\033[0;32m'
|
||||||
readonly YELLOW='\033[1;33m'
|
readonly YELLOW=$'\033[1;33m'
|
||||||
readonly BLUE='\033[0;34m'
|
readonly BLUE=$'\033[0;34m'
|
||||||
readonly PURPLE='\033[0;35m'
|
readonly PURPLE=$'\033[0;35m'
|
||||||
readonly CYAN='\033[0;36m'
|
readonly CYAN=$'\033[0;36m'
|
||||||
readonly WHITE='\033[1;37m'
|
readonly WHITE=$'\033[1;37m'
|
||||||
readonly BOLD='\033[1m'
|
readonly BOLD=$'\033[1m'
|
||||||
readonly DIM='\033[2m'
|
readonly DIM=$'\033[2m'
|
||||||
readonly RESET='\033[0m'
|
readonly RESET=$'\033[0m'
|
||||||
|
|
||||||
# 🔧 Configuration
|
# 🔧 Configuration
|
||||||
readonly PLEX_SERVICE="plexmediaserver"
|
readonly PLEX_SERVICE="plexmediaserver"
|
||||||
|
readonly PLEX_PREFS="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml"
|
||||||
|
readonly PLEX_API_BASE="http://localhost:32400"
|
||||||
readonly SCRIPT_NAME="$(basename "$0")"
|
readonly SCRIPT_NAME="$(basename "$0")"
|
||||||
readonly SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
readonly SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
readonly LOG_DIR="${SCRIPT_DIR}/../logs"
|
readonly LOG_DIR="${SCRIPT_DIR}/../logs"
|
||||||
@@ -144,6 +146,126 @@ find_scanner() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 🚀 Run Plex Media Scanner as the plex user with correct environment
|
||||||
|
# Usage: run_scanner [args...] — runs and returns exit code
|
||||||
|
run_scanner() {
|
||||||
|
sudo -u plex \
|
||||||
|
env LD_LIBRARY_PATH=/usr/lib/plexmediaserver \
|
||||||
|
PLEX_MEDIA_SERVER_APPLICATION_SUPPORT_DIR="/var/lib/plexmediaserver/Library/Application Support" \
|
||||||
|
"$SCANNER_PATH" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 🔑 Get Plex authentication token from Preferences.xml
|
||||||
|
get_plex_token() {
|
||||||
|
local token
|
||||||
|
token=$(sudo grep -oP 'PlexOnlineToken="\K[^"]+' "$PLEX_PREFS" 2>/dev/null)
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
log_verbose "Could not read Plex token from Preferences.xml"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📡 Trigger a library scan via the Plex API (replaces deprecated --scan)
|
||||||
|
# Usage: api_scan_section <section_id>
|
||||||
|
api_scan_section() {
|
||||||
|
local section_id="$1"
|
||||||
|
local token
|
||||||
|
if ! token=$(get_plex_token); then
|
||||||
|
log_verbose "Cannot scan: no Plex token available"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X GET "${PLEX_API_BASE}/library/sections/${section_id}/refresh?X-Plex-Token=${token}")
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2 ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_verbose "API scan failed for section $section_id (HTTP $http_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📡 Trigger a metadata refresh via the Plex API (replaces deprecated --refresh)
|
||||||
|
# Usage: api_refresh_section <section_id> [force]
|
||||||
|
api_refresh_section() {
|
||||||
|
local section_id="$1"
|
||||||
|
local force="${2:-false}"
|
||||||
|
local token
|
||||||
|
if ! token=$(get_plex_token); then
|
||||||
|
log_verbose "Cannot refresh: no Plex token available"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local url="${PLEX_API_BASE}/library/sections/${section_id}/refresh?X-Plex-Token=${token}"
|
||||||
|
if [[ "$force" == "true" ]]; then
|
||||||
|
url+="&force=1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$url")
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2 ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_verbose "API refresh failed for section $section_id (HTTP $http_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📡 Trigger media analysis via the Plex API (replaces deprecated --analyze)
|
||||||
|
# Usage: api_analyze_section <section_id>
|
||||||
|
api_analyze_section() {
|
||||||
|
local section_id="$1"
|
||||||
|
local token
|
||||||
|
if ! token=$(get_plex_token); then
|
||||||
|
log_verbose "Cannot analyze: no Plex token available"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X PUT "${PLEX_API_BASE}/library/sections/${section_id}/analyze?X-Plex-Token=${token}")
|
||||||
|
|
||||||
|
if [[ "$http_code" =~ ^2 ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_verbose "API analyze failed for section $section_id (HTTP $http_code)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📡 List library sections via the Plex API
|
||||||
|
# Output format: "key|title|type" per line (e.g. "1|Movies|movie")
|
||||||
|
api_list_sections() {
|
||||||
|
local token
|
||||||
|
if ! token=$(get_plex_token); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local xml
|
||||||
|
if ! xml=$(curl -fsS "${PLEX_API_BASE}/library/sections?X-Plex-Token=${token}" 2>/dev/null); then
|
||||||
|
log_verbose "Plex API request failed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse XML: extract key, title, and type from <Directory> elements
|
||||||
|
echo "$xml" | grep -oP '<Directory[^>]*>' | while IFS= read -r tag; do
|
||||||
|
local key title type
|
||||||
|
key=$(echo "$tag" | grep -oP 'key="\K[^"]+')
|
||||||
|
title=$(echo "$tag" | grep -oP 'title="\K[^"]+')
|
||||||
|
type=$(echo "$tag" | grep -oP 'type="\K[^"]+')
|
||||||
|
echo "${key}|${title}|${type}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 📋 Get just the section IDs from the API (one per line)
|
||||||
|
api_list_section_ids() {
|
||||||
|
api_list_sections | cut -d'|' -f1
|
||||||
|
}
|
||||||
|
|
||||||
# 🏥 Function to check Plex service status
|
# 🏥 Function to check Plex service status
|
||||||
check_plex_service() {
|
check_plex_service() {
|
||||||
log_verbose "Checking Plex service status..."
|
log_verbose "Checking Plex service status..."
|
||||||
@@ -166,38 +288,24 @@ list_libraries() {
|
|||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! find_scanner; then
|
local sections
|
||||||
return 3
|
if ! sections=$(api_list_sections) || [[ -z "$sections" ]]; then
|
||||||
fi
|
print_status "${CROSS}" "Failed to retrieve library sections from Plex API" "${RED}"
|
||||||
|
|
||||||
# Set library path for Linux
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
|
|
||||||
local output
|
|
||||||
if output=$("$SCANNER_PATH" --list 2>&1); then
|
|
||||||
echo ""
|
|
||||||
echo -e "${BOLD}${CYAN}Available Library Sections:${RESET}"
|
|
||||||
echo -e "${DIM}${CYAN}=========================${RESET}"
|
|
||||||
|
|
||||||
# Parse and format the output
|
|
||||||
echo "$output" | while IFS= read -r line; do
|
|
||||||
if [[ "$line" =~ ^[[:space:]]*([0-9]+):[[:space:]]*(.+)$ ]]; then
|
|
||||||
local section_id="${BASH_REMATCH[1]}"
|
|
||||||
local section_name="${BASH_REMATCH[2]}"
|
|
||||||
echo -e "${GREEN}${BOLD} ${section_id}:${RESET} ${WHITE}${section_name}${RESET}"
|
|
||||||
elif [[ -n "$line" ]]; then
|
|
||||||
echo -e "${DIM} $line${RESET}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
print_status "${CHECKMARK}" "Library listing completed" "${GREEN}"
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
print_status "${CROSS}" "Failed to list libraries" "${RED}"
|
|
||||||
echo -e "${DIM}${RED}Error output: $output${RESET}"
|
|
||||||
return 5
|
return 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}${CYAN}Available Library Sections:${RESET}"
|
||||||
|
echo -e "${DIM}${CYAN}=========================${RESET}"
|
||||||
|
|
||||||
|
while IFS='|' read -r key title type; do
|
||||||
|
[[ -n "$key" ]] || continue
|
||||||
|
printf " ${GREEN}${BOLD}%-4s${RESET} ${WHITE}%-30s${RESET} ${DIM}(%s)${RESET}\n" "${key}:" "$title" "$type"
|
||||||
|
done <<< "$sections"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
print_status "${CHECKMARK}" "Library listing completed" "${GREEN}"
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# 🔍 Function to validate section ID
|
# 🔍 Function to validate section ID
|
||||||
@@ -209,10 +317,9 @@ validate_section_id() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get list of valid section IDs
|
# Get list of valid section IDs via API
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
local valid_ids
|
local valid_ids
|
||||||
if valid_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then
|
if valid_ids=$(api_list_section_ids) && [[ -n "$valid_ids" ]]; then
|
||||||
if echo "$valid_ids" | grep -q "^${section_id}$"; then
|
if echo "$valid_ids" | grep -q "^${section_id}$"; then
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -237,9 +344,7 @@ scan_library() {
|
|||||||
return 4
|
return 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
if api_scan_section "$section_id"; then
|
||||||
|
|
||||||
if "$SCANNER_PATH" --scan --section "$section_id" ${VERBOSE:+--verbose}; then
|
|
||||||
print_status "${CHECKMARK}" "Library section $section_id scan completed" "${GREEN}"
|
print_status "${CHECKMARK}" "Library section $section_id scan completed" "${GREEN}"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -250,16 +355,15 @@ scan_library() {
|
|||||||
print_status "${ROCKET}" "Scanning all libraries for new media..." "${BLUE}"
|
print_status "${ROCKET}" "Scanning all libraries for new media..." "${BLUE}"
|
||||||
|
|
||||||
# Get all section IDs and scan each one
|
# Get all section IDs and scan each one
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
local section_ids
|
local section_ids
|
||||||
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then
|
if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
|
||||||
local failed_sections=()
|
local failed_sections=()
|
||||||
|
|
||||||
while IFS= read -r id; do
|
while IFS= read -r id; do
|
||||||
[[ -n "$id" ]] || continue
|
[[ -n "$id" ]] || continue
|
||||||
print_status "${INFO}" "Scanning section $id..." "${YELLOW}"
|
print_status "${INFO}" "Scanning section $id..." "${YELLOW}"
|
||||||
|
|
||||||
if "$SCANNER_PATH" --scan --section "$id" ${VERBOSE:+--verbose}; then
|
if api_scan_section "$id"; then
|
||||||
print_status "${CHECKMARK}" "Section $id scanned successfully" "${GREEN}"
|
print_status "${CHECKMARK}" "Section $id scanned successfully" "${GREEN}"
|
||||||
else
|
else
|
||||||
print_status "${CROSS}" "Failed to scan section $id" "${RED}"
|
print_status "${CROSS}" "Failed to scan section $id" "${RED}"
|
||||||
@@ -286,11 +390,6 @@ refresh_library() {
|
|||||||
local section_id="$1"
|
local section_id="$1"
|
||||||
local force="${2:-false}"
|
local force="${2:-false}"
|
||||||
|
|
||||||
local force_flag=""
|
|
||||||
if [[ "$force" == "true" ]]; then
|
|
||||||
force_flag="--force"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$section_id" ]]; then
|
if [[ -n "$section_id" ]]; then
|
||||||
print_status "${RECYCLE}" "Refreshing metadata for library section $section_id..." "${BLUE}"
|
print_status "${RECYCLE}" "Refreshing metadata for library section $section_id..." "${BLUE}"
|
||||||
|
|
||||||
@@ -298,9 +397,7 @@ refresh_library() {
|
|||||||
return 4
|
return 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
if api_refresh_section "$section_id" "$force"; then
|
||||||
|
|
||||||
if "$SCANNER_PATH" --refresh $force_flag --section "$section_id" ${VERBOSE:+--verbose}; then
|
|
||||||
print_status "${CHECKMARK}" "Library section $section_id metadata refreshed" "${GREEN}"
|
print_status "${CHECKMARK}" "Library section $section_id metadata refreshed" "${GREEN}"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -311,16 +408,15 @@ refresh_library() {
|
|||||||
print_status "${RECYCLE}" "Refreshing metadata for all libraries..." "${BLUE}"
|
print_status "${RECYCLE}" "Refreshing metadata for all libraries..." "${BLUE}"
|
||||||
|
|
||||||
# Get all section IDs and refresh each one
|
# Get all section IDs and refresh each one
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
local section_ids
|
local section_ids
|
||||||
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then
|
if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
|
||||||
local failed_sections=()
|
local failed_sections=()
|
||||||
|
|
||||||
while IFS= read -r id; do
|
while IFS= read -r id; do
|
||||||
[[ -n "$id" ]] || continue
|
[[ -n "$id" ]] || continue
|
||||||
print_status "${INFO}" "Refreshing section $id..." "${YELLOW}"
|
print_status "${INFO}" "Refreshing section $id..." "${YELLOW}"
|
||||||
|
|
||||||
if "$SCANNER_PATH" --refresh $force_flag --section "$id" ${VERBOSE:+--verbose}; then
|
if api_refresh_section "$id" "$force"; then
|
||||||
print_status "${CHECKMARK}" "Section $id refreshed successfully" "${GREEN}"
|
print_status "${CHECKMARK}" "Section $id refreshed successfully" "${GREEN}"
|
||||||
else
|
else
|
||||||
print_status "${CROSS}" "Failed to refresh section $id" "${RED}"
|
print_status "${CROSS}" "Failed to refresh section $id" "${RED}"
|
||||||
@@ -347,11 +443,6 @@ analyze_library() {
|
|||||||
local section_id="$1"
|
local section_id="$1"
|
||||||
local deep="${2:-false}"
|
local deep="${2:-false}"
|
||||||
|
|
||||||
local analyze_flag="--analyze"
|
|
||||||
if [[ "$deep" == "true" ]]; then
|
|
||||||
analyze_flag="--analyze-deeply"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$section_id" ]]; then
|
if [[ -n "$section_id" ]]; then
|
||||||
print_status "${SEARCH}" "Analyzing media in library section $section_id..." "${BLUE}"
|
print_status "${SEARCH}" "Analyzing media in library section $section_id..." "${BLUE}"
|
||||||
|
|
||||||
@@ -359,9 +450,7 @@ analyze_library() {
|
|||||||
return 4
|
return 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
if api_analyze_section "$section_id"; then
|
||||||
|
|
||||||
if "$SCANNER_PATH" $analyze_flag --section "$section_id" ${VERBOSE:+--verbose}; then
|
|
||||||
print_status "${CHECKMARK}" "Library section $section_id analysis completed" "${GREEN}"
|
print_status "${CHECKMARK}" "Library section $section_id analysis completed" "${GREEN}"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -372,16 +461,15 @@ analyze_library() {
|
|||||||
print_status "${SEARCH}" "Analyzing media in all libraries..." "${BLUE}"
|
print_status "${SEARCH}" "Analyzing media in all libraries..." "${BLUE}"
|
||||||
|
|
||||||
# Get all section IDs and analyze each one
|
# Get all section IDs and analyze each one
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
local section_ids
|
local section_ids
|
||||||
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then
|
if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
|
||||||
local failed_sections=()
|
local failed_sections=()
|
||||||
|
|
||||||
while IFS= read -r id; do
|
while IFS= read -r id; do
|
||||||
[[ -n "$id" ]] || continue
|
[[ -n "$id" ]] || continue
|
||||||
print_status "${INFO}" "Analyzing section $id..." "${YELLOW}"
|
print_status "${INFO}" "Analyzing section $id..." "${YELLOW}"
|
||||||
|
|
||||||
if "$SCANNER_PATH" $analyze_flag --section "$id" ${VERBOSE:+--verbose}; then
|
if api_analyze_section "$id"; then
|
||||||
print_status "${CHECKMARK}" "Section $id analyzed successfully" "${GREEN}"
|
print_status "${CHECKMARK}" "Section $id analyzed successfully" "${GREEN}"
|
||||||
else
|
else
|
||||||
print_status "${CROSS}" "Failed to analyze section $id" "${RED}"
|
print_status "${CROSS}" "Failed to analyze section $id" "${RED}"
|
||||||
@@ -414,9 +502,7 @@ generate_thumbnails() {
|
|||||||
return 4
|
return 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
if run_scanner --generate --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||||
|
|
||||||
if "$SCANNER_PATH" --generate --section "$section_id" ${VERBOSE:+--verbose}; then
|
|
||||||
print_status "${CHECKMARK}" "Thumbnails generated for library section $section_id" "${GREEN}"
|
print_status "${CHECKMARK}" "Thumbnails generated for library section $section_id" "${GREEN}"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -427,16 +513,15 @@ generate_thumbnails() {
|
|||||||
print_status "${SPARKLES}" "Generating thumbnails for all libraries..." "${BLUE}"
|
print_status "${SPARKLES}" "Generating thumbnails for all libraries..." "${BLUE}"
|
||||||
|
|
||||||
# Get all section IDs and generate thumbnails for each one
|
# Get all section IDs and generate thumbnails for each one
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
|
||||||
local section_ids
|
local section_ids
|
||||||
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then
|
if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
|
||||||
local failed_sections=()
|
local failed_sections=()
|
||||||
|
|
||||||
while IFS= read -r id; do
|
while IFS= read -r id; do
|
||||||
[[ -n "$id" ]] || continue
|
[[ -n "$id" ]] || continue
|
||||||
print_status "${INFO}" "Generating thumbnails for section $id..." "${YELLOW}"
|
print_status "${INFO}" "Generating thumbnails for section $id..." "${YELLOW}"
|
||||||
|
|
||||||
if "$SCANNER_PATH" --generate --section "$id" ${VERBOSE:+--verbose}; then
|
if run_scanner --generate --section "$id" ${VERBOSE:+--verbose}; then
|
||||||
print_status "${CHECKMARK}" "Section $id thumbnails generated successfully" "${GREEN}"
|
print_status "${CHECKMARK}" "Section $id thumbnails generated successfully" "${GREEN}"
|
||||||
else
|
else
|
||||||
print_status "${CROSS}" "Failed to generate thumbnails for section $id" "${RED}"
|
print_status "${CROSS}" "Failed to generate thumbnails for section $id" "${RED}"
|
||||||
@@ -473,9 +558,7 @@ show_library_tree() {
|
|||||||
return 4
|
return 4
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
if run_scanner --tree --section "$section_id"; then
|
||||||
|
|
||||||
if "$SCANNER_PATH" --tree --section "$section_id"; then
|
|
||||||
print_status "${CHECKMARK}" "Tree display completed for library section $section_id" "${GREEN}"
|
print_status "${CHECKMARK}" "Tree display completed for library section $section_id" "${GREEN}"
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
@@ -531,15 +614,11 @@ interactive_mode() {
|
|||||||
echo -e "${DIM}Select an operation to perform:${RESET}"
|
echo -e "${DIM}Select an operation to perform:${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# First, check if Plex is running and scanner is available
|
# First, check if Plex is running
|
||||||
if ! check_plex_service; then
|
if ! check_plex_service; then
|
||||||
return 2
|
return 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! find_scanner; then
|
|
||||||
return 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
echo -e "${BOLD}Available Operations:${RESET}"
|
echo -e "${BOLD}Available Operations:${RESET}"
|
||||||
echo -e "${GREEN}1)${RESET} List all libraries"
|
echo -e "${GREEN}1)${RESET} List all libraries"
|
||||||
@@ -638,6 +717,9 @@ interactive_mode() {
|
|||||||
;;
|
;;
|
||||||
5)
|
5)
|
||||||
echo ""
|
echo ""
|
||||||
|
if ! find_scanner; then
|
||||||
|
print_status "${CROSS}" "Scanner binary required for thumbnail generation" "${RED}"
|
||||||
|
else
|
||||||
echo -e "${BOLD}Thumbnail Generation Options:${RESET}"
|
echo -e "${BOLD}Thumbnail Generation Options:${RESET}"
|
||||||
echo -e "${GREEN}1)${RESET} Generate for all libraries"
|
echo -e "${GREEN}1)${RESET} Generate for all libraries"
|
||||||
echo -e "${GREEN}2)${RESET} Generate for specific library"
|
echo -e "${GREEN}2)${RESET} Generate for specific library"
|
||||||
@@ -655,11 +737,16 @@ interactive_mode() {
|
|||||||
print_status "${CROSS}" "Invalid choice" "${RED}"
|
print_status "${CROSS}" "Invalid choice" "${RED}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
6)
|
6)
|
||||||
echo ""
|
echo ""
|
||||||
|
if ! find_scanner; then
|
||||||
|
print_status "${CROSS}" "Scanner binary required for tree display" "${RED}"
|
||||||
|
else
|
||||||
read -p "$(echo -e "${BOLD}Enter section ID to show tree:${RESET} ")" section_id
|
read -p "$(echo -e "${BOLD}Enter section ID to show tree:${RESET} ")" section_id
|
||||||
show_library_tree "$section_id"
|
show_library_tree "$section_id"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
q|Q)
|
q|Q)
|
||||||
print_status "${INFO}" "Goodbye!" "${CYAN}"
|
print_status "${INFO}" "Goodbye!" "${CYAN}"
|
||||||
@@ -716,10 +803,6 @@ main() {
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! find_scanner; then
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Handle commands
|
# Handle commands
|
||||||
case "${1,,}" in
|
case "${1,,}" in
|
||||||
"list")
|
"list")
|
||||||
@@ -740,10 +823,12 @@ main() {
|
|||||||
analyze_library "$section_id" "$deep"
|
analyze_library "$section_id" "$deep"
|
||||||
;;
|
;;
|
||||||
"generate"|"thumbnails")
|
"generate"|"thumbnails")
|
||||||
|
if ! find_scanner; then exit 3; fi
|
||||||
local section_id="${2:-}"
|
local section_id="${2:-}"
|
||||||
generate_thumbnails "$section_id"
|
generate_thumbnails "$section_id"
|
||||||
;;
|
;;
|
||||||
"tree")
|
"tree")
|
||||||
|
if ! find_scanner; then exit 3; fi
|
||||||
local section_id="$2"
|
local section_id="$2"
|
||||||
if [[ -z "$section_id" ]]; then
|
if [[ -z "$section_id" ]]; then
|
||||||
print_status "${CROSS}" "Section ID required for tree command" "${RED}"
|
print_status "${CROSS}" "Section ID required for tree command" "${RED}"
|
||||||
|
|||||||
@@ -54,12 +54,12 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Test configuration
|
# Test configuration
|
||||||
TEST_DIR="/tmp/plex-backup-test-$(date +%s)"
|
TEST_DIR="/tmp/plex-backup-test-$(date +%s)"
|
||||||
|
|||||||
0
plex/tui/__init__.py
Normal file
0
plex/tui/__init__.py
Normal file
685
plex/tui/app.py
Normal file
685
plex/tui/app.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Plex Management TUI — a Textual-based terminal interface for Plex operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
ListItem,
|
||||||
|
ListView,
|
||||||
|
Log,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from backend import CommandResult, run_command # noqa: local import
|
||||||
|
from backend import cache_sudo, is_sudo_failure # noqa: local import
|
||||||
|
|
||||||
|
# ── Path to stylesheets ─────────────────────────────────────────────────
|
||||||
|
CSS_PATH = Path(__file__).parent / "plex_tui.tcss"
|
||||||
|
|
||||||
|
|
||||||
|
class NavListView(ListView):
|
||||||
|
"""ListView that only changes selection on click/keyboard, not mouse hover."""
|
||||||
|
|
||||||
|
def on_mouse_move(self, event) -> None:
|
||||||
|
"""Swallow mouse-move so the cursor doesn't follow the pointer."""
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
# ── Navigation items ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NAV_SECTIONS: list[tuple[str, str]] = [
|
||||||
|
("service", "⏻ Service Control"),
|
||||||
|
("libraries", "📚 Library Scanner"),
|
||||||
|
("backup", "💾 Backup & Validate"),
|
||||||
|
("monitor", "📊 Monitoring"),
|
||||||
|
("database", "🗄️ Database Management"),
|
||||||
|
("recovery", "🔧 Recovery"),
|
||||||
|
("queries", "🔍 Queries & Stats"),
|
||||||
|
("testing", "🧪 Testing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Confirmation dialog ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmDialog(ModalScreen[bool]):
|
||||||
|
"""A modal yes/no confirmation dialog."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="dialog-container"):
|
||||||
|
yield Label(self._title, id="dialog-title")
|
||||||
|
yield Static(self._body, id="dialog-body")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="dialog-cancel")
|
||||||
|
yield Button("Confirm", variant="error", id="dialog-confirm")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#dialog-confirm")
|
||||||
|
def _confirm(self) -> None:
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#dialog-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Input dialog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class InputDialog(ModalScreen[str | None]):
|
||||||
|
"""Modal dialog that asks for a text value."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str, placeholder: str = "") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
self._placeholder = placeholder
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="input-dialog-container"):
|
||||||
|
yield Label(self._title, id="input-dialog-title")
|
||||||
|
yield Static(self._body, id="input-dialog-body")
|
||||||
|
yield Input(placeholder=self._placeholder, id="input-value")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="input-cancel")
|
||||||
|
yield Button("OK", variant="primary", id="input-ok")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#input-ok")
|
||||||
|
def _ok(self) -> None:
|
||||||
|
value = self.query_one("#input-value", Input).value.strip()
|
||||||
|
self.dismiss(value if value else None)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#input-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def _submit(self) -> None:
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password dialog ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordDialog(ModalScreen[str | None]):
|
||||||
|
"""Modal dialog that asks for a password (masked input)."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="password-dialog-container"):
|
||||||
|
yield Label(self._title, id="password-dialog-title")
|
||||||
|
yield Static(self._body, id="password-dialog-body")
|
||||||
|
yield Input(placeholder="Password", password=True, id="password-value")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="password-cancel")
|
||||||
|
yield Button("Authenticate", variant="primary", id="password-ok")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#password-ok")
|
||||||
|
def _ok(self) -> None:
|
||||||
|
value = self.query_one("#password-value", Input).value
|
||||||
|
self.dismiss(value if value else None)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#password-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def _submit(self) -> None:
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Section panels ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _section_header(text: str) -> Static:
|
||||||
|
return Static(text, classes="section-header")
|
||||||
|
|
||||||
|
|
||||||
|
def _btn(label: str, btn_id: str, classes: str = "action-btn") -> Button:
|
||||||
|
return Button(label, id=btn_id, classes=classes)
|
||||||
|
|
||||||
|
|
||||||
|
class ServicePanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Plex Media Server — Service Control")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("▶ Start", "svc-start", "action-btn-success")
|
||||||
|
yield _btn("⏹ Stop", "svc-stop", "action-btn-danger")
|
||||||
|
yield _btn("🔄 Restart", "svc-restart", "action-btn-warning")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("ℹ️ Status", "svc-status")
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Library Scanner")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📋 List Libraries", "lib-list")
|
||||||
|
yield _btn("🔍 Scan All", "lib-scan-all")
|
||||||
|
yield _btn("🔍 Scan Section…", "lib-scan-id")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔄 Refresh All", "lib-refresh-all")
|
||||||
|
yield _btn("🔄 Refresh Section…", "lib-refresh-id")
|
||||||
|
yield _btn("⚡ Force Refresh All", "lib-force-refresh")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Analyze All", "lib-analyze-all")
|
||||||
|
yield _btn("📊 Analyze Section…", "lib-analyze-id")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Backup & Validation")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("💾 Run Backup", "bak-run")
|
||||||
|
yield _btn("💾 Backup + Auto-Repair", "bak-run-repair")
|
||||||
|
yield _btn("🔍 Integrity Check", "bak-integrity")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📋 List Backups", "bak-list")
|
||||||
|
yield _btn("✅ Validate Latest", "bak-validate-latest")
|
||||||
|
yield _btn("📝 Validation Report", "bak-validate-report")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📦 Built-in Status", "bak-builtin")
|
||||||
|
yield _btn("📦 Built-in Detailed", "bak-builtin-detail")
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Backup Monitoring Dashboard")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Show Dashboard", "mon-dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabasePanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Database Management")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔍 Integrity Check", "db-check")
|
||||||
|
yield _btn("🔧 Gentle Repair", "db-repair-gentle", "action-btn-warning")
|
||||||
|
yield _btn("⚠️ Force Repair", "db-repair-force", "action-btn-danger")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🧹 Cleanup (dry-run)", "db-cleanup-dry")
|
||||||
|
yield _btn("🧹 Cleanup (apply)", "db-cleanup-apply", "action-btn-warning")
|
||||||
|
yield _btn("📥 Install/Update DBRepair", "db-install-dbrepair")
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Recovery Operations")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔎 Verify Backup Only", "rec-verify")
|
||||||
|
yield _btn("🧪 Nuclear Dry-Run", "rec-nuclear-dry")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("☢️ Nuclear Recovery", "rec-nuclear-auto", "action-btn-danger")
|
||||||
|
yield Static(
|
||||||
|
" ⚠ Nuclear recovery is a last resort — it replaces your entire database from backup.",
|
||||||
|
classes="status-warning",
|
||||||
|
)
|
||||||
|
yield _section_header("Post-Recovery Validation")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("⚡ Quick Validate", "rec-validate-quick")
|
||||||
|
yield _btn("🔍 Detailed Validate", "rec-validate-detailed")
|
||||||
|
yield _btn("📈 Performance Validate", "rec-validate-perf")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Queries & Statistics")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🆕 Recent Additions (7d)", "qry-recent-7")
|
||||||
|
yield _btn("🆕 Recent Additions (30d)", "qry-recent-30")
|
||||||
|
yield _btn("🆕 Recent Additions…", "qry-recent-custom")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Library Stats", "qry-stats")
|
||||||
|
yield _btn("🔢 Media Counts", "qry-counts")
|
||||||
|
yield _btn("📋 List Libraries", "qry-libraries")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("💬 Custom SQL Query…", "qry-custom")
|
||||||
|
|
||||||
|
|
||||||
|
class TestingPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Testing & Diagnostics")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("⚡ Quick Smoke Tests", "test-quick")
|
||||||
|
yield _btn("🧪 Unit Tests", "test-unit")
|
||||||
|
yield _btn("🔗 Integration Tests", "test-integration")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🧹 Cleanup Test Artifacts", "test-cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
# Map section key -> panel class
|
||||||
|
PANELS: dict[str, type] = {
|
||||||
|
"service": ServicePanel,
|
||||||
|
"libraries": LibraryPanel,
|
||||||
|
"backup": BackupPanel,
|
||||||
|
"monitor": MonitorPanel,
|
||||||
|
"database": DatabasePanel,
|
||||||
|
"recovery": RecoveryPanel,
|
||||||
|
"queries": QueryPanel,
|
||||||
|
"testing": TestingPanel,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main application ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PlexTUI(App):
|
||||||
|
"""Plex Management TUI."""
|
||||||
|
|
||||||
|
TITLE = "Plex Management Console"
|
||||||
|
CSS_PATH = CSS_PATH
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
("d", "toggle_dark", "Toggle Dark"),
|
||||||
|
("c", "clear_log", "Clear Log"),
|
||||||
|
("s", "authenticate_sudo", "Sudo Auth"),
|
||||||
|
("1", "nav('service')", "Service"),
|
||||||
|
("2", "nav('libraries')", "Libraries"),
|
||||||
|
("3", "nav('backup')", "Backup"),
|
||||||
|
("4", "nav('monitor')", "Monitor"),
|
||||||
|
("5", "nav('database')", "Database"),
|
||||||
|
("6", "nav('recovery')", "Recovery"),
|
||||||
|
("7", "nav('queries')", "Queries"),
|
||||||
|
("8", "nav('testing')", "Testing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._current_section = "service"
|
||||||
|
|
||||||
|
# ── Composition ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Horizontal():
|
||||||
|
with Vertical(id="sidebar"):
|
||||||
|
yield Static("Plex Manager", id="sidebar-title")
|
||||||
|
yield NavListView(
|
||||||
|
*[
|
||||||
|
ListItem(Label(label, classes="nav-label"), id=f"nav-{key}")
|
||||||
|
for key, label in NAV_SECTIONS
|
||||||
|
],
|
||||||
|
id="nav-list",
|
||||||
|
)
|
||||||
|
with Vertical(id="main-content"):
|
||||||
|
# A scrollable area where the active section panel lives
|
||||||
|
yield VerticalScroll(ServicePanel(), id="panel-area")
|
||||||
|
yield Log(id="output-log", highlight=True, auto_scroll=True)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._select_nav(0)
|
||||||
|
# Disable any-event mouse tracking (mode 1003) to prevent hover effects.
|
||||||
|
# Basic click tracking (mode 1000) remains active.
|
||||||
|
self.set_timer(0.1, self._disable_mouse_move_tracking)
|
||||||
|
|
||||||
|
def _disable_mouse_move_tracking(self) -> None:
|
||||||
|
sys.stdout.write("\x1b[?1003l")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _select_nav(self, index: int) -> None:
|
||||||
|
nav_list = self.query_one("#nav-list", ListView)
|
||||||
|
nav_list.index = index
|
||||||
|
|
||||||
|
@on(ListView.Selected, "#nav-list")
|
||||||
|
def _on_nav_selected(self, event: ListView.Selected) -> None:
|
||||||
|
item_id = event.item.id or ""
|
||||||
|
section_key = item_id.removeprefix("nav-")
|
||||||
|
if section_key in PANELS:
|
||||||
|
self._switch_section(section_key)
|
||||||
|
|
||||||
|
def _switch_section(self, key: str) -> None:
|
||||||
|
if key == self._current_section:
|
||||||
|
return
|
||||||
|
self._current_section = key
|
||||||
|
panel_area = self.query_one("#panel-area", VerticalScroll)
|
||||||
|
panel_area.remove_children()
|
||||||
|
panel_area.mount(PANELS[key]())
|
||||||
|
|
||||||
|
def action_nav(self, section: str) -> None:
|
||||||
|
keys = [k for k, _ in NAV_SECTIONS]
|
||||||
|
if section in keys:
|
||||||
|
self._select_nav(keys.index(section))
|
||||||
|
self._switch_section(section)
|
||||||
|
|
||||||
|
def action_clear_log(self) -> None:
|
||||||
|
self.query_one("#output-log", Log).clear()
|
||||||
|
|
||||||
|
# ── Sudo authentication ─────────────────────────────────────
|
||||||
|
|
||||||
|
def action_authenticate_sudo(self) -> None:
|
||||||
|
self._prompt_sudo()
|
||||||
|
|
||||||
|
def _prompt_sudo(self) -> None:
|
||||||
|
async def _cb(password: str | None) -> None:
|
||||||
|
if password is not None:
|
||||||
|
success = await cache_sudo(password)
|
||||||
|
if success:
|
||||||
|
self._log("🔓 Sudo credentials cached successfully.")
|
||||||
|
else:
|
||||||
|
self._log("✗ Sudo authentication failed. Wrong password?")
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
PasswordDialog(
|
||||||
|
"🔒 Sudo Authentication",
|
||||||
|
"Enter your password to cache sudo credentials:",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Logging helper ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _log(self, text: str) -> None:
|
||||||
|
log_widget = self.query_one("#output-log", Log)
|
||||||
|
log_widget.write_line(text)
|
||||||
|
|
||||||
|
def _log_result(self, label: str, result: CommandResult) -> None:
|
||||||
|
status = "✓" if result.ok else "✗"
|
||||||
|
self._log(f"[{status}] {label}")
|
||||||
|
if result.output:
|
||||||
|
for line in result.output.splitlines():
|
||||||
|
self._log(f" {line}")
|
||||||
|
self._log("")
|
||||||
|
|
||||||
|
# ── Async operation runner ────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(thread=False)
|
||||||
|
async def _run_op(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
coro: Coroutine[None, None, CommandResult],
|
||||||
|
) -> None:
|
||||||
|
self._log(f"⏳ {label}...")
|
||||||
|
result = await coro
|
||||||
|
if is_sudo_failure(result):
|
||||||
|
self._log(f"[✗] {label}")
|
||||||
|
self._log(" 🔒 This command requires sudo. Press 's' to authenticate, then try again.")
|
||||||
|
self._log("")
|
||||||
|
else:
|
||||||
|
self._log_result(label, result)
|
||||||
|
|
||||||
|
def _ask_section_then_run(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
op: Callable[[str], Coroutine[None, None, CommandResult]],
|
||||||
|
) -> None:
|
||||||
|
"""Prompt for a section ID, then run an async operation with it."""
|
||||||
|
|
||||||
|
async def _do(dialog_result: str | None) -> None:
|
||||||
|
if dialog_result is not None:
|
||||||
|
self._run_op(f"{title} (section {dialog_result})", op(dialog_result))
|
||||||
|
|
||||||
|
self.app.push_screen(
|
||||||
|
InputDialog(title, "Enter library section ID:", placeholder="e.g. 1"),
|
||||||
|
_do, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Button dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def _on_button(self, event: Button.Pressed) -> None:
|
||||||
|
from backend import ( # local import to keep top-level light
|
||||||
|
backup_builtin_detailed,
|
||||||
|
backup_builtin_status,
|
||||||
|
backup_integrity_check,
|
||||||
|
backup_list,
|
||||||
|
backup_run,
|
||||||
|
backup_validate,
|
||||||
|
backup_validate_report,
|
||||||
|
custom_query,
|
||||||
|
db_check,
|
||||||
|
db_cleanup,
|
||||||
|
db_install_dbrepair,
|
||||||
|
db_repair_force,
|
||||||
|
db_repair_gentle,
|
||||||
|
library_analyze,
|
||||||
|
library_list,
|
||||||
|
library_refresh,
|
||||||
|
library_scan,
|
||||||
|
library_stats,
|
||||||
|
list_libraries_query,
|
||||||
|
media_counts,
|
||||||
|
monitor_dashboard,
|
||||||
|
nuclear_recovery_auto,
|
||||||
|
nuclear_recovery_dry_run,
|
||||||
|
nuclear_recovery_verify,
|
||||||
|
plex_restart,
|
||||||
|
plex_start,
|
||||||
|
plex_status,
|
||||||
|
plex_stop,
|
||||||
|
recent_additions,
|
||||||
|
run_tests_cleanup,
|
||||||
|
run_tests_integration,
|
||||||
|
run_tests_quick,
|
||||||
|
run_tests_unit,
|
||||||
|
validate_recovery,
|
||||||
|
)
|
||||||
|
|
||||||
|
bid = event.button.id or ""
|
||||||
|
|
||||||
|
# ── Service ────────────────────────────────
|
||||||
|
if bid == "svc-start":
|
||||||
|
self._run_op("Start Plex", plex_start())
|
||||||
|
elif bid == "svc-stop":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Stop Plex?",
|
||||||
|
"This will stop the Plex Media Server service.",
|
||||||
|
"Stop Plex",
|
||||||
|
plex_stop(),
|
||||||
|
)
|
||||||
|
elif bid == "svc-restart":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Restart Plex?",
|
||||||
|
"This will restart the Plex Media Server service.",
|
||||||
|
"Restart Plex",
|
||||||
|
plex_restart(),
|
||||||
|
)
|
||||||
|
elif bid == "svc-status":
|
||||||
|
self._run_op("Plex Status", plex_status())
|
||||||
|
|
||||||
|
# ── Libraries ──────────────────────────────
|
||||||
|
elif bid == "lib-list":
|
||||||
|
self._run_op("List Libraries", library_list())
|
||||||
|
elif bid == "lib-scan-all":
|
||||||
|
self._run_op("Scan All Libraries", library_scan())
|
||||||
|
elif bid == "lib-scan-id":
|
||||||
|
self._ask_section_then_run("Scan Library", library_scan)
|
||||||
|
elif bid == "lib-refresh-all":
|
||||||
|
self._run_op("Refresh All Libraries", library_refresh())
|
||||||
|
elif bid == "lib-refresh-id":
|
||||||
|
self._ask_section_then_run("Refresh Library", library_refresh)
|
||||||
|
elif bid == "lib-force-refresh":
|
||||||
|
self._run_op("Force Refresh All", library_refresh(force=True))
|
||||||
|
elif bid == "lib-analyze-all":
|
||||||
|
self._run_op("Analyze All Libraries", library_analyze())
|
||||||
|
elif bid == "lib-analyze-id":
|
||||||
|
self._ask_section_then_run("Analyze Library", library_analyze)
|
||||||
|
|
||||||
|
# ── Backup ─────────────────────────────────
|
||||||
|
elif bid == "bak-run":
|
||||||
|
self._run_op("Run Backup", backup_run())
|
||||||
|
elif bid == "bak-run-repair":
|
||||||
|
self._run_op("Backup + Auto-Repair", backup_run(auto_repair=True))
|
||||||
|
elif bid == "bak-integrity":
|
||||||
|
self._run_op("Integrity Check", backup_integrity_check())
|
||||||
|
elif bid == "bak-list":
|
||||||
|
self._run_op("List Backups", backup_list())
|
||||||
|
elif bid == "bak-validate-latest":
|
||||||
|
self._run_op("Validate Latest Backup", backup_validate(latest_only=True))
|
||||||
|
elif bid == "bak-validate-report":
|
||||||
|
self._run_op("Full Validation Report", backup_validate_report())
|
||||||
|
elif bid == "bak-builtin":
|
||||||
|
self._run_op("Built-in Backup Status", backup_builtin_status())
|
||||||
|
elif bid == "bak-builtin-detail":
|
||||||
|
self._run_op("Built-in Backup (Detailed)", backup_builtin_detailed())
|
||||||
|
|
||||||
|
# ── Monitor ────────────────────────────────
|
||||||
|
elif bid == "mon-dashboard":
|
||||||
|
self._run_op("Monitoring Dashboard", monitor_dashboard())
|
||||||
|
|
||||||
|
# ── Database ───────────────────────────────
|
||||||
|
elif bid == "db-check":
|
||||||
|
self._run_op("Database Integrity Check", db_check())
|
||||||
|
elif bid == "db-repair-gentle":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Gentle DB Repair?",
|
||||||
|
"This will attempt a gentle repair of the Plex database.",
|
||||||
|
"Gentle DB Repair",
|
||||||
|
db_repair_gentle(),
|
||||||
|
)
|
||||||
|
elif bid == "db-repair-force":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Force DB Repair?",
|
||||||
|
"This will aggressively repair the Plex database. "
|
||||||
|
"The Plex service will be stopped during repair.",
|
||||||
|
"Force DB Repair",
|
||||||
|
db_repair_force(),
|
||||||
|
)
|
||||||
|
elif bid == "db-cleanup-dry":
|
||||||
|
self._run_op("DB Cleanup (dry-run)", db_cleanup(dry_run=True))
|
||||||
|
elif bid == "db-cleanup-apply":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Apply DB Cleanup?",
|
||||||
|
"This will permanently remove temporary and recovery files "
|
||||||
|
"from the Plex database directory.",
|
||||||
|
"DB Cleanup",
|
||||||
|
db_cleanup(dry_run=False),
|
||||||
|
)
|
||||||
|
elif bid == "db-install-dbrepair":
|
||||||
|
self._run_op("Install/Update DBRepair", db_install_dbrepair())
|
||||||
|
|
||||||
|
# ── Recovery ───────────────────────────────
|
||||||
|
elif bid == "rec-verify":
|
||||||
|
self._run_op("Verify Backup Integrity", nuclear_recovery_verify())
|
||||||
|
elif bid == "rec-nuclear-dry":
|
||||||
|
self._run_op("Nuclear Recovery (dry-run)", nuclear_recovery_dry_run())
|
||||||
|
elif bid == "rec-nuclear-auto":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"☢️ NUCLEAR RECOVERY",
|
||||||
|
"This will REPLACE YOUR ENTIRE DATABASE from the best available backup.\n\n"
|
||||||
|
"This is a LAST RESORT operation. Plex will be stopped during recovery.\n"
|
||||||
|
"Are you absolutely sure?",
|
||||||
|
"Nuclear Recovery",
|
||||||
|
nuclear_recovery_auto(),
|
||||||
|
)
|
||||||
|
elif bid == "rec-validate-quick":
|
||||||
|
self._run_op("Quick Recovery Validation", validate_recovery("--quick"))
|
||||||
|
elif bid == "rec-validate-detailed":
|
||||||
|
self._run_op(
|
||||||
|
"Detailed Recovery Validation", validate_recovery("--detailed")
|
||||||
|
)
|
||||||
|
elif bid == "rec-validate-perf":
|
||||||
|
self._run_op(
|
||||||
|
"Performance Recovery Validation", validate_recovery("--performance")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Queries ────────────────────────────────
|
||||||
|
elif bid == "qry-recent-7":
|
||||||
|
self._run_op("Recent Additions (7 days)", recent_additions(7))
|
||||||
|
elif bid == "qry-recent-30":
|
||||||
|
self._run_op("Recent Additions (30 days)", recent_additions(30))
|
||||||
|
elif bid == "qry-recent-custom":
|
||||||
|
self._ask_days_then_run()
|
||||||
|
elif bid == "qry-stats":
|
||||||
|
self._run_op("Library Stats", library_stats())
|
||||||
|
elif bid == "qry-counts":
|
||||||
|
self._run_op("Media Counts", media_counts())
|
||||||
|
elif bid == "qry-libraries":
|
||||||
|
self._run_op("List Libraries", list_libraries_query())
|
||||||
|
elif bid == "qry-custom":
|
||||||
|
self._ask_sql_then_run()
|
||||||
|
|
||||||
|
# ── Testing ────────────────────────────────
|
||||||
|
elif bid == "test-quick":
|
||||||
|
self._run_op("Quick Smoke Tests", run_tests_quick())
|
||||||
|
elif bid == "test-unit":
|
||||||
|
self._run_op("Unit Tests", run_tests_unit())
|
||||||
|
elif bid == "test-integration":
|
||||||
|
self._run_op("Integration Tests", run_tests_integration())
|
||||||
|
elif bid == "test-cleanup":
|
||||||
|
self._run_op("Cleanup Test Artifacts", run_tests_cleanup())
|
||||||
|
|
||||||
|
# ── Confirmation helper ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _confirm_then_run(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
label: str,
|
||||||
|
coro: Coroutine[None, None, CommandResult],
|
||||||
|
) -> None:
|
||||||
|
async def _callback(confirmed: bool) -> None:
|
||||||
|
if confirmed:
|
||||||
|
self._run_op(label, coro)
|
||||||
|
|
||||||
|
self.push_screen(ConfirmDialog(title, body), _callback) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# ── Input prompt helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ask_days_then_run(self) -> None:
|
||||||
|
from backend import recent_additions
|
||||||
|
|
||||||
|
async def _cb(val: str | None) -> None:
|
||||||
|
if val is not None and val.isdigit():
|
||||||
|
self._run_op(
|
||||||
|
f"Recent Additions ({val} days)", recent_additions(int(val))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
InputDialog(
|
||||||
|
"Recent Additions",
|
||||||
|
"Enter number of days:",
|
||||||
|
placeholder="e.g. 14",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ask_sql_then_run(self) -> None:
|
||||||
|
from backend import custom_query
|
||||||
|
|
||||||
|
async def _cb(val: str | None) -> None:
|
||||||
|
if val is not None:
|
||||||
|
self._run_op(f"Custom Query", custom_query(val))
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
InputDialog(
|
||||||
|
"Custom SQL Query",
|
||||||
|
"Enter a SQL query to run against the Plex database:",
|
||||||
|
placeholder="SELECT count(*) FROM metadata_items",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = PlexTUI()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
345
plex/tui/backend.py
Normal file
345
plex/tui/backend.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Plex Management TUI — backend helpers for running shell scripts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent.parent # /home/…/shell/plex
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
command: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.returncode == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output(self) -> str:
|
||||||
|
text = self.stdout
|
||||||
|
if self.stderr:
|
||||||
|
text += "\n" + self.stderr
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _script(name: str) -> str:
|
||||||
|
"""Return absolute path to a script in the plex directory."""
|
||||||
|
return str(SCRIPT_DIR / name)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ansi stripping ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
return _ANSI_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Async command runner ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
cmd: str | list[str],
|
||||||
|
*,
|
||||||
|
sudo: bool = False,
|
||||||
|
timeout: int = 300,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Run a shell command asynchronously and return the result."""
|
||||||
|
if isinstance(cmd, list):
|
||||||
|
shell_cmd = " ".join(shlex.quote(c) for c in cmd)
|
||||||
|
else:
|
||||||
|
shell_cmd = cmd
|
||||||
|
|
||||||
|
if sudo:
|
||||||
|
shell_cmd = f"sudo {shell_cmd}"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TERM"] = "dumb" # suppress colour in child scripts
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
shell_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=timeout
|
||||||
|
)
|
||||||
|
return CommandResult(
|
||||||
|
returncode=proc.returncode or 0,
|
||||||
|
stdout=strip_ansi(stdout_bytes.decode(errors="replace")),
|
||||||
|
stderr=strip_ansi(stderr_bytes.decode(errors="replace")),
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return CommandResult(
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr=f"Command timed out after {timeout}s",
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return CommandResult(
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr=str(exc),
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sudo helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUDO_FAIL_PATTERNS = [
|
||||||
|
"sudo: a password is required",
|
||||||
|
"sudo: a terminal is required",
|
||||||
|
"sudo: no tty present",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_sudo_failure(result: CommandResult) -> bool:
|
||||||
|
"""Return True if the command failed because of missing sudo credentials."""
|
||||||
|
if result.ok:
|
||||||
|
return False
|
||||||
|
text = (result.stdout + " " + result.stderr).lower()
|
||||||
|
return any(p in text for p in _SUDO_FAIL_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_sudo_cached() -> bool:
|
||||||
|
"""Check whether sudo credentials are currently cached (no password needed)."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sudo", "-n", "true",
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_sudo(password: str) -> bool:
|
||||||
|
"""Cache sudo credentials by validating the given password."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sudo", "-S", "-v",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.communicate(input=(password + "\n").encode())
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── High-level operations (each returns a CommandResult) ────────────────
|
||||||
|
|
||||||
|
# Service management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def plex_start() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} start")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_stop() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} stop")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_restart() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} restart")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_status() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} status")
|
||||||
|
|
||||||
|
|
||||||
|
# Library scanning ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def library_list() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('scan-plex-libraries.sh')} list")
|
||||||
|
|
||||||
|
|
||||||
|
async def library_scan(section_id: str = "") -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} scan"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_refresh(section_id: str = "", force: bool = False) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} refresh"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
else:
|
||||||
|
cmd += ' ""'
|
||||||
|
if force:
|
||||||
|
cmd += " true"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_analyze(section_id: str = "") -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} analyze"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
# Backup ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def backup_run(auto_repair: bool = False) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('backup-plex.sh')} --non-interactive"
|
||||||
|
if auto_repair:
|
||||||
|
cmd += " --auto-repair"
|
||||||
|
return await run_command(cmd, timeout=600)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_integrity_check() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('backup-plex.sh')} --check-integrity --non-interactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_validate(latest_only: bool = True) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('validate-plex-backups.sh')}"
|
||||||
|
if latest_only:
|
||||||
|
cmd += " --latest"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_validate_report() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('validate-plex-backups.sh')} --report")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_list() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} backups")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_builtin_status() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('check-plex-builtin-backups.sh')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_builtin_detailed() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('check-plex-builtin-backups.sh')} --detailed")
|
||||||
|
|
||||||
|
|
||||||
|
# Monitoring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def monitor_dashboard() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('monitor-plex-backup.sh')}")
|
||||||
|
|
||||||
|
|
||||||
|
# Database management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def db_check() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} check")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_repair_gentle() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} repair --gentle")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_repair_force() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} repair --force")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_cleanup(dry_run: bool = True) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('cleanup-plex-databases.sh')}"
|
||||||
|
if dry_run:
|
||||||
|
cmd += " --dry-run"
|
||||||
|
cmd += " --verbose"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def db_install_dbrepair() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} install-dbrepair")
|
||||||
|
|
||||||
|
|
||||||
|
# Recovery ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def nuclear_recovery_dry_run() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --dry-run"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def nuclear_recovery_auto() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --auto", timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def nuclear_recovery_verify() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --verify-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_recovery(mode: str = "--quick") -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('validate-plex-recovery.sh')} {mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Queries ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def recent_additions(days: int = 7) -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('plex-recent-additions.sh')} recent {days}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_stats() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} stats")
|
||||||
|
|
||||||
|
|
||||||
|
async def media_counts() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} count")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_libraries_query() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} libraries")
|
||||||
|
|
||||||
|
|
||||||
|
async def custom_query(sql: str) -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('plex-recent-additions.sh')} custom {shlex.quote(sql)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Testing ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_tests_quick() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --quick", timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_unit() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --unit", timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_integration() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('integration-test-plex.sh')} --quick", timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_cleanup() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --cleanup"
|
||||||
|
)
|
||||||
279
plex/tui/plex_tui.tcss
Normal file
279
plex/tui/plex_tui.tcss
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/* Plex TUI Theme - Orange/Dark inspired by Plex branding */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
background: #282828;
|
||||||
|
color: #e5a00d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
background: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar navigation */
|
||||||
|
#sidebar {
|
||||||
|
width: 32;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-right: solid #e5a00d;
|
||||||
|
padding: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-title {
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
padding: 0 1;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem {
|
||||||
|
padding: 0 1;
|
||||||
|
height: 3;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem.-active {
|
||||||
|
background: #e5a00d 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
padding: 1 2;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
#main-content {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.section-header {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status panel */
|
||||||
|
#status-panel {
|
||||||
|
height: auto;
|
||||||
|
max-height: 12;
|
||||||
|
border: solid #444;
|
||||||
|
margin-bottom: 1;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-btn {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning:hover {
|
||||||
|
background: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-success {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-success:hover {
|
||||||
|
background: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress default button hover tint */
|
||||||
|
Button:hover {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button rows */
|
||||||
|
.button-row {
|
||||||
|
layout: horizontal;
|
||||||
|
height: auto;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output log */
|
||||||
|
#output-log {
|
||||||
|
border: solid #444;
|
||||||
|
height: 1fr;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DataTable */
|
||||||
|
DataTable {
|
||||||
|
height: auto;
|
||||||
|
max-height: 20;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirmation dialog */
|
||||||
|
ConfirmDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog > #dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick $error;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog #dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $error;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog #dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input dialog */
|
||||||
|
InputDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog > #input-dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick #e5a00d;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog #input-dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog #input-dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password dialog */
|
||||||
|
PasswordDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog > #password-dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick #e5a00d;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog #password-dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog #password-dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
TabbedContent {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabPane {
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info cards */
|
||||||
|
.info-card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: solid #444;
|
||||||
|
padding: 1;
|
||||||
|
margin-bottom: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-ok {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: $warning;
|
||||||
|
}
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m'
|
NC=$'\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
# Comprehensive check to ensure Plex is fully recovered and functional
|
# Comprehensive check to ensure Plex is fully recovered and functional
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user