mirror of
https://github.com/acedanger/shell.git
synced 2026-03-24 21:21:49 -07:00
Compare commits
2 Commits
9196879b6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33d64041ff | ||
|
|
70ec810f58 |
3
plex/.gitignore
vendored
Normal file
3
plex/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -51,12 +51,12 @@
|
|||||||
# Critical operations use explicit error checking instead of automatic exit
|
# Critical operations use explicit error checking instead of automatic exit
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Performance tracking variables (removed unused variables)
|
# Performance tracking variables (removed unused variables)
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
# Color codes
|
# Color codes
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Plex database path
|
# Plex database path
|
||||||
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_PATH="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
set +e
|
set +e
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|||||||
@@ -55,12 +55,12 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Test configuration
|
# Test configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -49,13 +49,13 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
MAGENTA='\033[0;35m'
|
MAGENTA=$'\033[0;35m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -58,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
51
plex/plex.sh
51
plex/plex.sh
@@ -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"
|
||||||
@@ -784,6 +784,7 @@ show_help() {
|
|||||||
printf " ${RED}${BOLD}%-18s${RESET} %s %s\n" "nuclear" "[!!]" "Nuclear database recovery (last resort)"
|
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 " ${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 " ${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"
|
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}"
|
||||||
@@ -796,10 +797,35 @@ show_help() {
|
|||||||
printf " ${DIM}%-40s # %s${RESET}\n" "${SCRIPT_NAME} backups delete" "Interactive backup deletion"
|
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} 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} 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}"
|
||||||
|
|
||||||
@@ -966,6 +992,9 @@ main() {
|
|||||||
"install-dbrepair"|"update-dbrepair"|"dbrepair")
|
"install-dbrepair"|"update-dbrepair"|"dbrepair")
|
||||||
install_or_update_dbrepair
|
install_or_update_dbrepair
|
||||||
;;
|
;;
|
||||||
|
"tui"|"dashboard"|"ui")
|
||||||
|
launch_tui
|
||||||
|
;;
|
||||||
"backups"|"backup-list")
|
"backups"|"backup-list")
|
||||||
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
|
if [[ $# -ge 2 && "${2,,}" == "delete" ]]; then
|
||||||
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
|
if [[ $# -ge 4 && "${3}" == "--name" ]]; then
|
||||||
|
|||||||
@@ -44,16 +44,16 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -54,12 +54,12 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
CYAN='\033[0;36m'
|
CYAN=$'\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
# Test configuration
|
# Test configuration
|
||||||
TEST_DIR="/tmp/plex-backup-test-$(date +%s)"
|
TEST_DIR="/tmp/plex-backup-test-$(date +%s)"
|
||||||
|
|||||||
0
plex/tui/__init__.py
Normal file
0
plex/tui/__init__.py
Normal file
685
plex/tui/app.py
Normal file
685
plex/tui/app.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Plex Management TUI — a Textual-based terminal interface for Plex operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Coroutine
|
||||||
|
|
||||||
|
from textual import on, work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import (
|
||||||
|
Button,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
ListItem,
|
||||||
|
ListView,
|
||||||
|
Log,
|
||||||
|
Static,
|
||||||
|
)
|
||||||
|
|
||||||
|
from backend import CommandResult, run_command # noqa: local import
|
||||||
|
from backend import cache_sudo, is_sudo_failure # noqa: local import
|
||||||
|
|
||||||
|
# ── Path to stylesheets ─────────────────────────────────────────────────
|
||||||
|
CSS_PATH = Path(__file__).parent / "plex_tui.tcss"
|
||||||
|
|
||||||
|
|
||||||
|
class NavListView(ListView):
|
||||||
|
"""ListView that only changes selection on click/keyboard, not mouse hover."""
|
||||||
|
|
||||||
|
def on_mouse_move(self, event) -> None:
|
||||||
|
"""Swallow mouse-move so the cursor doesn't follow the pointer."""
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
# ── Navigation items ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
NAV_SECTIONS: list[tuple[str, str]] = [
|
||||||
|
("service", "⏻ Service Control"),
|
||||||
|
("libraries", "📚 Library Scanner"),
|
||||||
|
("backup", "💾 Backup & Validate"),
|
||||||
|
("monitor", "📊 Monitoring"),
|
||||||
|
("database", "🗄️ Database Management"),
|
||||||
|
("recovery", "🔧 Recovery"),
|
||||||
|
("queries", "🔍 Queries & Stats"),
|
||||||
|
("testing", "🧪 Testing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Confirmation dialog ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmDialog(ModalScreen[bool]):
|
||||||
|
"""A modal yes/no confirmation dialog."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="dialog-container"):
|
||||||
|
yield Label(self._title, id="dialog-title")
|
||||||
|
yield Static(self._body, id="dialog-body")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="dialog-cancel")
|
||||||
|
yield Button("Confirm", variant="error", id="dialog-confirm")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#dialog-confirm")
|
||||||
|
def _confirm(self) -> None:
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#dialog-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Input dialog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class InputDialog(ModalScreen[str | None]):
|
||||||
|
"""Modal dialog that asks for a text value."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str, placeholder: str = "") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
self._placeholder = placeholder
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="input-dialog-container"):
|
||||||
|
yield Label(self._title, id="input-dialog-title")
|
||||||
|
yield Static(self._body, id="input-dialog-body")
|
||||||
|
yield Input(placeholder=self._placeholder, id="input-value")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="input-cancel")
|
||||||
|
yield Button("OK", variant="primary", id="input-ok")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#input-ok")
|
||||||
|
def _ok(self) -> None:
|
||||||
|
value = self.query_one("#input-value", Input).value.strip()
|
||||||
|
self.dismiss(value if value else None)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#input-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def _submit(self) -> None:
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password dialog ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordDialog(ModalScreen[str | None]):
|
||||||
|
"""Modal dialog that asks for a password (masked input)."""
|
||||||
|
|
||||||
|
def __init__(self, title: str, body: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._title = title
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="password-dialog-container"):
|
||||||
|
yield Label(self._title, id="password-dialog-title")
|
||||||
|
yield Static(self._body, id="password-dialog-body")
|
||||||
|
yield Input(placeholder="Password", password=True, id="password-value")
|
||||||
|
with Horizontal(classes="dialog-buttons"):
|
||||||
|
yield Button("Cancel", variant="default", id="password-cancel")
|
||||||
|
yield Button("Authenticate", variant="primary", id="password-ok")
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#password-ok")
|
||||||
|
def _ok(self) -> None:
|
||||||
|
value = self.query_one("#password-value", Input).value
|
||||||
|
self.dismiss(value if value else None)
|
||||||
|
|
||||||
|
@on(Button.Pressed, "#password-cancel")
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.dismiss(None)
|
||||||
|
|
||||||
|
@on(Input.Submitted)
|
||||||
|
def _submit(self) -> None:
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Section panels ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _section_header(text: str) -> Static:
|
||||||
|
return Static(text, classes="section-header")
|
||||||
|
|
||||||
|
|
||||||
|
def _btn(label: str, btn_id: str, classes: str = "action-btn") -> Button:
|
||||||
|
return Button(label, id=btn_id, classes=classes)
|
||||||
|
|
||||||
|
|
||||||
|
class ServicePanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Plex Media Server — Service Control")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("▶ Start", "svc-start", "action-btn-success")
|
||||||
|
yield _btn("⏹ Stop", "svc-stop", "action-btn-danger")
|
||||||
|
yield _btn("🔄 Restart", "svc-restart", "action-btn-warning")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("ℹ️ Status", "svc-status")
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Library Scanner")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📋 List Libraries", "lib-list")
|
||||||
|
yield _btn("🔍 Scan All", "lib-scan-all")
|
||||||
|
yield _btn("🔍 Scan Section…", "lib-scan-id")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔄 Refresh All", "lib-refresh-all")
|
||||||
|
yield _btn("🔄 Refresh Section…", "lib-refresh-id")
|
||||||
|
yield _btn("⚡ Force Refresh All", "lib-force-refresh")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Analyze All", "lib-analyze-all")
|
||||||
|
yield _btn("📊 Analyze Section…", "lib-analyze-id")
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Backup & Validation")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("💾 Run Backup", "bak-run")
|
||||||
|
yield _btn("💾 Backup + Auto-Repair", "bak-run-repair")
|
||||||
|
yield _btn("🔍 Integrity Check", "bak-integrity")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📋 List Backups", "bak-list")
|
||||||
|
yield _btn("✅ Validate Latest", "bak-validate-latest")
|
||||||
|
yield _btn("📝 Validation Report", "bak-validate-report")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📦 Built-in Status", "bak-builtin")
|
||||||
|
yield _btn("📦 Built-in Detailed", "bak-builtin-detail")
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Backup Monitoring Dashboard")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Show Dashboard", "mon-dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
class DatabasePanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Database Management")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔍 Integrity Check", "db-check")
|
||||||
|
yield _btn("🔧 Gentle Repair", "db-repair-gentle", "action-btn-warning")
|
||||||
|
yield _btn("⚠️ Force Repair", "db-repair-force", "action-btn-danger")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🧹 Cleanup (dry-run)", "db-cleanup-dry")
|
||||||
|
yield _btn("🧹 Cleanup (apply)", "db-cleanup-apply", "action-btn-warning")
|
||||||
|
yield _btn("📥 Install/Update DBRepair", "db-install-dbrepair")
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Recovery Operations")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🔎 Verify Backup Only", "rec-verify")
|
||||||
|
yield _btn("🧪 Nuclear Dry-Run", "rec-nuclear-dry")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("☢️ Nuclear Recovery", "rec-nuclear-auto", "action-btn-danger")
|
||||||
|
yield Static(
|
||||||
|
" ⚠ Nuclear recovery is a last resort — it replaces your entire database from backup.",
|
||||||
|
classes="status-warning",
|
||||||
|
)
|
||||||
|
yield _section_header("Post-Recovery Validation")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("⚡ Quick Validate", "rec-validate-quick")
|
||||||
|
yield _btn("🔍 Detailed Validate", "rec-validate-detailed")
|
||||||
|
yield _btn("📈 Performance Validate", "rec-validate-perf")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Queries & Statistics")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🆕 Recent Additions (7d)", "qry-recent-7")
|
||||||
|
yield _btn("🆕 Recent Additions (30d)", "qry-recent-30")
|
||||||
|
yield _btn("🆕 Recent Additions…", "qry-recent-custom")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("📊 Library Stats", "qry-stats")
|
||||||
|
yield _btn("🔢 Media Counts", "qry-counts")
|
||||||
|
yield _btn("📋 List Libraries", "qry-libraries")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("💬 Custom SQL Query…", "qry-custom")
|
||||||
|
|
||||||
|
|
||||||
|
class TestingPanel(Vertical):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield _section_header("Testing & Diagnostics")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("⚡ Quick Smoke Tests", "test-quick")
|
||||||
|
yield _btn("🧪 Unit Tests", "test-unit")
|
||||||
|
yield _btn("🔗 Integration Tests", "test-integration")
|
||||||
|
with Horizontal(classes="button-row"):
|
||||||
|
yield _btn("🧹 Cleanup Test Artifacts", "test-cleanup")
|
||||||
|
|
||||||
|
|
||||||
|
# Map section key -> panel class
|
||||||
|
PANELS: dict[str, type] = {
|
||||||
|
"service": ServicePanel,
|
||||||
|
"libraries": LibraryPanel,
|
||||||
|
"backup": BackupPanel,
|
||||||
|
"monitor": MonitorPanel,
|
||||||
|
"database": DatabasePanel,
|
||||||
|
"recovery": RecoveryPanel,
|
||||||
|
"queries": QueryPanel,
|
||||||
|
"testing": TestingPanel,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main application ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class PlexTUI(App):
|
||||||
|
"""Plex Management TUI."""
|
||||||
|
|
||||||
|
TITLE = "Plex Management Console"
|
||||||
|
CSS_PATH = CSS_PATH
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
("d", "toggle_dark", "Toggle Dark"),
|
||||||
|
("c", "clear_log", "Clear Log"),
|
||||||
|
("s", "authenticate_sudo", "Sudo Auth"),
|
||||||
|
("1", "nav('service')", "Service"),
|
||||||
|
("2", "nav('libraries')", "Libraries"),
|
||||||
|
("3", "nav('backup')", "Backup"),
|
||||||
|
("4", "nav('monitor')", "Monitor"),
|
||||||
|
("5", "nav('database')", "Database"),
|
||||||
|
("6", "nav('recovery')", "Recovery"),
|
||||||
|
("7", "nav('queries')", "Queries"),
|
||||||
|
("8", "nav('testing')", "Testing"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._current_section = "service"
|
||||||
|
|
||||||
|
# ── Composition ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
with Horizontal():
|
||||||
|
with Vertical(id="sidebar"):
|
||||||
|
yield Static("Plex Manager", id="sidebar-title")
|
||||||
|
yield NavListView(
|
||||||
|
*[
|
||||||
|
ListItem(Label(label, classes="nav-label"), id=f"nav-{key}")
|
||||||
|
for key, label in NAV_SECTIONS
|
||||||
|
],
|
||||||
|
id="nav-list",
|
||||||
|
)
|
||||||
|
with Vertical(id="main-content"):
|
||||||
|
# A scrollable area where the active section panel lives
|
||||||
|
yield VerticalScroll(ServicePanel(), id="panel-area")
|
||||||
|
yield Log(id="output-log", highlight=True, auto_scroll=True)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self._select_nav(0)
|
||||||
|
# Disable any-event mouse tracking (mode 1003) to prevent hover effects.
|
||||||
|
# Basic click tracking (mode 1000) remains active.
|
||||||
|
self.set_timer(0.1, self._disable_mouse_move_tracking)
|
||||||
|
|
||||||
|
def _disable_mouse_move_tracking(self) -> None:
|
||||||
|
sys.stdout.write("\x1b[?1003l")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
# ── Navigation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _select_nav(self, index: int) -> None:
|
||||||
|
nav_list = self.query_one("#nav-list", ListView)
|
||||||
|
nav_list.index = index
|
||||||
|
|
||||||
|
@on(ListView.Selected, "#nav-list")
|
||||||
|
def _on_nav_selected(self, event: ListView.Selected) -> None:
|
||||||
|
item_id = event.item.id or ""
|
||||||
|
section_key = item_id.removeprefix("nav-")
|
||||||
|
if section_key in PANELS:
|
||||||
|
self._switch_section(section_key)
|
||||||
|
|
||||||
|
def _switch_section(self, key: str) -> None:
|
||||||
|
if key == self._current_section:
|
||||||
|
return
|
||||||
|
self._current_section = key
|
||||||
|
panel_area = self.query_one("#panel-area", VerticalScroll)
|
||||||
|
panel_area.remove_children()
|
||||||
|
panel_area.mount(PANELS[key]())
|
||||||
|
|
||||||
|
def action_nav(self, section: str) -> None:
|
||||||
|
keys = [k for k, _ in NAV_SECTIONS]
|
||||||
|
if section in keys:
|
||||||
|
self._select_nav(keys.index(section))
|
||||||
|
self._switch_section(section)
|
||||||
|
|
||||||
|
def action_clear_log(self) -> None:
|
||||||
|
self.query_one("#output-log", Log).clear()
|
||||||
|
|
||||||
|
# ── Sudo authentication ─────────────────────────────────────
|
||||||
|
|
||||||
|
def action_authenticate_sudo(self) -> None:
|
||||||
|
self._prompt_sudo()
|
||||||
|
|
||||||
|
def _prompt_sudo(self) -> None:
|
||||||
|
async def _cb(password: str | None) -> None:
|
||||||
|
if password is not None:
|
||||||
|
success = await cache_sudo(password)
|
||||||
|
if success:
|
||||||
|
self._log("🔓 Sudo credentials cached successfully.")
|
||||||
|
else:
|
||||||
|
self._log("✗ Sudo authentication failed. Wrong password?")
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
PasswordDialog(
|
||||||
|
"🔒 Sudo Authentication",
|
||||||
|
"Enter your password to cache sudo credentials:",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Logging helper ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _log(self, text: str) -> None:
|
||||||
|
log_widget = self.query_one("#output-log", Log)
|
||||||
|
log_widget.write_line(text)
|
||||||
|
|
||||||
|
def _log_result(self, label: str, result: CommandResult) -> None:
|
||||||
|
status = "✓" if result.ok else "✗"
|
||||||
|
self._log(f"[{status}] {label}")
|
||||||
|
if result.output:
|
||||||
|
for line in result.output.splitlines():
|
||||||
|
self._log(f" {line}")
|
||||||
|
self._log("")
|
||||||
|
|
||||||
|
# ── Async operation runner ────────────────────────────────────────
|
||||||
|
|
||||||
|
@work(thread=False)
|
||||||
|
async def _run_op(
|
||||||
|
self,
|
||||||
|
label: str,
|
||||||
|
coro: Coroutine[None, None, CommandResult],
|
||||||
|
) -> None:
|
||||||
|
self._log(f"⏳ {label}...")
|
||||||
|
result = await coro
|
||||||
|
if is_sudo_failure(result):
|
||||||
|
self._log(f"[✗] {label}")
|
||||||
|
self._log(" 🔒 This command requires sudo. Press 's' to authenticate, then try again.")
|
||||||
|
self._log("")
|
||||||
|
else:
|
||||||
|
self._log_result(label, result)
|
||||||
|
|
||||||
|
def _ask_section_then_run(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
op: Callable[[str], Coroutine[None, None, CommandResult]],
|
||||||
|
) -> None:
|
||||||
|
"""Prompt for a section ID, then run an async operation with it."""
|
||||||
|
|
||||||
|
async def _do(dialog_result: str | None) -> None:
|
||||||
|
if dialog_result is not None:
|
||||||
|
self._run_op(f"{title} (section {dialog_result})", op(dialog_result))
|
||||||
|
|
||||||
|
self.app.push_screen(
|
||||||
|
InputDialog(title, "Enter library section ID:", placeholder="e.g. 1"),
|
||||||
|
_do, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Button dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@on(Button.Pressed)
|
||||||
|
def _on_button(self, event: Button.Pressed) -> None:
|
||||||
|
from backend import ( # local import to keep top-level light
|
||||||
|
backup_builtin_detailed,
|
||||||
|
backup_builtin_status,
|
||||||
|
backup_integrity_check,
|
||||||
|
backup_list,
|
||||||
|
backup_run,
|
||||||
|
backup_validate,
|
||||||
|
backup_validate_report,
|
||||||
|
custom_query,
|
||||||
|
db_check,
|
||||||
|
db_cleanup,
|
||||||
|
db_install_dbrepair,
|
||||||
|
db_repair_force,
|
||||||
|
db_repair_gentle,
|
||||||
|
library_analyze,
|
||||||
|
library_list,
|
||||||
|
library_refresh,
|
||||||
|
library_scan,
|
||||||
|
library_stats,
|
||||||
|
list_libraries_query,
|
||||||
|
media_counts,
|
||||||
|
monitor_dashboard,
|
||||||
|
nuclear_recovery_auto,
|
||||||
|
nuclear_recovery_dry_run,
|
||||||
|
nuclear_recovery_verify,
|
||||||
|
plex_restart,
|
||||||
|
plex_start,
|
||||||
|
plex_status,
|
||||||
|
plex_stop,
|
||||||
|
recent_additions,
|
||||||
|
run_tests_cleanup,
|
||||||
|
run_tests_integration,
|
||||||
|
run_tests_quick,
|
||||||
|
run_tests_unit,
|
||||||
|
validate_recovery,
|
||||||
|
)
|
||||||
|
|
||||||
|
bid = event.button.id or ""
|
||||||
|
|
||||||
|
# ── Service ────────────────────────────────
|
||||||
|
if bid == "svc-start":
|
||||||
|
self._run_op("Start Plex", plex_start())
|
||||||
|
elif bid == "svc-stop":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Stop Plex?",
|
||||||
|
"This will stop the Plex Media Server service.",
|
||||||
|
"Stop Plex",
|
||||||
|
plex_stop(),
|
||||||
|
)
|
||||||
|
elif bid == "svc-restart":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Restart Plex?",
|
||||||
|
"This will restart the Plex Media Server service.",
|
||||||
|
"Restart Plex",
|
||||||
|
plex_restart(),
|
||||||
|
)
|
||||||
|
elif bid == "svc-status":
|
||||||
|
self._run_op("Plex Status", plex_status())
|
||||||
|
|
||||||
|
# ── Libraries ──────────────────────────────
|
||||||
|
elif bid == "lib-list":
|
||||||
|
self._run_op("List Libraries", library_list())
|
||||||
|
elif bid == "lib-scan-all":
|
||||||
|
self._run_op("Scan All Libraries", library_scan())
|
||||||
|
elif bid == "lib-scan-id":
|
||||||
|
self._ask_section_then_run("Scan Library", library_scan)
|
||||||
|
elif bid == "lib-refresh-all":
|
||||||
|
self._run_op("Refresh All Libraries", library_refresh())
|
||||||
|
elif bid == "lib-refresh-id":
|
||||||
|
self._ask_section_then_run("Refresh Library", library_refresh)
|
||||||
|
elif bid == "lib-force-refresh":
|
||||||
|
self._run_op("Force Refresh All", library_refresh(force=True))
|
||||||
|
elif bid == "lib-analyze-all":
|
||||||
|
self._run_op("Analyze All Libraries", library_analyze())
|
||||||
|
elif bid == "lib-analyze-id":
|
||||||
|
self._ask_section_then_run("Analyze Library", library_analyze)
|
||||||
|
|
||||||
|
# ── Backup ─────────────────────────────────
|
||||||
|
elif bid == "bak-run":
|
||||||
|
self._run_op("Run Backup", backup_run())
|
||||||
|
elif bid == "bak-run-repair":
|
||||||
|
self._run_op("Backup + Auto-Repair", backup_run(auto_repair=True))
|
||||||
|
elif bid == "bak-integrity":
|
||||||
|
self._run_op("Integrity Check", backup_integrity_check())
|
||||||
|
elif bid == "bak-list":
|
||||||
|
self._run_op("List Backups", backup_list())
|
||||||
|
elif bid == "bak-validate-latest":
|
||||||
|
self._run_op("Validate Latest Backup", backup_validate(latest_only=True))
|
||||||
|
elif bid == "bak-validate-report":
|
||||||
|
self._run_op("Full Validation Report", backup_validate_report())
|
||||||
|
elif bid == "bak-builtin":
|
||||||
|
self._run_op("Built-in Backup Status", backup_builtin_status())
|
||||||
|
elif bid == "bak-builtin-detail":
|
||||||
|
self._run_op("Built-in Backup (Detailed)", backup_builtin_detailed())
|
||||||
|
|
||||||
|
# ── Monitor ────────────────────────────────
|
||||||
|
elif bid == "mon-dashboard":
|
||||||
|
self._run_op("Monitoring Dashboard", monitor_dashboard())
|
||||||
|
|
||||||
|
# ── Database ───────────────────────────────
|
||||||
|
elif bid == "db-check":
|
||||||
|
self._run_op("Database Integrity Check", db_check())
|
||||||
|
elif bid == "db-repair-gentle":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Gentle DB Repair?",
|
||||||
|
"This will attempt a gentle repair of the Plex database.",
|
||||||
|
"Gentle DB Repair",
|
||||||
|
db_repair_gentle(),
|
||||||
|
)
|
||||||
|
elif bid == "db-repair-force":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Force DB Repair?",
|
||||||
|
"This will aggressively repair the Plex database. "
|
||||||
|
"The Plex service will be stopped during repair.",
|
||||||
|
"Force DB Repair",
|
||||||
|
db_repair_force(),
|
||||||
|
)
|
||||||
|
elif bid == "db-cleanup-dry":
|
||||||
|
self._run_op("DB Cleanup (dry-run)", db_cleanup(dry_run=True))
|
||||||
|
elif bid == "db-cleanup-apply":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"Apply DB Cleanup?",
|
||||||
|
"This will permanently remove temporary and recovery files "
|
||||||
|
"from the Plex database directory.",
|
||||||
|
"DB Cleanup",
|
||||||
|
db_cleanup(dry_run=False),
|
||||||
|
)
|
||||||
|
elif bid == "db-install-dbrepair":
|
||||||
|
self._run_op("Install/Update DBRepair", db_install_dbrepair())
|
||||||
|
|
||||||
|
# ── Recovery ───────────────────────────────
|
||||||
|
elif bid == "rec-verify":
|
||||||
|
self._run_op("Verify Backup Integrity", nuclear_recovery_verify())
|
||||||
|
elif bid == "rec-nuclear-dry":
|
||||||
|
self._run_op("Nuclear Recovery (dry-run)", nuclear_recovery_dry_run())
|
||||||
|
elif bid == "rec-nuclear-auto":
|
||||||
|
self._confirm_then_run(
|
||||||
|
"☢️ NUCLEAR RECOVERY",
|
||||||
|
"This will REPLACE YOUR ENTIRE DATABASE from the best available backup.\n\n"
|
||||||
|
"This is a LAST RESORT operation. Plex will be stopped during recovery.\n"
|
||||||
|
"Are you absolutely sure?",
|
||||||
|
"Nuclear Recovery",
|
||||||
|
nuclear_recovery_auto(),
|
||||||
|
)
|
||||||
|
elif bid == "rec-validate-quick":
|
||||||
|
self._run_op("Quick Recovery Validation", validate_recovery("--quick"))
|
||||||
|
elif bid == "rec-validate-detailed":
|
||||||
|
self._run_op(
|
||||||
|
"Detailed Recovery Validation", validate_recovery("--detailed")
|
||||||
|
)
|
||||||
|
elif bid == "rec-validate-perf":
|
||||||
|
self._run_op(
|
||||||
|
"Performance Recovery Validation", validate_recovery("--performance")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Queries ────────────────────────────────
|
||||||
|
elif bid == "qry-recent-7":
|
||||||
|
self._run_op("Recent Additions (7 days)", recent_additions(7))
|
||||||
|
elif bid == "qry-recent-30":
|
||||||
|
self._run_op("Recent Additions (30 days)", recent_additions(30))
|
||||||
|
elif bid == "qry-recent-custom":
|
||||||
|
self._ask_days_then_run()
|
||||||
|
elif bid == "qry-stats":
|
||||||
|
self._run_op("Library Stats", library_stats())
|
||||||
|
elif bid == "qry-counts":
|
||||||
|
self._run_op("Media Counts", media_counts())
|
||||||
|
elif bid == "qry-libraries":
|
||||||
|
self._run_op("List Libraries", list_libraries_query())
|
||||||
|
elif bid == "qry-custom":
|
||||||
|
self._ask_sql_then_run()
|
||||||
|
|
||||||
|
# ── Testing ────────────────────────────────
|
||||||
|
elif bid == "test-quick":
|
||||||
|
self._run_op("Quick Smoke Tests", run_tests_quick())
|
||||||
|
elif bid == "test-unit":
|
||||||
|
self._run_op("Unit Tests", run_tests_unit())
|
||||||
|
elif bid == "test-integration":
|
||||||
|
self._run_op("Integration Tests", run_tests_integration())
|
||||||
|
elif bid == "test-cleanup":
|
||||||
|
self._run_op("Cleanup Test Artifacts", run_tests_cleanup())
|
||||||
|
|
||||||
|
# ── Confirmation helper ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _confirm_then_run(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
label: str,
|
||||||
|
coro: Coroutine[None, None, CommandResult],
|
||||||
|
) -> None:
|
||||||
|
async def _callback(confirmed: bool) -> None:
|
||||||
|
if confirmed:
|
||||||
|
self._run_op(label, coro)
|
||||||
|
|
||||||
|
self.push_screen(ConfirmDialog(title, body), _callback) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# ── Input prompt helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ask_days_then_run(self) -> None:
|
||||||
|
from backend import recent_additions
|
||||||
|
|
||||||
|
async def _cb(val: str | None) -> None:
|
||||||
|
if val is not None and val.isdigit():
|
||||||
|
self._run_op(
|
||||||
|
f"Recent Additions ({val} days)", recent_additions(int(val))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
InputDialog(
|
||||||
|
"Recent Additions",
|
||||||
|
"Enter number of days:",
|
||||||
|
placeholder="e.g. 14",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ask_sql_then_run(self) -> None:
|
||||||
|
from backend import custom_query
|
||||||
|
|
||||||
|
async def _cb(val: str | None) -> None:
|
||||||
|
if val is not None:
|
||||||
|
self._run_op(f"Custom Query", custom_query(val))
|
||||||
|
|
||||||
|
self.push_screen(
|
||||||
|
InputDialog(
|
||||||
|
"Custom SQL Query",
|
||||||
|
"Enter a SQL query to run against the Plex database:",
|
||||||
|
placeholder="SELECT count(*) FROM metadata_items",
|
||||||
|
),
|
||||||
|
_cb, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Entry point ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = PlexTUI()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
345
plex/tui/backend.py
Normal file
345
plex/tui/backend.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""Plex Management TUI — backend helpers for running shell scripts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).resolve().parent.parent # /home/…/shell/plex
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandResult:
|
||||||
|
returncode: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
command: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.returncode == 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output(self) -> str:
|
||||||
|
text = self.stdout
|
||||||
|
if self.stderr:
|
||||||
|
text += "\n" + self.stderr
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _script(name: str) -> str:
|
||||||
|
"""Return absolute path to a script in the plex directory."""
|
||||||
|
return str(SCRIPT_DIR / name)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ansi stripping ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_ansi(text: str) -> str:
|
||||||
|
return _ANSI_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Async command runner ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_command(
|
||||||
|
cmd: str | list[str],
|
||||||
|
*,
|
||||||
|
sudo: bool = False,
|
||||||
|
timeout: int = 300,
|
||||||
|
) -> CommandResult:
|
||||||
|
"""Run a shell command asynchronously and return the result."""
|
||||||
|
if isinstance(cmd, list):
|
||||||
|
shell_cmd = " ".join(shlex.quote(c) for c in cmd)
|
||||||
|
else:
|
||||||
|
shell_cmd = cmd
|
||||||
|
|
||||||
|
if sudo:
|
||||||
|
shell_cmd = f"sudo {shell_cmd}"
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TERM"] = "dumb" # suppress colour in child scripts
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
shell_cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=timeout
|
||||||
|
)
|
||||||
|
return CommandResult(
|
||||||
|
returncode=proc.returncode or 0,
|
||||||
|
stdout=strip_ansi(stdout_bytes.decode(errors="replace")),
|
||||||
|
stderr=strip_ansi(stderr_bytes.decode(errors="replace")),
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return CommandResult(
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr=f"Command timed out after {timeout}s",
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return CommandResult(
|
||||||
|
returncode=-1,
|
||||||
|
stdout="",
|
||||||
|
stderr=str(exc),
|
||||||
|
command=shell_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sudo helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SUDO_FAIL_PATTERNS = [
|
||||||
|
"sudo: a password is required",
|
||||||
|
"sudo: a terminal is required",
|
||||||
|
"sudo: no tty present",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def is_sudo_failure(result: CommandResult) -> bool:
|
||||||
|
"""Return True if the command failed because of missing sudo credentials."""
|
||||||
|
if result.ok:
|
||||||
|
return False
|
||||||
|
text = (result.stdout + " " + result.stderr).lower()
|
||||||
|
return any(p in text for p in _SUDO_FAIL_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_sudo_cached() -> bool:
|
||||||
|
"""Check whether sudo credentials are currently cached (no password needed)."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sudo", "-n", "true",
|
||||||
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.wait()
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def cache_sudo(password: str) -> bool:
|
||||||
|
"""Cache sudo credentials by validating the given password."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"sudo", "-S", "-v",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
await proc.communicate(input=(password + "\n").encode())
|
||||||
|
return proc.returncode == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── High-level operations (each returns a CommandResult) ────────────────
|
||||||
|
|
||||||
|
# Service management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def plex_start() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} start")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_stop() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} stop")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_restart() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} restart")
|
||||||
|
|
||||||
|
|
||||||
|
async def plex_status() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} status")
|
||||||
|
|
||||||
|
|
||||||
|
# Library scanning ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def library_list() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('scan-plex-libraries.sh')} list")
|
||||||
|
|
||||||
|
|
||||||
|
async def library_scan(section_id: str = "") -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} scan"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_refresh(section_id: str = "", force: bool = False) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} refresh"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
else:
|
||||||
|
cmd += ' ""'
|
||||||
|
if force:
|
||||||
|
cmd += " true"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_analyze(section_id: str = "") -> CommandResult:
|
||||||
|
cmd = f"bash {_script('scan-plex-libraries.sh')} analyze"
|
||||||
|
if section_id:
|
||||||
|
cmd += f" {shlex.quote(section_id)}"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
# Backup ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def backup_run(auto_repair: bool = False) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('backup-plex.sh')} --non-interactive"
|
||||||
|
if auto_repair:
|
||||||
|
cmd += " --auto-repair"
|
||||||
|
return await run_command(cmd, timeout=600)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_integrity_check() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('backup-plex.sh')} --check-integrity --non-interactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_validate(latest_only: bool = True) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('validate-plex-backups.sh')}"
|
||||||
|
if latest_only:
|
||||||
|
cmd += " --latest"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_validate_report() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('validate-plex-backups.sh')} --report")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_list() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} backups")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_builtin_status() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('check-plex-builtin-backups.sh')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def backup_builtin_detailed() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('check-plex-builtin-backups.sh')} --detailed")
|
||||||
|
|
||||||
|
|
||||||
|
# Monitoring ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def monitor_dashboard() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('monitor-plex-backup.sh')}")
|
||||||
|
|
||||||
|
|
||||||
|
# Database management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def db_check() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} check")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_repair_gentle() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} repair --gentle")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_repair_force() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-db-manager.sh')} repair --force")
|
||||||
|
|
||||||
|
|
||||||
|
async def db_cleanup(dry_run: bool = True) -> CommandResult:
|
||||||
|
cmd = f"bash {_script('cleanup-plex-databases.sh')}"
|
||||||
|
if dry_run:
|
||||||
|
cmd += " --dry-run"
|
||||||
|
cmd += " --verbose"
|
||||||
|
return await run_command(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
async def db_install_dbrepair() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex.sh')} install-dbrepair")
|
||||||
|
|
||||||
|
|
||||||
|
# Recovery ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def nuclear_recovery_dry_run() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --dry-run"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def nuclear_recovery_auto() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --auto", timeout=600
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def nuclear_recovery_verify() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('nuclear-plex-recovery.sh')} --verify-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_recovery(mode: str = "--quick") -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('validate-plex-recovery.sh')} {mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Queries ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def recent_additions(days: int = 7) -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('plex-recent-additions.sh')} recent {days}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def library_stats() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} stats")
|
||||||
|
|
||||||
|
|
||||||
|
async def media_counts() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} count")
|
||||||
|
|
||||||
|
|
||||||
|
async def list_libraries_query() -> CommandResult:
|
||||||
|
return await run_command(f"bash {_script('plex-recent-additions.sh')} libraries")
|
||||||
|
|
||||||
|
|
||||||
|
async def custom_query(sql: str) -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('plex-recent-additions.sh')} custom {shlex.quote(sql)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Testing ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def run_tests_quick() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --quick", timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_unit() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --unit", timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_integration() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('integration-test-plex.sh')} --quick", timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tests_cleanup() -> CommandResult:
|
||||||
|
return await run_command(
|
||||||
|
f"bash {_script('test-plex-backup.sh')} --cleanup"
|
||||||
|
)
|
||||||
279
plex/tui/plex_tui.tcss
Normal file
279
plex/tui/plex_tui.tcss
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/* Plex TUI Theme - Orange/Dark inspired by Plex branding */
|
||||||
|
|
||||||
|
Screen {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
Header {
|
||||||
|
background: #282828;
|
||||||
|
color: #e5a00d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
background: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar navigation */
|
||||||
|
#sidebar {
|
||||||
|
width: 32;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-right: solid #e5a00d;
|
||||||
|
padding: 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-title {
|
||||||
|
text-align: center;
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
padding: 0 1;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem {
|
||||||
|
padding: 0 1;
|
||||||
|
height: 3;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nav-list > ListItem.-active {
|
||||||
|
background: #e5a00d 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
padding: 1 2;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
#main-content {
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.section-header {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status panel */
|
||||||
|
#status-panel {
|
||||||
|
height: auto;
|
||||||
|
max-height: 12;
|
||||||
|
border: solid #444;
|
||||||
|
margin-bottom: 1;
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-btn {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning:hover {
|
||||||
|
background: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-success {
|
||||||
|
margin: 0 1 1 0;
|
||||||
|
min-width: 24;
|
||||||
|
background: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-success:hover {
|
||||||
|
background: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress default button hover tint */
|
||||||
|
Button:hover {
|
||||||
|
opacity: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button rows */
|
||||||
|
.button-row {
|
||||||
|
layout: horizontal;
|
||||||
|
height: auto;
|
||||||
|
padding: 0 0 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output log */
|
||||||
|
#output-log {
|
||||||
|
border: solid #444;
|
||||||
|
height: 1fr;
|
||||||
|
margin-top: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DataTable */
|
||||||
|
DataTable {
|
||||||
|
height: auto;
|
||||||
|
max-height: 20;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirmation dialog */
|
||||||
|
ConfirmDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog > #dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick $error;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog #dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: $error;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog #dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfirmDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input dialog */
|
||||||
|
InputDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog > #input-dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick #e5a00d;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog #input-dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog #input-dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password dialog */
|
||||||
|
PasswordDialog {
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog > #password-dialog-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
border: thick #e5a00d;
|
||||||
|
background: $surface;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog #password-dialog-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog #password-dialog-body {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog .dialog-buttons {
|
||||||
|
layout: horizontal;
|
||||||
|
align-horizontal: center;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordDialog .dialog-buttons Button {
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
TabbedContent {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
TabPane {
|
||||||
|
padding: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info cards */
|
||||||
|
.info-card {
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: solid #444;
|
||||||
|
padding: 1;
|
||||||
|
margin-bottom: 1;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #e5a00d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.status-ok {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: $warning;
|
||||||
|
}
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m'
|
NC=$'\033[0m'
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
# Comprehensive check to ensure Plex is fully recovered and functional
|
# Comprehensive check to ensure Plex is fully recovered and functional
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED=$'\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN=$'\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW=$'\033[1;33m'
|
||||||
BLUE='\033[0;34m'
|
BLUE=$'\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC=$'\033[0m' # No Color
|
||||||
|
|
||||||
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
PLEX_DB_DIR="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user