mirror of
https://github.com/acedanger/shell.git
synced 2026-03-24 20:11:49 -07:00
Compare commits
11 Commits
4ce77211b5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d64041ff | ||
|
|
70ec810f58 | ||
|
|
9196879b6c | ||
|
|
dc8d35f593 | ||
|
|
c3af84b3e6 | ||
|
|
edae8513d1 | ||
|
|
8e7e22a1a3 | ||
|
|
2bae9bc6ce | ||
|
|
9bb99aecbf | ||
|
|
ddaa641668 | ||
|
|
b47f58fad7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,3 +43,4 @@ dotfiles/my-aliases.zsh
|
||||
|
||||
# Compiled binaries
|
||||
tui/tui
|
||||
plex/DBRepair.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
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
CYAN=$'\033[0;36m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Performance tracking variables (removed unused variables)
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
################################################################################
|
||||
|
||||
# Color codes
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
RED=$'\033[0;31m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Plex database path
|
||||
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
set +e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||
|
||||
@@ -55,12 +55,12 @@
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
CYAN=$'\033[0;36m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Test configuration
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
@@ -49,13 +49,13 @@
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
MAGENTA='\033[0;35m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
CYAN=$'\033[0;36m'
|
||||
MAGENTA=$'\033[0;35m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
@@ -58,14 +58,15 @@
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
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_GROUP="plex"
|
||||
BACKUP_TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
@@ -244,27 +245,66 @@ restore_from_backup() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to verify restored databases
|
||||
# Function to verify restored databases (structural + FTS)
|
||||
verify_databases() {
|
||||
print_status "$YELLOW" "Verifying restored databases..."
|
||||
|
||||
# Check main database
|
||||
if sqlite3 "${PLEX_DB_DIR}/com.plexapp.plugins.library.db" "PRAGMA integrity_check;" | grep -q "ok"; then
|
||||
print_status "$GREEN" "Main database integrity check: PASSED"
|
||||
else
|
||||
print_status "$RED" "Main database integrity check: FAILED"
|
||||
return 1
|
||||
# Use Plex's bundled SQLite for ICU compatibility; fall back to system sqlite3
|
||||
local sqlite_bin="sqlite3"
|
||||
if [[ -x "$PLEX_SQLITE" ]]; then
|
||||
sqlite_bin="$PLEX_SQLITE"
|
||||
fi
|
||||
|
||||
# Check blobs database
|
||||
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"
|
||||
else
|
||||
print_status "$RED" "Blobs database integrity check: FAILED"
|
||||
return 1
|
||||
local overall_ok=true
|
||||
|
||||
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
|
||||
print_status "$RED" "One or more database checks failed!"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to fix ownership issues
|
||||
|
||||
@@ -34,15 +34,15 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Color codes for output
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly BLUE='\033[0;34m'
|
||||
readonly CYAN='\033[0;36m'
|
||||
readonly WHITE='\033[1;37m'
|
||||
readonly BOLD='\033[1m'
|
||||
readonly DIM='\033[2m'
|
||||
readonly RESET='\033[0m'
|
||||
readonly RED=$'\033[0;31m'
|
||||
readonly GREEN=$'\033[0;32m'
|
||||
readonly YELLOW=$'\033[1;33m'
|
||||
readonly BLUE=$'\033[0;34m'
|
||||
readonly CYAN=$'\033[0;36m'
|
||||
readonly WHITE=$'\033[1;37m'
|
||||
readonly BOLD=$'\033[1m'
|
||||
readonly DIM=$'\033[2m'
|
||||
readonly RESET=$'\033[0m'
|
||||
|
||||
# Configuration
|
||||
readonly PLEX_SERVICE="plexmediaserver"
|
||||
@@ -56,6 +56,251 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly SCRIPT_DIR
|
||||
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
|
||||
mkdir -p "$SCRIPT_DIR/logs"
|
||||
|
||||
@@ -210,26 +455,54 @@ check_database_integrity() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run integrity check
|
||||
# Run structural integrity check
|
||||
local integrity_result
|
||||
integrity_result=$(sudo "$PLEX_SQLITE" "$db_file" "PRAGMA integrity_check;" 2>&1)
|
||||
local check_exit_code=$?
|
||||
|
||||
if [[ $check_exit_code -ne 0 ]]; then
|
||||
log_error "Failed to run integrity check on $db_name"
|
||||
if [[ $check_exit_code -ne 0 && -z "$integrity_result" ]]; then
|
||||
log_error "Failed to open database: $db_name (exit code $check_exit_code)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if echo "$integrity_result" | grep -q "^ok$"; then
|
||||
log_success "Database integrity check passed: $db_name"
|
||||
return 0
|
||||
local struct_ok=true
|
||||
if [[ "$integrity_result" == "ok" ]]; then
|
||||
log_success "Structural integrity check passed: $db_name"
|
||||
else
|
||||
log_warning "Database integrity issues detected in $db_name:"
|
||||
echo "$integrity_result" | while IFS= read -r line; do
|
||||
struct_ok=false
|
||||
log_warning "Structural integrity issues detected in $db_name:"
|
||||
echo "$integrity_result" | head -n 10 | while IFS= read -r line; do
|
||||
log_warning " $line"
|
||||
done
|
||||
return 1
|
||||
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
|
||||
@@ -254,26 +527,35 @@ check_all_databases() {
|
||||
return 0
|
||||
else
|
||||
log_warning "Found integrity issues in $issues database(s)"
|
||||
_hint_install_dbrepair
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 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 -e "${BOLD}${WHITE}Commands:${RESET}"
|
||||
echo -e " ${GREEN}${BOLD}check${RESET} Read-only database integrity check"
|
||||
echo -e " ${YELLOW}${BOLD}repair${RESET} Interactive database repair"
|
||||
echo -e " ${YELLOW}${BOLD}repair --gentle${RESET} Gentle repair methods only"
|
||||
echo -e " ${RED}${BOLD}repair --force${RESET} Aggressive repair methods"
|
||||
echo -e " ${RED}${BOLD}nuclear${RESET} Nuclear recovery (replace from backup)"
|
||||
echo -e " ${CYAN}${BOLD}help${RESET} Show this help message"
|
||||
printf " ${GREEN}${BOLD}%-18s${RESET} %s\n" "check" "Read-only database integrity check"
|
||||
printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair" "Interactive database repair"
|
||||
printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair --gentle" "Gentle repair methods only"
|
||||
printf " ${RED}${BOLD}%-18s${RESET} %s\n" "repair --force" "Aggressive repair methods"
|
||||
printf " ${RED}${BOLD}%-18s${RESET} %s\n" "nuclear" "Nuclear recovery (replace from backup)"
|
||||
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 -e "${BOLD}${WHITE}Examples:${RESET}"
|
||||
echo -e " ${DIM}$(basename "$0") check # Safe integrity check${RESET}"
|
||||
echo -e " ${DIM}$(basename "$0") repair # Interactive repair${RESET}"
|
||||
echo -e " ${DIM}$(basename "$0") repair --gentle # Minimal repair only${RESET}"
|
||||
printf " ${DIM}%-46s # %s${RESET}\n" "${script} check" "Safe integrity check"
|
||||
printf " ${DIM}%-46s # %s${RESET}\n" "${script} repair" "Interactive repair"
|
||||
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 -e "${BOLD}${YELLOW}⚠️ WARNING:${RESET} Always run ${CYAN}check${RESET} first before attempting repairs!"
|
||||
echo ""
|
||||
@@ -315,20 +597,72 @@ main() {
|
||||
;;
|
||||
|
||||
"repair")
|
||||
echo -e "${RED}${BOLD}⚠️ REPAIR FUNCTIONALITY TEMPORARILY DISABLED${RESET}"
|
||||
echo -e "${YELLOW}Database repairs are disabled until corruption issues are resolved.${RESET}"
|
||||
echo -e "${CYAN}Use the individual repair scripts if manual intervention is needed:${RESET}"
|
||||
echo -e " ${DIM}- plex-database-repair.sh${RESET}"
|
||||
echo -e " ${DIM}- recover-plex-database.sh${RESET}"
|
||||
echo -e " ${DIM}- nuclear-plex-recovery.sh${RESET}"
|
||||
print_header
|
||||
check_prerequisites
|
||||
|
||||
local dbrepair_bin
|
||||
if dbrepair_bin=$(find_dbrepair); then
|
||||
log_success "Found DBRepair.sh: $dbrepair_bin"
|
||||
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")
|
||||
echo -e "${RED}${BOLD}⚠️ NUCLEAR RECOVERY TEMPORARILY DISABLED${RESET}"
|
||||
echo -e "${YELLOW}Nuclear recovery is disabled until corruption issues are resolved.${RESET}"
|
||||
echo -e "${CYAN}Use nuclear-plex-recovery.sh directly if absolutely necessary.${RESET}"
|
||||
print_header
|
||||
check_prerequisites
|
||||
|
||||
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")
|
||||
@@ -336,6 +670,24 @@ main() {
|
||||
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
|
||||
log_error "Unknown command: $1"
|
||||
|
||||
714
plex/plex.sh
714
plex/plex.sh
@@ -57,24 +57,285 @@
|
||||
set -euo pipefail
|
||||
|
||||
# 🎨 Color definitions for sexy output
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly BLUE='\033[0;34m'
|
||||
readonly PURPLE='\033[0;35m'
|
||||
readonly CYAN='\033[0;36m'
|
||||
readonly WHITE='\033[1;37m'
|
||||
readonly BOLD='\033[1m'
|
||||
readonly DIM='\033[2m'
|
||||
readonly RESET='\033[0m'
|
||||
readonly RED=$'\033[0;31m'
|
||||
readonly GREEN=$'\033[0;32m'
|
||||
readonly YELLOW=$'\033[1;33m'
|
||||
readonly BLUE=$'\033[0;34m'
|
||||
readonly PURPLE=$'\033[0;35m'
|
||||
readonly CYAN=$'\033[0;36m'
|
||||
readonly WHITE=$'\033[1;37m'
|
||||
readonly BOLD=$'\033[1m'
|
||||
readonly DIM=$'\033[2m'
|
||||
readonly RESET=$'\033[0m'
|
||||
|
||||
# 🔧 Configuration
|
||||
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")"
|
||||
readonly SCRIPT_NAME
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
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
|
||||
readonly CHECKMARK="[✓]"
|
||||
readonly CROSS="[✗]"
|
||||
@@ -114,218 +375,229 @@ show_loading() {
|
||||
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() {
|
||||
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="$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)"
|
||||
local main_db="$PLEX_DB_DIR/com.plexapp.plugins.library.db"
|
||||
|
||||
if [[ ! -f "$main_db" ]]; then
|
||||
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
||||
return 1
|
||||
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
|
||||
print_status "${INFO}" "Stopping Plex service..." "${BLUE}"
|
||||
sudo systemctl stop "$PLEX_SERVICE" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Check if critical tables exist
|
||||
print_status "${INFO}" "Checking database structure..." "${BLUE}"
|
||||
local has_metadata_table=false
|
||||
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
|
||||
has_metadata_table=true
|
||||
# Step 1: WAL checkpoint
|
||||
if [[ -f "${main_db}-wal" ]]; then
|
||||
print_status "${INFO}" "Checkpointing WAL journal..." "${BLUE}"
|
||||
sudo -u plex "$sqlite_bin" "$main_db" "PRAGMA wal_checkpoint(TRUNCATE);" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$has_metadata_table" == "false" ]]; then
|
||||
print_status "${CROSS}" "Critical table 'metadata_items' is missing! Database is severely corrupted." "${RED}"
|
||||
print_status "${INFO}" "Attempting recovery from available backups..." "${YELLOW}"
|
||||
# Step 2: Export → import (the core of DBRepair's repair/optimize)
|
||||
local ts
|
||||
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
|
||||
local recovery_db=""
|
||||
local recovery_candidates=(
|
||||
"$db_dir/com.plexapp.plugins.library.db.recovery-"*
|
||||
"$db_dir/com.plexapp.plugins.library.db.20"*
|
||||
)
|
||||
|
||||
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
|
||||
print_status "${INFO}" "Exporting database to SQL..." "${BLUE}"
|
||||
if sudo -u plex "$sqlite_bin" "$main_db" ".dump" | sudo -u plex tee "$sql_dump" >/dev/null 2>&1; then
|
||||
print_status "${CHECKMARK}" "SQL export completed ($(du -sh "$sql_dump" | cut -f1))" "${GREEN}"
|
||||
else
|
||||
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}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_status "${INFO}" "Importing into fresh database..." "${BLUE}"
|
||||
if sudo cat "$sql_dump" | sudo -u plex "$sqlite_bin" "$new_db" 2>/dev/null; then
|
||||
print_status "${CHECKMARK}" "Import into fresh database succeeded" "${GREEN}"
|
||||
else
|
||||
print_status "${CROSS}" "Import failed" "${RED}"
|
||||
rm -f "$sql_dump" "$new_db" 2>/dev/null
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
rm -f "$sql_dump" 2>/dev/null
|
||||
|
||||
if [[ -n "$recovery_db" ]]; then
|
||||
print_status "${CHECKMARK}" "Found recovery database: $(basename "$recovery_db")" "${GREEN}"
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# Step 4: Reindex (rebuilds all indexes INCLUDING FTS)
|
||||
print_status "${INFO}" "Rebuilding indexes (including FTS)..." "${BLUE}"
|
||||
sudo -u plex "$sqlite_bin" "$new_db" "REINDEX;" 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
|
||||
# Rebuild FTS index content explicitly
|
||||
local fts_tables
|
||||
fts_tables=$(sudo -u plex "$sqlite_bin" "$new_db" \
|
||||
"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"
|
||||
print_status "${CHECKMARK}" "Database restored successfully!" "${GREEN}"
|
||||
# Remove stale journals for the old database
|
||||
sudo rm -f "${main_db}-shm" "${main_db}-wal" 2>/dev/null || true
|
||||
|
||||
# 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}"
|
||||
print_status "${CHECKMARK}" "Database repaired and activated. Backup at: $(basename "$backup_db")" "${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}"
|
||||
# 🔍 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
|
||||
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
|
||||
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
|
||||
fi
|
||||
|
||||
print_status "${CHECKMARK}" "Database backed up to: $backup_db" "${GREEN}"
|
||||
|
||||
# Try to vacuum the database
|
||||
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
|
||||
print_status "${CROSS}" "VACUUM operation failed" "${RED}"
|
||||
fi
|
||||
|
||||
# Try reindex as last resort
|
||||
print_status "${INFO}" "Attempting REINDEX operation..." "${BLUE}"
|
||||
if sudo -u plex sqlite3 "$main_db" "REINDEX;" 2>/dev/null; then
|
||||
print_status "${CHECKMARK}" "Database REINDEX completed" "${GREEN}"
|
||||
|
||||
# Test integrity one more time
|
||||
if sudo -u plex sqlite3 "$main_db" "PRAGMA integrity_check;" 2>/dev/null | grep -q "ok"; then
|
||||
print_status "${CHECKMARK}" "Database integrity restored after REINDEX!" "${GREEN}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
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
|
||||
check_database_integrity() {
|
||||
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="$db_dir/com.plexapp.plugins.library.db"
|
||||
local repair_script="${SCRIPT_DIR}/plex-database-repair.sh"
|
||||
local main_db="$PLEX_DB_DIR/com.plexapp.plugins.library.db"
|
||||
|
||||
if [[ ! -f "$main_db" ]]; then
|
||||
print_status "${CROSS}" "Main database not found at: $main_db" "${RED}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Use shared repair script for integrity checking if available
|
||||
if [[ -f "$repair_script" ]]; then
|
||||
if "$repair_script" check "$main_db" >/dev/null 2>&1; then
|
||||
print_status "${CHECKMARK}" "Database integrity check passed" "${GREEN}"
|
||||
return 0
|
||||
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
|
||||
# Prefer Plex's bundled SQLite for ICU compatibility
|
||||
local sqlite_bin="sqlite3"
|
||||
if [[ -x "$PLEX_SQLITE" ]]; then
|
||||
sqlite_bin="$PLEX_SQLITE"
|
||||
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
|
||||
fi
|
||||
|
||||
print_status "${INFO}" "Consider running database repair: ${SCRIPT_NAME} repair" "${YELLOW}"
|
||||
_hint_install_dbrepair
|
||||
return 1
|
||||
}
|
||||
|
||||
# <20>🚀 Enhanced start function
|
||||
@@ -341,11 +613,12 @@ start_plex() {
|
||||
# Reset any failed state first
|
||||
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
|
||||
print_status "${CROSS}" "Database integrity issues detected. Service may fail to start." "${RED}"
|
||||
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}"
|
||||
return 1
|
||||
print_status "${INFO}" "Database integrity issues detected — starting Plex anyway (it may self-repair)." "${YELLOW}"
|
||||
echo -e "${DIM}${YELLOW} If Plex fails to start, run: ${SCRIPT_NAME} repair${RESET}"
|
||||
_hint_install_dbrepair
|
||||
fi
|
||||
|
||||
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 ""
|
||||
echo -e "${BOLD}${WHITE}Available Commands:${RESET}"
|
||||
echo -e " ${GREEN}${BOLD}start${RESET} ${ROCKET} Start Plex Media Server"
|
||||
echo -e " ${YELLOW}${BOLD}stop${RESET} ${STOP_SIGN} Stop Plex Media Server"
|
||||
echo -e " ${BLUE}${BOLD}restart${RESET} ${RECYCLE} Restart Plex Media Server"
|
||||
echo -e " ${CYAN}${BOLD}status${RESET} ${INFO} Show detailed service status"
|
||||
echo -e " ${PURPLE}${BOLD}scan${RESET} ${SPARKLES} Library scanner operations"
|
||||
echo -e " ${RED}${BOLD}repair${RESET} [!] Repair database corruption issues"
|
||||
echo -e " ${RED}${BOLD}nuclear${RESET} [!!] Nuclear database recovery (last resort)"
|
||||
echo -e " ${PURPLE}${BOLD}help${RESET} [*] Show this help message"
|
||||
printf " ${GREEN}${BOLD}%-18s${RESET} %s %s\n" "start" "${ROCKET}" "Start Plex Media Server"
|
||||
printf " ${YELLOW}${BOLD}%-18s${RESET} %s %s\n" "stop" "${STOP_SIGN}" "Stop Plex Media Server"
|
||||
printf " ${BLUE}${BOLD}%-18s${RESET} %s %s\n" "restart" "${RECYCLE}" "Restart Plex Media Server"
|
||||
printf " ${CYAN}${BOLD}%-18s${RESET} %s %s\n" "status" "${INFO}" "Show detailed service status"
|
||||
printf " ${PURPLE}${BOLD}%-18s${RESET} %s %s\n" "scan" "${SPARKLES}" "Library scanner operations"
|
||||
printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "repair" "[!]" "Repair database corruption issues"
|
||||
printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "nuclear" "[!!]" "Nuclear database recovery (last resort)"
|
||||
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 -e "${DIM}${WHITE}Examples:${RESET}"
|
||||
echo -e " ${DIM}${SCRIPT_NAME} start # Start the Plex service${RESET}"
|
||||
echo -e " ${DIM}${SCRIPT_NAME} status # Show current status${RESET}"
|
||||
echo -e " ${DIM}${SCRIPT_NAME} scan # Launch library scanner interface${RESET}"
|
||||
echo -e " ${DIM}${SCRIPT_NAME} repair # Fix database issues${RESET}"
|
||||
echo -e " ${DIM}${SCRIPT_NAME} nuclear # Complete database replacement${RESET}"
|
||||
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} start" "Start the Plex service"
|
||||
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} status" "Show current status"
|
||||
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} scan" "Launch library scanner interface"
|
||||
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} repair" "Fix database issues"
|
||||
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 ""
|
||||
}
|
||||
|
||||
# 📚 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() {
|
||||
print_status "${SPARKLES}" "Launching Plex Library Scanner..." "${PURPLE}"
|
||||
|
||||
@@ -684,6 +989,23 @@ main() {
|
||||
"nuclear"|"nuke")
|
||||
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")
|
||||
show_help
|
||||
;;
|
||||
|
||||
@@ -44,19 +44,21 @@
|
||||
set -euo pipefail
|
||||
|
||||
# 🎨 Color definitions for styled output
|
||||
readonly RED='\033[0;31m'
|
||||
readonly GREEN='\033[0;32m'
|
||||
readonly YELLOW='\033[1;33m'
|
||||
readonly BLUE='\033[0;34m'
|
||||
readonly PURPLE='\033[0;35m'
|
||||
readonly CYAN='\033[0;36m'
|
||||
readonly WHITE='\033[1;37m'
|
||||
readonly BOLD='\033[1m'
|
||||
readonly DIM='\033[2m'
|
||||
readonly RESET='\033[0m'
|
||||
readonly RED=$'\033[0;31m'
|
||||
readonly GREEN=$'\033[0;32m'
|
||||
readonly YELLOW=$'\033[1;33m'
|
||||
readonly BLUE=$'\033[0;34m'
|
||||
readonly PURPLE=$'\033[0;35m'
|
||||
readonly CYAN=$'\033[0;36m'
|
||||
readonly WHITE=$'\033[1;37m'
|
||||
readonly BOLD=$'\033[1m'
|
||||
readonly DIM=$'\033[2m'
|
||||
readonly RESET=$'\033[0m'
|
||||
|
||||
# 🔧 Configuration
|
||||
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_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
readonly LOG_DIR="${SCRIPT_DIR}/../logs"
|
||||
@@ -144,6 +146,126 @@ find_scanner() {
|
||||
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
|
||||
check_plex_service() {
|
||||
log_verbose "Checking Plex service status..."
|
||||
@@ -166,38 +288,24 @@ list_libraries() {
|
||||
return 2
|
||||
fi
|
||||
|
||||
if ! find_scanner; then
|
||||
return 3
|
||||
local sections
|
||||
if ! sections=$(api_list_sections) || [[ -z "$sections" ]]; then
|
||||
print_status "${CROSS}" "Failed to retrieve library sections from Plex API" "${RED}"
|
||||
return 5
|
||||
fi
|
||||
|
||||
# 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
|
||||
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
|
||||
else
|
||||
print_status "${CROSS}" "Failed to list libraries" "${RED}"
|
||||
echo -e "${DIM}${RED}Error output: $output${RESET}"
|
||||
return 5
|
||||
fi
|
||||
}
|
||||
|
||||
# 🔍 Function to validate section ID
|
||||
@@ -209,10 +317,9 @@ validate_section_id() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Get list of valid section IDs
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
# Get list of valid section IDs via API
|
||||
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
|
||||
return 0
|
||||
else
|
||||
@@ -237,9 +344,7 @@ scan_library() {
|
||||
return 4
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
|
||||
if "$SCANNER_PATH" --scan --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||
if api_scan_section "$section_id"; then
|
||||
print_status "${CHECKMARK}" "Library section $section_id scan completed" "${GREEN}"
|
||||
return 0
|
||||
else
|
||||
@@ -250,16 +355,15 @@ scan_library() {
|
||||
print_status "${ROCKET}" "Scanning all libraries for new media..." "${BLUE}"
|
||||
|
||||
# Get all section IDs and scan each one
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
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=()
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
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}"
|
||||
else
|
||||
print_status "${CROSS}" "Failed to scan section $id" "${RED}"
|
||||
@@ -286,11 +390,6 @@ refresh_library() {
|
||||
local section_id="$1"
|
||||
local force="${2:-false}"
|
||||
|
||||
local force_flag=""
|
||||
if [[ "$force" == "true" ]]; then
|
||||
force_flag="--force"
|
||||
fi
|
||||
|
||||
if [[ -n "$section_id" ]]; then
|
||||
print_status "${RECYCLE}" "Refreshing metadata for library section $section_id..." "${BLUE}"
|
||||
|
||||
@@ -298,9 +397,7 @@ refresh_library() {
|
||||
return 4
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
|
||||
if "$SCANNER_PATH" --refresh $force_flag --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||
if api_refresh_section "$section_id" "$force"; then
|
||||
print_status "${CHECKMARK}" "Library section $section_id metadata refreshed" "${GREEN}"
|
||||
return 0
|
||||
else
|
||||
@@ -311,16 +408,15 @@ refresh_library() {
|
||||
print_status "${RECYCLE}" "Refreshing metadata for all libraries..." "${BLUE}"
|
||||
|
||||
# Get all section IDs and refresh each one
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
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=()
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
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}"
|
||||
else
|
||||
print_status "${CROSS}" "Failed to refresh section $id" "${RED}"
|
||||
@@ -347,11 +443,6 @@ analyze_library() {
|
||||
local section_id="$1"
|
||||
local deep="${2:-false}"
|
||||
|
||||
local analyze_flag="--analyze"
|
||||
if [[ "$deep" == "true" ]]; then
|
||||
analyze_flag="--analyze-deeply"
|
||||
fi
|
||||
|
||||
if [[ -n "$section_id" ]]; then
|
||||
print_status "${SEARCH}" "Analyzing media in library section $section_id..." "${BLUE}"
|
||||
|
||||
@@ -359,9 +450,7 @@ analyze_library() {
|
||||
return 4
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
|
||||
if "$SCANNER_PATH" $analyze_flag --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||
if api_analyze_section "$section_id"; then
|
||||
print_status "${CHECKMARK}" "Library section $section_id analysis completed" "${GREEN}"
|
||||
return 0
|
||||
else
|
||||
@@ -372,16 +461,15 @@ analyze_library() {
|
||||
print_status "${SEARCH}" "Analyzing media in all libraries..." "${BLUE}"
|
||||
|
||||
# Get all section IDs and analyze each one
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
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=()
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
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}"
|
||||
else
|
||||
print_status "${CROSS}" "Failed to analyze section $id" "${RED}"
|
||||
@@ -414,9 +502,7 @@ generate_thumbnails() {
|
||||
return 4
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
|
||||
if "$SCANNER_PATH" --generate --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||
if run_scanner --generate --section "$section_id" ${VERBOSE:+--verbose}; then
|
||||
print_status "${CHECKMARK}" "Thumbnails generated for library section $section_id" "${GREEN}"
|
||||
return 0
|
||||
else
|
||||
@@ -427,16 +513,15 @@ generate_thumbnails() {
|
||||
print_status "${SPARKLES}" "Generating thumbnails for all libraries..." "${BLUE}"
|
||||
|
||||
# Get all section IDs and generate thumbnails for each one
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
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=()
|
||||
|
||||
while IFS= read -r id; do
|
||||
[[ -n "$id" ]] || continue
|
||||
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}"
|
||||
else
|
||||
print_status "${CROSS}" "Failed to generate thumbnails for section $id" "${RED}"
|
||||
@@ -473,9 +558,7 @@ show_library_tree() {
|
||||
return 4
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
|
||||
|
||||
if "$SCANNER_PATH" --tree --section "$section_id"; then
|
||||
if run_scanner --tree --section "$section_id"; then
|
||||
print_status "${CHECKMARK}" "Tree display completed for library section $section_id" "${GREEN}"
|
||||
return 0
|
||||
else
|
||||
@@ -531,15 +614,11 @@ interactive_mode() {
|
||||
echo -e "${DIM}Select an operation to perform:${RESET}"
|
||||
echo ""
|
||||
|
||||
# First, check if Plex is running and scanner is available
|
||||
# First, check if Plex is running
|
||||
if ! check_plex_service; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
if ! find_scanner; then
|
||||
return 3
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo -e "${BOLD}Available Operations:${RESET}"
|
||||
echo -e "${GREEN}1)${RESET} List all libraries"
|
||||
@@ -638,6 +717,9 @@ interactive_mode() {
|
||||
;;
|
||||
5)
|
||||
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 "${GREEN}1)${RESET} Generate for all libraries"
|
||||
echo -e "${GREEN}2)${RESET} Generate for specific library"
|
||||
@@ -655,11 +737,16 @@ interactive_mode() {
|
||||
print_status "${CROSS}" "Invalid choice" "${RED}"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
;;
|
||||
6)
|
||||
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
|
||||
show_library_tree "$section_id"
|
||||
fi
|
||||
;;
|
||||
q|Q)
|
||||
print_status "${INFO}" "Goodbye!" "${CYAN}"
|
||||
@@ -716,10 +803,6 @@ main() {
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! find_scanner; then
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Handle commands
|
||||
case "${1,,}" in
|
||||
"list")
|
||||
@@ -740,10 +823,12 @@ main() {
|
||||
analyze_library "$section_id" "$deep"
|
||||
;;
|
||||
"generate"|"thumbnails")
|
||||
if ! find_scanner; then exit 3; fi
|
||||
local section_id="${2:-}"
|
||||
generate_thumbnails "$section_id"
|
||||
;;
|
||||
"tree")
|
||||
if ! find_scanner; then exit 3; fi
|
||||
local section_id="$2"
|
||||
if [[ -z "$section_id" ]]; then
|
||||
print_status "${CROSS}" "Section ID required for tree command" "${RED}"
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
set -e
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
CYAN=$'\033[0;36m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
# Test configuration
|
||||
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
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
NC=$'\033[0m'
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
# Comprehensive check to ensure Plex is fully recovered and functional
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
RED=$'\033[0;31m'
|
||||
GREEN=$'\033[0;32m'
|
||||
YELLOW=$'\033[1;33m'
|
||||
BLUE=$'\033[0;34m'
|
||||
NC=$'\033[0m' # No Color
|
||||
|
||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user