Compare commits

..

8 Commits

18 changed files with 2133 additions and 186 deletions

1
.gitignore vendored
View File

@@ -43,3 +43,4 @@ dotfiles/my-aliases.zsh
# Compiled binaries # Compiled binaries
tui/tui tui/tui
plex/DBRepair.sh

3
plex/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc

View File

@@ -51,12 +51,12 @@
# Critical operations use explicit error checking instead of automatic exit # Critical operations use explicit error checking instead of automatic exit
# Color codes for output # Color codes for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Performance tracking variables (removed unused variables) # Performance tracking variables (removed unused variables)

View File

@@ -15,11 +15,11 @@
################################################################################ ################################################################################
# Color codes # Color codes
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
RED='\033[0;31m' RED=$'\033[0;31m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Plex database path # Plex database path
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases" PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"

View File

@@ -12,11 +12,11 @@
set +e set +e
# Colors for output # Colors for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Configuration # Configuration
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases" PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"

View File

@@ -55,12 +55,12 @@
set -e set -e
# Color codes for output # Color codes for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Test configuration # Test configuration
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"

View File

@@ -49,13 +49,13 @@
set -e set -e
# Color codes for output # Color codes for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
MAGENTA='\033[0;35m' MAGENTA=$'\033[0;35m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Configuration # Configuration
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"

View File

@@ -58,11 +58,11 @@
set -e set -e
# Colors for output # Colors for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Configuration # Configuration
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases" PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"

View File

@@ -34,15 +34,15 @@
set -euo pipefail set -euo pipefail
# Color codes for output # Color codes for output
readonly RED='\033[0;31m' readonly RED=$'\033[0;31m'
readonly GREEN='\033[0;32m' readonly GREEN=$'\033[0;32m'
readonly YELLOW='\033[1;33m' readonly YELLOW=$'\033[1;33m'
readonly BLUE='\033[0;34m' readonly BLUE=$'\033[0;34m'
readonly CYAN='\033[0;36m' readonly CYAN=$'\033[0;36m'
readonly WHITE='\033[1;37m' readonly WHITE=$'\033[1;37m'
readonly BOLD='\033[1m' readonly BOLD=$'\033[1m'
readonly DIM='\033[2m' readonly DIM=$'\033[2m'
readonly RESET='\033[0m' readonly RESET=$'\033[0m'
# Configuration # Configuration
readonly PLEX_SERVICE="plexmediaserver" readonly PLEX_SERVICE="plexmediaserver"
@@ -57,6 +57,10 @@ readonly SCRIPT_DIR
readonly LOG_FILE="$SCRIPT_DIR/logs/db-manager-$(date +%Y%m%d_%H%M%S).log" readonly LOG_FILE="$SCRIPT_DIR/logs/db-manager-$(date +%Y%m%d_%H%M%S).log"
# DBRepair.sh location — searched in order: script dir, database dir, /usr/local/bin # 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() { find_dbrepair() {
local candidates=( local candidates=(
"${SCRIPT_DIR}/DBRepair.sh" "${SCRIPT_DIR}/DBRepair.sh"
@@ -72,6 +76,231 @@ find_dbrepair() {
return 1 return 1
} }
# Get the latest non-beta release tag from GitHub
_dbrepair_latest_release_tag() {
local tag
tag=$(curl -fsSL "${DBREPAIR_GITHUB_API}" 2>/dev/null \
| grep -Eo '"tag_name"\s*:\s*"[^"]+"' \
| head -n 1 \
| sed 's/"tag_name"\s*:\s*"//;s/"//')
if [[ -z "$tag" ]]; then
return 1
fi
echo "$tag"
}
# Get currently installed DBRepair version
_dbrepair_installed_version() {
local bin="$1"
grep -oP 'Version\s+v\K[0-9.]+' "$bin" 2>/dev/null | head -n 1
}
# Install or update DBRepair.sh
install_or_update_dbrepair() {
log_message "Checking DBRepair (ChuckPa/PlexDBRepair)..."
local latest_tag
if ! latest_tag=$(_dbrepair_latest_release_tag); then
log_error "Failed to query GitHub for the latest release"
log_warning "Check your internet connection or try: https://github.com/ChuckPa/PlexDBRepair/releases"
return 1
fi
log_message "Latest stable release: ${latest_tag}"
local dbrepair_bin
if dbrepair_bin=$(find_dbrepair); then
local installed_ver
installed_ver=$(_dbrepair_installed_version "$dbrepair_bin")
local remote_ver
remote_ver=$(echo "$latest_tag" | sed 's/^v//')
if [[ -n "$installed_ver" ]]; then
log_message "Installed version: v${installed_ver} at ${dbrepair_bin}"
if [[ "$installed_ver" == "$remote_ver" ]]; then
log_success "DBRepair is already up to date (v${installed_ver})"
return 0
else
log_warning "Update available: v${installed_ver} -> ${latest_tag}"
fi
else
log_warning "Installed at ${dbrepair_bin} (version unknown), will update"
fi
else
log_message "DBRepair not found — installing to ${DBREPAIR_INSTALL_PATH}"
fi
local download_url="${DBREPAIR_DOWNLOAD_BASE}/${latest_tag}/DBRepair.sh"
log_message "Downloading ${download_url}"
if curl -fsSL -o "${DBREPAIR_INSTALL_PATH}" "$download_url"; then
chmod +x "${DBREPAIR_INSTALL_PATH}"
log_success "DBRepair ${latest_tag} installed to ${DBREPAIR_INSTALL_PATH}"
return 0
else
log_error "Download failed"
rm -f "${DBREPAIR_INSTALL_PATH}" 2>/dev/null
return 1
fi
}
# Suggest installing DBRepair when errors are found and it's not available
_hint_install_dbrepair() {
if ! find_dbrepair >/dev/null 2>&1; then
echo ""
log_warning "DBRepair is NOT installed. It can fix most database issues automatically."
echo -e " ${CYAN}Install it now: $(basename "$0") install-dbrepair${RESET}"
echo -e " ${CYAN}Then repair: $(basename "$0") repair${RESET}"
fi
}
# List and manage database backup files
list_db_backups() {
local db_dir="$PLEX_DB_DIR"
local -a backup_files=()
local -a backup_paths=()
while IFS= read -r -d '' entry; do
backup_paths+=("$entry")
done < <(sudo find "$db_dir" -maxdepth 1 \( \
-name '*-BACKUP-*' -o \
-name '*-BKUP-*' -o \
-name '*.backup.*' -o \
-name '*recovery*' -o \
-name 'corrupted-*' -o \
-name '*-BLOATED-*' \
\) -print0 2>/dev/null | sort -z)
if [[ ${#backup_paths[@]} -eq 0 ]]; then
log_message "No database backup files found in the Plex database directory"
return 0
fi
echo -e "\n${BOLD}${WHITE} # Type Size Created Name${RESET}"
echo -e " --- ------------- ----------- ------------------------ ------------------------------------"
local idx=0
for entry in "${backup_paths[@]}"; do
((idx++))
local name
name=$(basename "$entry")
local type_label
if [[ "$name" == *-BACKUP-* || "$name" == *-BKUP-* ]]; then
type_label="DBRepair"
elif [[ "$name" == *-BLOATED-* ]]; then
type_label="Bloated"
elif [[ "$name" == *.backup.* ]]; then
type_label="Script"
elif [[ "$name" == corrupted-* ]]; then
type_label="Corrupted"
elif [[ "$name" == *recovery* ]]; then
type_label="Recovery"
else
type_label="Other"
fi
local size
if [[ -d "$entry" ]]; then
size=$(sudo du -sh "$entry" 2>/dev/null | cut -f1)
type_label="${type_label}/dir"
else
size=$(sudo stat --printf='%s' "$entry" 2>/dev/null)
if [[ -n "$size" ]]; then
size=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
else
size="?"
fi
fi
local created
created=$(sudo stat --printf='%y' "$entry" 2>/dev/null | cut -d. -f1)
[[ -z "$created" ]] && created="unknown"
printf " ${WHITE}%-3s${RESET} ${CYAN}%-13s${RESET} ${YELLOW}%-11s${RESET} %-24s %s\n" \
"$idx" "$type_label" "$size" "$created" "$name"
backup_files+=("$entry")
done
echo -e " --- ------------- ----------- ------------------------ ------------------------------------"
echo -e " Total: ${idx} backup file(s)\n"
_BACKUP_LIST=("${backup_files[@]}")
_BACKUP_COUNT=$idx
}
# Interactive backup deletion
delete_db_backups_interactive() {
list_db_backups
if [[ ${_BACKUP_COUNT:-0} -eq 0 ]]; then
return 0
fi
echo -e "${CYAN}Enter backup number(s) to delete (comma-separated), or 'q' to cancel:${RESET} "
read -r selection
if [[ "$selection" == "q" || -z "$selection" ]]; then
log_message "Cancelled"
return 0
fi
IFS=',' read -ra nums <<< "$selection"
local deleted=0
for num in "${nums[@]}"; do
num=$(echo "$num" | tr -d ' ')
if ! [[ "$num" =~ ^[0-9]+$ ]] || (( num < 1 || num > _BACKUP_COUNT )); then
log_error "Invalid selection: $num (skipping)"
continue
fi
local target="${_BACKUP_LIST[$((num-1))]}"
local target_name
target_name=$(basename "$target")
echo -e "${YELLOW}Delete ${target_name}? [y/N]:${RESET} "
read -r confirm
if [[ "${confirm,,}" == "y" ]]; then
if [[ -d "$target" ]]; then
sudo rm -rf "$target"
else
sudo rm -f "$target"
fi
log_success "Deleted: $target_name"
((deleted++))
else
log_message "Skipped: $target_name"
fi
done
echo ""
log_message "Deleted $deleted backup(s)"
}
# Delete backup by name/pattern
delete_db_backup_by_name() {
local pattern="$1"
local db_dir="$PLEX_DB_DIR"
local found=0
while IFS= read -r -d '' entry; do
local name
name=$(basename "$entry")
echo -e "${YELLOW}Delete ${name}? [y/N]:${RESET} "
read -r confirm
if [[ "${confirm,,}" == "y" ]]; then
if [[ -d "$entry" ]]; then
sudo rm -rf "$entry"
else
sudo rm -f "$entry"
fi
log_success "Deleted: $name"
((found++))
fi
done < <(sudo find "$db_dir" -maxdepth 1 -name "*${pattern}*" -print0 2>/dev/null)
if [[ $found -eq 0 ]]; then
log_error "No backups matching '${pattern}' found"
return 1
fi
log_message "Deleted $found file(s)"
}
# Create log directory # Create log directory
mkdir -p "$SCRIPT_DIR/logs" mkdir -p "$SCRIPT_DIR/logs"
@@ -298,26 +527,35 @@ check_all_databases() {
return 0 return 0
else else
log_warning "Found integrity issues in $issues database(s)" log_warning "Found integrity issues in $issues database(s)"
_hint_install_dbrepair
return 1 return 1
fi fi
} }
# Show help # Show help
show_help() { show_help() {
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}$(basename "$0")${RESET} ${YELLOW}<command>${RESET} ${DIM}[options]${RESET}" local script
script=$(basename "$0")
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${script}${RESET} ${YELLOW}<command>${RESET} ${DIM}[options]${RESET}"
echo "" echo ""
echo -e "${BOLD}${WHITE}Commands:${RESET}" echo -e "${BOLD}${WHITE}Commands:${RESET}"
echo -e " ${GREEN}${BOLD}check${RESET} Read-only database integrity check" printf " ${GREEN}${BOLD}%-18s${RESET} %s\n" "check" "Read-only database integrity check"
echo -e " ${YELLOW}${BOLD}repair${RESET} Interactive database repair" printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair" "Interactive database repair"
echo -e " ${YELLOW}${BOLD}repair --gentle${RESET} Gentle repair methods only" printf " ${YELLOW}${BOLD}%-18s${RESET} %s\n" "repair --gentle" "Gentle repair methods only"
echo -e " ${RED}${BOLD}repair --force${RESET} Aggressive repair methods" printf " ${RED}${BOLD}%-18s${RESET} %s\n" "repair --force" "Aggressive repair methods"
echo -e " ${RED}${BOLD}nuclear${RESET} Nuclear recovery (replace from backup)" printf " ${RED}${BOLD}%-18s${RESET} %s\n" "nuclear" "Nuclear recovery (replace from backup)"
echo -e " ${CYAN}${BOLD}help${RESET} Show this help message" printf " ${CYAN}${BOLD}%-18s${RESET} %s\n" "backups" "List and manage database backup files"
printf " ${GREEN}${BOLD}%-18s${RESET} %s\n" "install-dbrepair" "Install or update DBRepair tool"
printf " ${CYAN}${BOLD}%-18s${RESET} %s\n" "help" "Show this help message"
echo "" echo ""
echo -e "${BOLD}${WHITE}Examples:${RESET}" echo -e "${BOLD}${WHITE}Examples:${RESET}"
echo -e " ${DIM}$(basename "$0") check # Safe integrity check${RESET}" printf " ${DIM}%-46s # %s${RESET}\n" "${script} check" "Safe integrity check"
echo -e " ${DIM}$(basename "$0") repair # Interactive repair${RESET}" printf " ${DIM}%-46s # %s${RESET}\n" "${script} repair" "Interactive repair"
echo -e " ${DIM}$(basename "$0") repair --gentle # Minimal repair only${RESET}" printf " ${DIM}%-46s # %s${RESET}\n" "${script} repair --gentle" "Minimal repair only"
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups" "List DB backups"
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups delete" "Interactive backup deletion"
printf " ${DIM}%-46s # %s${RESET}\n" "${script} backups delete --name foo" "Delete by name pattern"
printf " ${DIM}%-46s # %s${RESET}\n" "${script} install-dbrepair" "Install/update DBRepair"
echo "" echo ""
echo -e "${BOLD}${YELLOW}⚠️ WARNING:${RESET} Always run ${CYAN}check${RESET} first before attempting repairs!" echo -e "${BOLD}${YELLOW}⚠️ WARNING:${RESET} Always run ${CYAN}check${RESET} first before attempting repairs!"
echo "" echo ""
@@ -375,9 +613,8 @@ main() {
fi fi
else else
echo -e "${RED}${BOLD}⚠️ DBRepair.sh NOT FOUND${RESET}" echo -e "${RED}${BOLD}⚠️ DBRepair.sh NOT FOUND${RESET}"
echo -e "${YELLOW}Install DBRepair for repair/optimize/reindex/FTS-rebuild:${RESET}" echo -e "${YELLOW}You can install it automatically:${RESET}"
echo -e " ${CYAN}wget -O ${SCRIPT_DIR}/DBRepair.sh https://github.com/ChuckPa/PlexDBRepair/releases/latest/download/DBRepair.sh${RESET}" echo -e " ${CYAN}$(basename "$0") install-dbrepair${RESET}"
echo -e " ${CYAN}chmod +x ${SCRIPT_DIR}/DBRepair.sh${RESET}"
echo -e "${YELLOW}Then re-run: $(basename "$0") repair${RESET}" echo -e "${YELLOW}Then re-run: $(basename "$0") repair${RESET}"
exit 2 exit 2
fi fi
@@ -432,7 +669,25 @@ main() {
print_header print_header
show_help show_help
;; ;;
"install-dbrepair"|"update-dbrepair"|"dbrepair")
print_header
install_or_update_dbrepair
;;
"backups"|"backup-list")
print_header
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
delete_db_backup_by_name "$4"
else
delete_db_backups_interactive
fi
else
list_db_backups
fi
;;
*) *)
print_header print_header
log_error "Unknown command: $1" log_error "Unknown command: $1"

View File

@@ -57,16 +57,16 @@
set -euo pipefail set -euo pipefail
# 🎨 Color definitions for sexy output # 🎨 Color definitions for sexy output
readonly RED='\033[0;31m' readonly RED=$'\033[0;31m'
readonly GREEN='\033[0;32m' readonly GREEN=$'\033[0;32m'
readonly YELLOW='\033[1;33m' readonly YELLOW=$'\033[1;33m'
readonly BLUE='\033[0;34m' readonly BLUE=$'\033[0;34m'
readonly PURPLE='\033[0;35m' readonly PURPLE=$'\033[0;35m'
readonly CYAN='\033[0;36m' readonly CYAN=$'\033[0;36m'
readonly WHITE='\033[1;37m' readonly WHITE=$'\033[1;37m'
readonly BOLD='\033[1m' readonly BOLD=$'\033[1m'
readonly DIM='\033[2m' readonly DIM=$'\033[2m'
readonly RESET='\033[0m' readonly RESET=$'\033[0m'
# 🔧 Configuration # 🔧 Configuration
readonly PLEX_SERVICE="plexmediaserver" readonly PLEX_SERVICE="plexmediaserver"
@@ -78,6 +78,10 @@ SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR readonly SCRIPT_DIR
# DBRepair.sh location — searched in order: script dir, database dir, /usr/local/bin # 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() { find_dbrepair() {
local candidates=( local candidates=(
"${SCRIPT_DIR}/DBRepair.sh" "${SCRIPT_DIR}/DBRepair.sh"
@@ -93,6 +97,245 @@ find_dbrepair() {
return 1 return 1
} }
# Get the latest non-beta release tag from GitHub
_dbrepair_latest_release_tag() {
# Fetch releases, filter out pre-releases & drafts, grab the first tag_name
local tag
tag=$(curl -fsSL "${DBREPAIR_GITHUB_API}" 2>/dev/null \
| grep -Eo '"tag_name"\s*:\s*"[^"]+"' \
| head -n 1 \
| sed 's/"tag_name"\s*:\s*"//;s/"//')
if [[ -z "$tag" ]]; then
return 1
fi
echo "$tag"
}
# Get currently installed DBRepair version (from its own version output)
_dbrepair_installed_version() {
local bin="$1"
# DBRepair prints "Version vX.YY.ZZ" near the top when run interactively;
# we can also grep the script file itself for the version string.
grep -oP 'Version\s+v\K[0-9.]+' "$bin" 2>/dev/null | head -n 1
}
# Install or update DBRepair.sh
install_or_update_dbrepair() {
print_status "${INFO}" "Checking DBRepair (ChuckPa/PlexDBRepair)..." "${BLUE}"
# Determine latest non-beta release
local latest_tag
if ! latest_tag=$(_dbrepair_latest_release_tag); then
print_status "${CROSS}" "Failed to query GitHub for the latest release" "${RED}"
print_status "${INFO}" "Check your internet connection or try: https://github.com/ChuckPa/PlexDBRepair/releases" "${YELLOW}"
return 1
fi
print_status "${INFO}" "Latest stable release: ${latest_tag}" "${BLUE}"
# Check if already installed
local dbrepair_bin
if dbrepair_bin=$(find_dbrepair); then
local installed_ver
installed_ver=$(_dbrepair_installed_version "$dbrepair_bin")
local remote_ver
remote_ver=$(echo "$latest_tag" | sed 's/^v//')
if [[ -n "$installed_ver" ]]; then
print_status "${INFO}" "Installed version: v${installed_ver} at ${dbrepair_bin}" "${BLUE}"
if [[ "$installed_ver" == "$remote_ver" ]]; then
print_status "${CHECKMARK}" "DBRepair is already up to date (v${installed_ver})" "${GREEN}"
return 0
else
print_status "${INFO}" "Update available: v${installed_ver} -> ${latest_tag}" "${YELLOW}"
fi
else
print_status "${INFO}" "Installed at ${dbrepair_bin} (version unknown), will update" "${YELLOW}"
fi
else
print_status "${INFO}" "DBRepair not found — installing to ${DBREPAIR_INSTALL_PATH}" "${BLUE}"
fi
# Download
local download_url="${DBREPAIR_DOWNLOAD_BASE}/${latest_tag}/DBRepair.sh"
print_status "${INFO}" "Downloading ${download_url}" "${BLUE}"
if curl -fsSL -o "${DBREPAIR_INSTALL_PATH}" "$download_url"; then
chmod +x "${DBREPAIR_INSTALL_PATH}"
print_status "${CHECKMARK}" "DBRepair ${latest_tag} installed to ${DBREPAIR_INSTALL_PATH}" "${GREEN}"
return 0
else
print_status "${CROSS}" "Download failed" "${RED}"
rm -f "${DBREPAIR_INSTALL_PATH}" 2>/dev/null
return 1
fi
}
# Suggest installing DBRepair when errors are found and it's not available
_hint_install_dbrepair() {
if ! find_dbrepair >/dev/null 2>&1; then
echo ""
print_status "${INFO}" "DBRepair is NOT installed. It can fix most database issues automatically." "${YELLOW}"
echo -e "${DIM}${CYAN} Install it now: ${SCRIPT_NAME} install-dbrepair${RESET}"
echo -e "${DIM}${CYAN} Then repair: ${SCRIPT_NAME} repair${RESET}"
fi
}
# 📦 List and manage database backup files
# Covers: DBRepair backups (-BACKUP-*, -BKUP-*), script backups (*.backup.*),
# corrupted dirs (corrupted-*), recovery files (*recovery*)
list_db_backups() {
local db_dir="$PLEX_DB_DIR"
local -a backup_files=()
local -a backup_paths=()
# Collect all backup-like files and dirs
while IFS= read -r -d '' entry; do
backup_paths+=("$entry")
done < <(sudo find "$db_dir" -maxdepth 1 \( \
-name '*-BACKUP-*' -o \
-name '*-BKUP-*' -o \
-name '*.backup.*' -o \
-name '*recovery*' -o \
-name 'corrupted-*' -o \
-name '*-BLOATED-*' \
\) -print0 2>/dev/null | sort -z)
if [[ ${#backup_paths[@]} -eq 0 ]]; then
print_status "${INFO}" "No database backup files found in the Plex database directory" "${YELLOW}"
return 0
fi
echo -e "\n${BOLD}${WHITE} # Type Size Created Name${RESET}"
echo -e "${DIM}${CYAN} --- ------------- ----------- ------------------------ ------------------------------------${RESET}"
local idx=0
for entry in "${backup_paths[@]}"; do
((idx++))
local name
name=$(basename "$entry")
# Determine type label
local type_label
if [[ "$name" == *-BACKUP-* || "$name" == *-BKUP-* ]]; then
type_label="DBRepair"
elif [[ "$name" == *-BLOATED-* ]]; then
type_label="Bloated"
elif [[ "$name" == *.backup.* ]]; then
type_label="Script"
elif [[ "$name" == corrupted-* ]]; then
type_label="Corrupted"
elif [[ "$name" == *recovery* ]]; then
type_label="Recovery"
else
type_label="Other"
fi
# Size (human-readable)
local size
if [[ -d "$entry" ]]; then
size=$(sudo du -sh "$entry" 2>/dev/null | cut -f1)
type_label="${type_label}/dir"
else
size=$(sudo stat --printf='%s' "$entry" 2>/dev/null)
if [[ -n "$size" ]]; then
size=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
else
size="?"
fi
fi
# Created date
local created
created=$(sudo stat --printf='%y' "$entry" 2>/dev/null | cut -d. -f1)
[[ -z "$created" ]] && created="unknown"
printf " ${WHITE}%-3s${RESET} ${CYAN}%-13s${RESET} ${YELLOW}%-11s${RESET} ${DIM}%-24s${RESET} %s\n" \
"$idx" "$type_label" "$size" "$created" "$name"
backup_files+=("$entry")
done
echo -e "${DIM}${CYAN} --- ------------- ----------- ------------------------ ------------------------------------${RESET}"
echo -e " ${DIM}Total: ${idx} backup file(s)${RESET}\n"
# Store for use by delete function
_BACKUP_LIST=("${backup_files[@]}")
_BACKUP_COUNT=$idx
}
# Interactive backup deletion
delete_db_backups_interactive() {
list_db_backups
if [[ ${_BACKUP_COUNT:-0} -eq 0 ]]; then
return 0
fi
echo -e "${CYAN}Enter backup number(s) to delete (comma-separated), or 'q' to cancel:${RESET} "
read -r selection
if [[ "$selection" == "q" || -z "$selection" ]]; then
print_status "${INFO}" "Cancelled" "${YELLOW}"
return 0
fi
# Parse comma-separated numbers
IFS=',' read -ra nums <<< "$selection"
local deleted=0
for num in "${nums[@]}"; do
num=$(echo "$num" | tr -d ' ')
if ! [[ "$num" =~ ^[0-9]+$ ]] || (( num < 1 || num > _BACKUP_COUNT )); then
print_status "${CROSS}" "Invalid selection: $num (skipping)" "${RED}"
continue
fi
local target="${_BACKUP_LIST[$((num-1))]}"
local target_name
target_name=$(basename "$target")
echo -e "${YELLOW}Delete ${target_name}? [y/N]:${RESET} "
read -r confirm
if [[ "${confirm,,}" == "y" ]]; then
if [[ -d "$target" ]]; then
sudo rm -rf "$target"
else
sudo rm -f "$target"
fi
print_status "${CHECKMARK}" "Deleted: $target_name" "${GREEN}"
((deleted++))
else
print_status "${INFO}" "Skipped: $target_name" "${YELLOW}"
fi
done
echo ""
print_status "${INFO}" "Deleted $deleted backup(s)" "${BLUE}"
}
# Delete backup by name/pattern (for scripted use)
delete_db_backup_by_name() {
local pattern="$1"
local db_dir="$PLEX_DB_DIR"
local found=0
while IFS= read -r -d '' entry; do
local name
name=$(basename "$entry")
echo -e "${YELLOW}Delete ${name}? [y/N]:${RESET} "
read -r confirm
if [[ "${confirm,,}" == "y" ]]; then
if [[ -d "$entry" ]]; then
sudo rm -rf "$entry"
else
sudo rm -f "$entry"
fi
print_status "${CHECKMARK}" "Deleted: $name" "${GREEN}"
((found++))
fi
done < <(sudo find "$db_dir" -maxdepth 1 -name "*${pattern}*" -print0 2>/dev/null)
if [[ $found -eq 0 ]]; then
print_status "${CROSS}" "No backups matching '${pattern}' found" "${RED}"
return 1
fi
print_status "${INFO}" "Deleted $found file(s)" "${BLUE}"
}
# 🎭 ASCII symbols for compatible output # 🎭 ASCII symbols for compatible output
readonly CHECKMARK="[✓]" readonly CHECKMARK="[✓]"
readonly CROSS="[✗]" readonly CROSS="[✗]"
@@ -353,6 +596,7 @@ check_database_integrity() {
fi fi
print_status "${INFO}" "Consider running database repair: ${SCRIPT_NAME} repair" "${YELLOW}" print_status "${INFO}" "Consider running database repair: ${SCRIPT_NAME} repair" "${YELLOW}"
_hint_install_dbrepair
return 1 return 1
} }
@@ -374,6 +618,7 @@ start_plex() {
if ! check_database_integrity; then if ! check_database_integrity; then
print_status "${INFO}" "Database integrity issues detected — starting Plex anyway (it may self-repair)." "${YELLOW}" 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}" echo -e "${DIM}${YELLOW} If Plex fails to start, run: ${SCRIPT_NAME} repair${RESET}"
_hint_install_dbrepair
fi fi
print_status "${INFO}" "Attempting to start service..." "${BLUE}" print_status "${INFO}" "Attempting to start service..." "${BLUE}"
@@ -530,25 +775,57 @@ show_help() {
echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${SCRIPT_NAME}${RESET} ${YELLOW}<command>${RESET}" echo -e "${BOLD}${WHITE}Usage:${RESET} ${CYAN}${SCRIPT_NAME}${RESET} ${YELLOW}<command>${RESET}"
echo "" echo ""
echo -e "${BOLD}${WHITE}Available Commands:${RESET}" echo -e "${BOLD}${WHITE}Available Commands:${RESET}"
echo -e " ${GREEN}${BOLD}start${RESET} ${ROCKET} Start Plex Media Server" printf " ${GREEN}${BOLD}%-18s${RESET} %s %s\n" "start" "${ROCKET}" "Start Plex Media Server"
echo -e " ${YELLOW}${BOLD}stop${RESET} ${STOP_SIGN} Stop Plex Media Server" printf " ${YELLOW}${BOLD}%-18s${RESET} %s %s\n" "stop" "${STOP_SIGN}" "Stop Plex Media Server"
echo -e " ${BLUE}${BOLD}restart${RESET} ${RECYCLE} Restart Plex Media Server" printf " ${BLUE}${BOLD}%-18s${RESET} %s %s\n" "restart" "${RECYCLE}" "Restart Plex Media Server"
echo -e " ${CYAN}${BOLD}status${RESET} ${INFO} Show detailed service status" printf " ${CYAN}${BOLD}%-18s${RESET} %s %s\n" "status" "${INFO}" "Show detailed service status"
echo -e " ${PURPLE}${BOLD}scan${RESET} ${SPARKLES} Library scanner operations" printf " ${PURPLE}${BOLD}%-18s${RESET} %s %s\n" "scan" "${SPARKLES}" "Library scanner operations"
echo -e " ${RED}${BOLD}repair${RESET} [!] Repair database corruption issues" printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "repair" "[!]" "Repair database corruption issues"
echo -e " ${RED}${BOLD}nuclear${RESET} [!!] Nuclear database recovery (last resort)" printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "nuclear" "[!!]" "Nuclear database recovery (last resort)"
echo -e " ${PURPLE}${BOLD}help${RESET} [*] Show this help message" printf " ${CYAN}${BOLD}%-18s${RESET} %s %s\n" "backups" "[#]" "List and manage database backup files"
printf " ${GREEN}${BOLD}%-18s${RESET} %s %s\n" "install-dbrepair" "[+]" "Install or update DBRepair tool"
printf " ${WHITE}${BOLD}%-18s${RESET} %s %s\n" "tui" "[>]" "Launch interactive TUI dashboard"
printf " ${PURPLE}${BOLD}%-18s${RESET} %s %s\n" "help" "${HOURGLASS}" "Show this help message"
echo "" echo ""
echo -e "${DIM}${WHITE}Examples:${RESET}" echo -e "${DIM}${WHITE}Examples:${RESET}"
echo -e " ${DIM}${SCRIPT_NAME} start # Start the Plex service${RESET}" printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} start" "Start the Plex service"
echo -e " ${DIM}${SCRIPT_NAME} status # Show current status${RESET}" printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} status" "Show current status"
echo -e " ${DIM}${SCRIPT_NAME} scan # Launch library scanner interface${RESET}" printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} scan" "Launch library scanner interface"
echo -e " ${DIM}${SCRIPT_NAME} repair # Fix database issues${RESET}" printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} repair" "Fix database issues"
echo -e " ${DIM}${SCRIPT_NAME} nuclear # Complete database replacement${RESET}" printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} nuclear" "Complete database replacement"
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups" "List and manage DB backups"
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups delete" "Interactive backup deletion"
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups delete --name foo" "Delete by name pattern"
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} install-dbrepair" "Install/update DBRepair"
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} tui" "Launch full TUI dashboard"
echo "" echo ""
} }
# 📚 Function to launch library scanner # <EFBFBD> Function to launch the Python TUI dashboard
launch_tui() {
local venv_dir="${SCRIPT_DIR}/.venv"
local tui_app="${SCRIPT_DIR}/tui/app.py"
if [[ ! -f "$tui_app" ]]; then
print_status "${CROSS}" "TUI application not found: $tui_app" "${RED}"
return 1
fi
# Create venv and install dependencies if needed
if [[ ! -d "$venv_dir" ]]; then
print_status "${INFO}" "Setting up Python environment (first run)..." "${BLUE}"
if ! python3 -m venv "$venv_dir" 2>/dev/null; then
print_status "${CROSS}" "Failed to create Python venv. Install python3-venv." "${RED}"
return 1
fi
"${venv_dir}/bin/pip" install --quiet textual
print_status "${CHECKMARK}" "Python environment ready" "${GREEN}"
fi
exec "${venv_dir}/bin/python3" "$tui_app"
}
# <20>📚 Function to launch library scanner
launch_scanner() { launch_scanner() {
print_status "${SPARKLES}" "Launching Plex Library Scanner..." "${PURPLE}" print_status "${SPARKLES}" "Launching Plex Library Scanner..." "${PURPLE}"
@@ -712,6 +989,23 @@ main() {
"nuclear"|"nuke") "nuclear"|"nuke")
nuclear_recovery nuclear_recovery
;; ;;
"install-dbrepair"|"update-dbrepair"|"dbrepair")
install_or_update_dbrepair
;;
"tui"|"dashboard"|"ui")
launch_tui
;;
"backups"|"backup-list")
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
delete_db_backup_by_name "$4"
else
delete_db_backups_interactive
fi
else
list_db_backups
fi
;;
"help"|"--help"|"-h") "help"|"--help"|"-h")
show_help show_help
;; ;;

View File

@@ -44,19 +44,21 @@
set -euo pipefail set -euo pipefail
# 🎨 Color definitions for styled output # 🎨 Color definitions for styled output
readonly RED='\033[0;31m' readonly RED=$'\033[0;31m'
readonly GREEN='\033[0;32m' readonly GREEN=$'\033[0;32m'
readonly YELLOW='\033[1;33m' readonly YELLOW=$'\033[1;33m'
readonly BLUE='\033[0;34m' readonly BLUE=$'\033[0;34m'
readonly PURPLE='\033[0;35m' readonly PURPLE=$'\033[0;35m'
readonly CYAN='\033[0;36m' readonly CYAN=$'\033[0;36m'
readonly WHITE='\033[1;37m' readonly WHITE=$'\033[1;37m'
readonly BOLD='\033[1m' readonly BOLD=$'\033[1m'
readonly DIM='\033[2m' readonly DIM=$'\033[2m'
readonly RESET='\033[0m' readonly RESET=$'\033[0m'
# 🔧 Configuration # 🔧 Configuration
readonly PLEX_SERVICE="plexmediaserver" readonly PLEX_SERVICE="plexmediaserver"
readonly PLEX_PREFS="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Preferences.xml"
readonly PLEX_API_BASE="http://localhost:32400"
readonly SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" readonly SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly LOG_DIR="${SCRIPT_DIR}/../logs" readonly LOG_DIR="${SCRIPT_DIR}/../logs"
@@ -144,6 +146,126 @@ find_scanner() {
return 1 return 1
} }
# 🚀 Run Plex Media Scanner as the plex user with correct environment
# Usage: run_scanner [args...] — runs and returns exit code
run_scanner() {
sudo -u plex \
env LD_LIBRARY_PATH=/usr/lib/plexmediaserver \
PLEX_MEDIA_SERVER_APPLICATION_SUPPORT_DIR="/var/lib/plexmediaserver/Library/Application Support" \
"$SCANNER_PATH" "$@"
}
# 🔑 Get Plex authentication token from Preferences.xml
get_plex_token() {
local token
token=$(sudo grep -oP 'PlexOnlineToken="\K[^"]+' "$PLEX_PREFS" 2>/dev/null)
if [[ -z "$token" ]]; then
log_verbose "Could not read Plex token from Preferences.xml"
return 1
fi
echo "$token"
}
# 📡 Trigger a library scan via the Plex API (replaces deprecated --scan)
# Usage: api_scan_section <section_id>
api_scan_section() {
local section_id="$1"
local token
if ! token=$(get_plex_token); then
log_verbose "Cannot scan: no Plex token available"
return 1
fi
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X GET "${PLEX_API_BASE}/library/sections/${section_id}/refresh?X-Plex-Token=${token}")
if [[ "$http_code" =~ ^2 ]]; then
return 0
else
log_verbose "API scan failed for section $section_id (HTTP $http_code)"
return 1
fi
}
# 📡 Trigger a metadata refresh via the Plex API (replaces deprecated --refresh)
# Usage: api_refresh_section <section_id> [force]
api_refresh_section() {
local section_id="$1"
local force="${2:-false}"
local token
if ! token=$(get_plex_token); then
log_verbose "Cannot refresh: no Plex token available"
return 1
fi
local url="${PLEX_API_BASE}/library/sections/${section_id}/refresh?X-Plex-Token=${token}"
if [[ "$force" == "true" ]]; then
url+="&force=1"
fi
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$url")
if [[ "$http_code" =~ ^2 ]]; then
return 0
else
log_verbose "API refresh failed for section $section_id (HTTP $http_code)"
return 1
fi
}
# 📡 Trigger media analysis via the Plex API (replaces deprecated --analyze)
# Usage: api_analyze_section <section_id>
api_analyze_section() {
local section_id="$1"
local token
if ! token=$(get_plex_token); then
log_verbose "Cannot analyze: no Plex token available"
return 1
fi
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X PUT "${PLEX_API_BASE}/library/sections/${section_id}/analyze?X-Plex-Token=${token}")
if [[ "$http_code" =~ ^2 ]]; then
return 0
else
log_verbose "API analyze failed for section $section_id (HTTP $http_code)"
return 1
fi
}
# 📡 List library sections via the Plex API
# Output format: "key|title|type" per line (e.g. "1|Movies|movie")
api_list_sections() {
local token
if ! token=$(get_plex_token); then
return 1
fi
local xml
if ! xml=$(curl -fsS "${PLEX_API_BASE}/library/sections?X-Plex-Token=${token}" 2>/dev/null); then
log_verbose "Plex API request failed"
return 1
fi
# Parse XML: extract key, title, and type from <Directory> elements
echo "$xml" | grep -oP '<Directory[^>]*>' | while IFS= read -r tag; do
local key title type
key=$(echo "$tag" | grep -oP 'key="\K[^"]+')
title=$(echo "$tag" | grep -oP 'title="\K[^"]+')
type=$(echo "$tag" | grep -oP 'type="\K[^"]+')
echo "${key}|${title}|${type}"
done
}
# 📋 Get just the section IDs from the API (one per line)
api_list_section_ids() {
api_list_sections | cut -d'|' -f1
}
# 🏥 Function to check Plex service status # 🏥 Function to check Plex service status
check_plex_service() { check_plex_service() {
log_verbose "Checking Plex service status..." log_verbose "Checking Plex service status..."
@@ -166,38 +288,24 @@ list_libraries() {
return 2 return 2
fi fi
if ! find_scanner; then local sections
return 3 if ! sections=$(api_list_sections) || [[ -z "$sections" ]]; then
fi print_status "${CROSS}" "Failed to retrieve library sections from Plex API" "${RED}"
# Set library path for Linux
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local output
if output=$("$SCANNER_PATH" --list 2>&1); then
echo ""
echo -e "${BOLD}${CYAN}Available Library Sections:${RESET}"
echo -e "${DIM}${CYAN}=========================${RESET}"
# Parse and format the output
echo "$output" | while IFS= read -r line; do
if [[ "$line" =~ ^[[:space:]]*([0-9]+):[[:space:]]*(.+)$ ]]; then
local section_id="${BASH_REMATCH[1]}"
local section_name="${BASH_REMATCH[2]}"
echo -e "${GREEN}${BOLD} ${section_id}:${RESET} ${WHITE}${section_name}${RESET}"
elif [[ -n "$line" ]]; then
echo -e "${DIM} $line${RESET}"
fi
done
echo ""
print_status "${CHECKMARK}" "Library listing completed" "${GREEN}"
return 0
else
print_status "${CROSS}" "Failed to list libraries" "${RED}"
echo -e "${DIM}${RED}Error output: $output${RESET}"
return 5 return 5
fi fi
echo ""
echo -e "${BOLD}${CYAN}Available Library Sections:${RESET}"
echo -e "${DIM}${CYAN}=========================${RESET}"
while IFS='|' read -r key title type; do
[[ -n "$key" ]] || continue
printf " ${GREEN}${BOLD}%-4s${RESET} ${WHITE}%-30s${RESET} ${DIM}(%s)${RESET}\n" "${key}:" "$title" "$type"
done <<< "$sections"
echo ""
print_status "${CHECKMARK}" "Library listing completed" "${GREEN}"
return 0
} }
# 🔍 Function to validate section ID # 🔍 Function to validate section ID
@@ -209,10 +317,9 @@ validate_section_id() {
return 1 return 1
fi fi
# Get list of valid section IDs # Get list of valid section IDs via API
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local valid_ids local valid_ids
if valid_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then if valid_ids=$(api_list_section_ids) && [[ -n "$valid_ids" ]]; then
if echo "$valid_ids" | grep -q "^${section_id}$"; then if echo "$valid_ids" | grep -q "^${section_id}$"; then
return 0 return 0
else else
@@ -237,9 +344,7 @@ scan_library() {
return 4 return 4
fi fi
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-} if api_scan_section "$section_id"; then
if "$SCANNER_PATH" --scan --section "$section_id" ${VERBOSE:+--verbose}; then
print_status "${CHECKMARK}" "Library section $section_id scan completed" "${GREEN}" print_status "${CHECKMARK}" "Library section $section_id scan completed" "${GREEN}"
return 0 return 0
else else
@@ -250,16 +355,15 @@ scan_library() {
print_status "${ROCKET}" "Scanning all libraries for new media..." "${BLUE}" print_status "${ROCKET}" "Scanning all libraries for new media..." "${BLUE}"
# Get all section IDs and scan each one # Get all section IDs and scan each one
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local section_ids local section_ids
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
local failed_sections=() local failed_sections=()
while IFS= read -r id; do while IFS= read -r id; do
[[ -n "$id" ]] || continue [[ -n "$id" ]] || continue
print_status "${INFO}" "Scanning section $id..." "${YELLOW}" print_status "${INFO}" "Scanning section $id..." "${YELLOW}"
if "$SCANNER_PATH" --scan --section "$id" ${VERBOSE:+--verbose}; then if api_scan_section "$id"; then
print_status "${CHECKMARK}" "Section $id scanned successfully" "${GREEN}" print_status "${CHECKMARK}" "Section $id scanned successfully" "${GREEN}"
else else
print_status "${CROSS}" "Failed to scan section $id" "${RED}" print_status "${CROSS}" "Failed to scan section $id" "${RED}"
@@ -286,11 +390,6 @@ refresh_library() {
local section_id="$1" local section_id="$1"
local force="${2:-false}" local force="${2:-false}"
local force_flag=""
if [[ "$force" == "true" ]]; then
force_flag="--force"
fi
if [[ -n "$section_id" ]]; then if [[ -n "$section_id" ]]; then
print_status "${RECYCLE}" "Refreshing metadata for library section $section_id..." "${BLUE}" print_status "${RECYCLE}" "Refreshing metadata for library section $section_id..." "${BLUE}"
@@ -298,9 +397,7 @@ refresh_library() {
return 4 return 4
fi fi
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-} if api_refresh_section "$section_id" "$force"; then
if "$SCANNER_PATH" --refresh $force_flag --section "$section_id" ${VERBOSE:+--verbose}; then
print_status "${CHECKMARK}" "Library section $section_id metadata refreshed" "${GREEN}" print_status "${CHECKMARK}" "Library section $section_id metadata refreshed" "${GREEN}"
return 0 return 0
else else
@@ -311,16 +408,15 @@ refresh_library() {
print_status "${RECYCLE}" "Refreshing metadata for all libraries..." "${BLUE}" print_status "${RECYCLE}" "Refreshing metadata for all libraries..." "${BLUE}"
# Get all section IDs and refresh each one # Get all section IDs and refresh each one
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local section_ids local section_ids
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
local failed_sections=() local failed_sections=()
while IFS= read -r id; do while IFS= read -r id; do
[[ -n "$id" ]] || continue [[ -n "$id" ]] || continue
print_status "${INFO}" "Refreshing section $id..." "${YELLOW}" print_status "${INFO}" "Refreshing section $id..." "${YELLOW}"
if "$SCANNER_PATH" --refresh $force_flag --section "$id" ${VERBOSE:+--verbose}; then if api_refresh_section "$id" "$force"; then
print_status "${CHECKMARK}" "Section $id refreshed successfully" "${GREEN}" print_status "${CHECKMARK}" "Section $id refreshed successfully" "${GREEN}"
else else
print_status "${CROSS}" "Failed to refresh section $id" "${RED}" print_status "${CROSS}" "Failed to refresh section $id" "${RED}"
@@ -347,11 +443,6 @@ analyze_library() {
local section_id="$1" local section_id="$1"
local deep="${2:-false}" local deep="${2:-false}"
local analyze_flag="--analyze"
if [[ "$deep" == "true" ]]; then
analyze_flag="--analyze-deeply"
fi
if [[ -n "$section_id" ]]; then if [[ -n "$section_id" ]]; then
print_status "${SEARCH}" "Analyzing media in library section $section_id..." "${BLUE}" print_status "${SEARCH}" "Analyzing media in library section $section_id..." "${BLUE}"
@@ -359,9 +450,7 @@ analyze_library() {
return 4 return 4
fi fi
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-} if api_analyze_section "$section_id"; then
if "$SCANNER_PATH" $analyze_flag --section "$section_id" ${VERBOSE:+--verbose}; then
print_status "${CHECKMARK}" "Library section $section_id analysis completed" "${GREEN}" print_status "${CHECKMARK}" "Library section $section_id analysis completed" "${GREEN}"
return 0 return 0
else else
@@ -372,16 +461,15 @@ analyze_library() {
print_status "${SEARCH}" "Analyzing media in all libraries..." "${BLUE}" print_status "${SEARCH}" "Analyzing media in all libraries..." "${BLUE}"
# Get all section IDs and analyze each one # Get all section IDs and analyze each one
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local section_ids local section_ids
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
local failed_sections=() local failed_sections=()
while IFS= read -r id; do while IFS= read -r id; do
[[ -n "$id" ]] || continue [[ -n "$id" ]] || continue
print_status "${INFO}" "Analyzing section $id..." "${YELLOW}" print_status "${INFO}" "Analyzing section $id..." "${YELLOW}"
if "$SCANNER_PATH" $analyze_flag --section "$id" ${VERBOSE:+--verbose}; then if api_analyze_section "$id"; then
print_status "${CHECKMARK}" "Section $id analyzed successfully" "${GREEN}" print_status "${CHECKMARK}" "Section $id analyzed successfully" "${GREEN}"
else else
print_status "${CROSS}" "Failed to analyze section $id" "${RED}" print_status "${CROSS}" "Failed to analyze section $id" "${RED}"
@@ -414,9 +502,7 @@ generate_thumbnails() {
return 4 return 4
fi fi
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-} if run_scanner --generate --section "$section_id" ${VERBOSE:+--verbose}; then
if "$SCANNER_PATH" --generate --section "$section_id" ${VERBOSE:+--verbose}; then
print_status "${CHECKMARK}" "Thumbnails generated for library section $section_id" "${GREEN}" print_status "${CHECKMARK}" "Thumbnails generated for library section $section_id" "${GREEN}"
return 0 return 0
else else
@@ -427,16 +513,15 @@ generate_thumbnails() {
print_status "${SPARKLES}" "Generating thumbnails for all libraries..." "${BLUE}" print_status "${SPARKLES}" "Generating thumbnails for all libraries..." "${BLUE}"
# Get all section IDs and generate thumbnails for each one # Get all section IDs and generate thumbnails for each one
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-}
local section_ids local section_ids
if section_ids=$("$SCANNER_PATH" --list 2>/dev/null | grep -oE '^[[:space:]]*[0-9]+:' | grep -oE '[0-9]+'); then if section_ids=$(api_list_section_ids) && [[ -n "$section_ids" ]]; then
local failed_sections=() local failed_sections=()
while IFS= read -r id; do while IFS= read -r id; do
[[ -n "$id" ]] || continue [[ -n "$id" ]] || continue
print_status "${INFO}" "Generating thumbnails for section $id..." "${YELLOW}" print_status "${INFO}" "Generating thumbnails for section $id..." "${YELLOW}"
if "$SCANNER_PATH" --generate --section "$id" ${VERBOSE:+--verbose}; then if run_scanner --generate --section "$id" ${VERBOSE:+--verbose}; then
print_status "${CHECKMARK}" "Section $id thumbnails generated successfully" "${GREEN}" print_status "${CHECKMARK}" "Section $id thumbnails generated successfully" "${GREEN}"
else else
print_status "${CROSS}" "Failed to generate thumbnails for section $id" "${RED}" print_status "${CROSS}" "Failed to generate thumbnails for section $id" "${RED}"
@@ -473,9 +558,7 @@ show_library_tree() {
return 4 return 4
fi fi
export LD_LIBRARY_PATH=/usr/lib/plexmediaserver:${LD_LIBRARY_PATH:-} if run_scanner --tree --section "$section_id"; then
if "$SCANNER_PATH" --tree --section "$section_id"; then
print_status "${CHECKMARK}" "Tree display completed for library section $section_id" "${GREEN}" print_status "${CHECKMARK}" "Tree display completed for library section $section_id" "${GREEN}"
return 0 return 0
else else
@@ -531,15 +614,11 @@ interactive_mode() {
echo -e "${DIM}Select an operation to perform:${RESET}" echo -e "${DIM}Select an operation to perform:${RESET}"
echo "" echo ""
# First, check if Plex is running and scanner is available # First, check if Plex is running
if ! check_plex_service; then if ! check_plex_service; then
return 2 return 2
fi fi
if ! find_scanner; then
return 3
fi
while true; do while true; do
echo -e "${BOLD}Available Operations:${RESET}" echo -e "${BOLD}Available Operations:${RESET}"
echo -e "${GREEN}1)${RESET} List all libraries" echo -e "${GREEN}1)${RESET} List all libraries"
@@ -638,6 +717,9 @@ interactive_mode() {
;; ;;
5) 5)
echo "" echo ""
if ! find_scanner; then
print_status "${CROSS}" "Scanner binary required for thumbnail generation" "${RED}"
else
echo -e "${BOLD}Thumbnail Generation Options:${RESET}" echo -e "${BOLD}Thumbnail Generation Options:${RESET}"
echo -e "${GREEN}1)${RESET} Generate for all libraries" echo -e "${GREEN}1)${RESET} Generate for all libraries"
echo -e "${GREEN}2)${RESET} Generate for specific library" echo -e "${GREEN}2)${RESET} Generate for specific library"
@@ -655,11 +737,16 @@ interactive_mode() {
print_status "${CROSS}" "Invalid choice" "${RED}" print_status "${CROSS}" "Invalid choice" "${RED}"
;; ;;
esac esac
fi
;; ;;
6) 6)
echo "" echo ""
if ! find_scanner; then
print_status "${CROSS}" "Scanner binary required for tree display" "${RED}"
else
read -p "$(echo -e "${BOLD}Enter section ID to show tree:${RESET} ")" section_id read -p "$(echo -e "${BOLD}Enter section ID to show tree:${RESET} ")" section_id
show_library_tree "$section_id" show_library_tree "$section_id"
fi
;; ;;
q|Q) q|Q)
print_status "${INFO}" "Goodbye!" "${CYAN}" print_status "${INFO}" "Goodbye!" "${CYAN}"
@@ -716,10 +803,6 @@ main() {
exit 2 exit 2
fi fi
if ! find_scanner; then
exit 3
fi
# Handle commands # Handle commands
case "${1,,}" in case "${1,,}" in
"list") "list")
@@ -740,10 +823,12 @@ main() {
analyze_library "$section_id" "$deep" analyze_library "$section_id" "$deep"
;; ;;
"generate"|"thumbnails") "generate"|"thumbnails")
if ! find_scanner; then exit 3; fi
local section_id="${2:-}" local section_id="${2:-}"
generate_thumbnails "$section_id" generate_thumbnails "$section_id"
;; ;;
"tree") "tree")
if ! find_scanner; then exit 3; fi
local section_id="$2" local section_id="$2"
if [[ -z "$section_id" ]]; then if [[ -z "$section_id" ]]; then
print_status "${CROSS}" "Section ID required for tree command" "${RED}" print_status "${CROSS}" "Section ID required for tree command" "${RED}"

View File

@@ -54,12 +54,12 @@
set -e set -e
# Color codes for output # Color codes for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
CYAN='\033[0;36m' CYAN=$'\033[0;36m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
# Test configuration # Test configuration
TEST_DIR="/tmp/plex-backup-test-$(date +%s)" TEST_DIR="/tmp/plex-backup-test-$(date +%s)"

0
plex/tui/__init__.py Normal file
View File

685
plex/tui/app.py Normal file
View 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
View 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
View 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;
}

View File

@@ -50,11 +50,11 @@
set -e set -e
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
NC='\033[0m' NC=$'\033[0m'
# Configuration # Configuration
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"

View File

@@ -53,11 +53,11 @@
# Comprehensive check to ensure Plex is fully recovered and functional # Comprehensive check to ensure Plex is fully recovered and functional
# Colors for output # Colors for output
RED='\033[0;31m' RED=$'\033[0;31m'
GREEN='\033[0;32m' GREEN=$'\033[0;32m'
YELLOW='\033[1;33m' YELLOW=$'\033[1;33m'
BLUE='\033[0;34m' BLUE=$'\033[0;34m'
NC='\033[0m' # No Color NC=$'\033[0m' # No Color
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases" PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"