346 lines
11 KiB
Python
346 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Database Management Utility for encoderPro
|
|
Provides convenient commands for inspecting and managing the state database
|
|
"""
|
|
|
|
import argparse
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
|
|
class DatabaseManager:
|
|
"""Utility for managing the state database"""
|
|
|
|
def __init__(self, db_path: Path):
|
|
self.db_path = db_path
|
|
self.conn = None
|
|
|
|
if not db_path.exists():
|
|
print(f"ERROR: Database not found: {db_path}")
|
|
sys.exit(1)
|
|
|
|
self.conn = sqlite3.connect(str(db_path))
|
|
self.conn.row_factory = sqlite3.Row
|
|
|
|
def close(self):
|
|
if self.conn:
|
|
self.conn.close()
|
|
|
|
def stats(self):
|
|
"""Show database statistics"""
|
|
cursor = self.conn.cursor()
|
|
|
|
print("\n" + "="*70)
|
|
print("DATABASE STATISTICS")
|
|
print("="*70)
|
|
|
|
# Count by state
|
|
cursor.execute("""
|
|
SELECT state, COUNT(*) as count
|
|
FROM files
|
|
GROUP BY state
|
|
ORDER BY state
|
|
""")
|
|
|
|
total = 0
|
|
for row in cursor.fetchall():
|
|
state = row['state'].upper()
|
|
count = row['count']
|
|
total += count
|
|
print(f"{state:15} {count:6}")
|
|
|
|
print("-"*70)
|
|
print(f"{'TOTAL':15} {total:6}")
|
|
|
|
# Space statistics
|
|
cursor.execute("""
|
|
SELECT
|
|
SUM(original_size) as orig,
|
|
SUM(encoded_size) as enc
|
|
FROM files
|
|
WHERE state = 'completed'
|
|
""")
|
|
row = cursor.fetchone()
|
|
|
|
if row and row['orig']:
|
|
orig = row['orig']
|
|
enc = row['enc'] or 0
|
|
saved = orig - enc
|
|
|
|
print("\n" + "="*70)
|
|
print("SPACE STATISTICS (Completed Files)")
|
|
print("="*70)
|
|
print(f"Original size: {self._human_size(orig)}")
|
|
print(f"Encoded size: {self._human_size(enc)}")
|
|
print(f"Space saved: {self._human_size(saved)} ({saved/orig*100:.1f}%)")
|
|
|
|
print("="*70 + "\n")
|
|
|
|
def list_files(self, state: str = None, limit: int = None):
|
|
"""List files, optionally filtered by state"""
|
|
cursor = self.conn.cursor()
|
|
|
|
query = "SELECT * FROM files"
|
|
params = []
|
|
|
|
if state:
|
|
query += " WHERE state = ?"
|
|
params.append(state)
|
|
|
|
query += " ORDER BY updated_at DESC"
|
|
|
|
if limit:
|
|
query += " LIMIT ?"
|
|
params.append(limit)
|
|
|
|
cursor.execute(query, params)
|
|
rows = cursor.fetchall()
|
|
|
|
if not rows:
|
|
print("No files found")
|
|
return
|
|
|
|
print(f"\nFound {len(rows)} file(s):\n")
|
|
print(f"{'State':<12} {'Size':<12} {'Path'}")
|
|
print("-" * 80)
|
|
|
|
for row in rows:
|
|
state = row['state']
|
|
size = self._human_size(row['original_size']) if row['original_size'] else 'N/A'
|
|
path = row['relative_path']
|
|
|
|
# Truncate long paths
|
|
if len(path) > 50:
|
|
path = path[:47] + "..."
|
|
|
|
print(f"{state:<12} {size:<12} {path}")
|
|
|
|
def failed_files(self):
|
|
"""Show failed files with error messages"""
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("""
|
|
SELECT relative_path, error_message, updated_at
|
|
FROM files
|
|
WHERE state = 'failed'
|
|
ORDER BY updated_at DESC
|
|
""")
|
|
|
|
rows = cursor.fetchall()
|
|
|
|
if not rows:
|
|
print("No failed files")
|
|
return
|
|
|
|
print(f"\n{len(rows)} FAILED FILE(S):\n")
|
|
print("="*80)
|
|
|
|
for row in rows:
|
|
print(f"File: {row['relative_path']}")
|
|
print(f"Error: {row['error_message']}")
|
|
print(f"Date: {row['updated_at']}")
|
|
print("-"*80)
|
|
|
|
def reset_state(self, from_state: str, to_state: str = 'pending'):
|
|
"""Reset files from one state to another"""
|
|
cursor = self.conn.cursor()
|
|
|
|
# Count affected
|
|
cursor.execute("SELECT COUNT(*) FROM files WHERE state = ?", (from_state,))
|
|
count = cursor.fetchone()[0]
|
|
|
|
if count == 0:
|
|
print(f"No files in state '{from_state}'")
|
|
return
|
|
|
|
print(f"Found {count} file(s) in state '{from_state}'")
|
|
response = input(f"Reset to '{to_state}'? (yes/no): ")
|
|
|
|
if response.lower() != 'yes':
|
|
print("Cancelled")
|
|
return
|
|
|
|
cursor.execute("""
|
|
UPDATE files
|
|
SET state = ?, error_message = NULL, updated_at = CURRENT_TIMESTAMP
|
|
WHERE state = ?
|
|
""", (to_state, from_state))
|
|
|
|
self.conn.commit()
|
|
print(f"✓ Reset {cursor.rowcount} file(s) to '{to_state}'")
|
|
|
|
def reset_file(self, filepath: str, to_state: str = 'pending'):
|
|
"""Reset a specific file to a given state"""
|
|
cursor = self.conn.cursor()
|
|
|
|
# Check if exists
|
|
cursor.execute("SELECT * FROM files WHERE filepath = ? OR relative_path = ?",
|
|
(filepath, filepath))
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
print(f"File not found: {filepath}")
|
|
return
|
|
|
|
print(f"File: {row['relative_path']}")
|
|
print(f"Current state: {row['state']}")
|
|
response = input(f"Reset to '{to_state}'? (yes/no): ")
|
|
|
|
if response.lower() != 'yes':
|
|
print("Cancelled")
|
|
return
|
|
|
|
cursor.execute("""
|
|
UPDATE files
|
|
SET state = ?, error_message = NULL, updated_at = CURRENT_TIMESTAMP
|
|
WHERE filepath = ? OR relative_path = ?
|
|
""", (to_state, filepath, filepath))
|
|
|
|
self.conn.commit()
|
|
print(f"✓ Reset to '{to_state}'")
|
|
|
|
def search(self, pattern: str):
|
|
"""Search for files by path pattern"""
|
|
cursor = self.conn.cursor()
|
|
cursor.execute("""
|
|
SELECT state, relative_path, original_size, has_subtitles
|
|
FROM files
|
|
WHERE relative_path LIKE ?
|
|
ORDER BY relative_path
|
|
""", (f'%{pattern}%',))
|
|
|
|
rows = cursor.fetchall()
|
|
|
|
if not rows:
|
|
print(f"No files matching '{pattern}'")
|
|
return
|
|
|
|
print(f"\nFound {len(rows)} file(s) matching '{pattern}':\n")
|
|
print(f"{'State':<12} {'Subs':<6} {'Size':<12} {'Path'}")
|
|
print("-" * 80)
|
|
|
|
for row in rows:
|
|
state = row['state']
|
|
subs = 'Yes' if row['has_subtitles'] else 'No'
|
|
size = self._human_size(row['original_size']) if row['original_size'] else 'N/A'
|
|
path = row['relative_path']
|
|
|
|
if len(path) > 45:
|
|
path = path[:42] + "..."
|
|
|
|
print(f"{state:<12} {subs:<6} {size:<12} {path}")
|
|
|
|
def vacuum(self):
|
|
"""Vacuum (optimize) the database"""
|
|
print("Vacuuming database...")
|
|
self.conn.execute("VACUUM")
|
|
print("✓ Database optimized")
|
|
|
|
def _human_size(self, size: int) -> str:
|
|
"""Convert bytes to human readable format"""
|
|
if not size:
|
|
return "0 B"
|
|
|
|
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
|
if size < 1024.0:
|
|
return f"{size:.2f} {unit}"
|
|
size /= 1024.0
|
|
return f"{size:.2f} PB"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Database Management Utility for encoderPro',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s stats # Show statistics
|
|
%(prog)s list -s completed -n 10 # List 10 most recent completed files
|
|
%(prog)s failed # Show failed files with errors
|
|
%(prog)s reset -f failed -t pending # Reset all failed files to pending
|
|
%(prog)s reset-file movie.mkv # Reset specific file to pending
|
|
%(prog)s search "Avatar" # Search for files with "Avatar" in path
|
|
%(prog)s vacuum # Optimize database
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
'-d', '--database',
|
|
type=Path,
|
|
default=Path('/var/lib/encoderpro/state.db'),
|
|
help='Path to database file (default: /var/lib/encoderpro/state.db)'
|
|
)
|
|
|
|
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
|
|
|
# Stats command
|
|
subparsers.add_parser('stats', help='Show database statistics')
|
|
|
|
# List command
|
|
list_parser = subparsers.add_parser('list', help='List files')
|
|
list_parser.add_argument('-s', '--state', help='Filter by state')
|
|
list_parser.add_argument('-n', '--limit', type=int, help='Limit number of results')
|
|
|
|
# Failed command
|
|
subparsers.add_parser('failed', help='Show failed files with errors')
|
|
|
|
# Reset command
|
|
reset_parser = subparsers.add_parser('reset', help='Reset files from one state to another')
|
|
reset_parser.add_argument('-f', '--from-state', required=True, help='Source state')
|
|
reset_parser.add_argument('-t', '--to-state', default='pending', help='Target state (default: pending)')
|
|
|
|
# Reset file command
|
|
reset_file_parser = subparsers.add_parser('reset-file', help='Reset a specific file')
|
|
reset_file_parser.add_argument('filepath', help='File path (absolute or relative)')
|
|
reset_file_parser.add_argument('-t', '--to-state', default='pending', help='Target state (default: pending)')
|
|
|
|
# Search command
|
|
search_parser = subparsers.add_parser('search', help='Search for files by path pattern')
|
|
search_parser.add_argument('pattern', help='Search pattern (case-insensitive)')
|
|
|
|
# Vacuum command
|
|
subparsers.add_parser('vacuum', help='Optimize database')
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
return 1
|
|
|
|
# Create manager
|
|
db = DatabaseManager(args.database)
|
|
|
|
try:
|
|
# Execute command
|
|
if args.command == 'stats':
|
|
db.stats()
|
|
|
|
elif args.command == 'list':
|
|
db.list_files(args.state, args.limit)
|
|
|
|
elif args.command == 'failed':
|
|
db.failed_files()
|
|
|
|
elif args.command == 'reset':
|
|
db.reset_state(args.from_state, args.to_state)
|
|
|
|
elif args.command == 'reset-file':
|
|
db.reset_file(args.filepath, args.to_state)
|
|
|
|
elif args.command == 'search':
|
|
db.search(args.pattern)
|
|
|
|
elif args.command == 'vacuum':
|
|
db.vacuum()
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|