dev #64
+184
-14
@@ -804,7 +804,10 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
if path and scan_path.name.lower().startswith('season'):
|
||||
# Single season processing
|
||||
series_path = scan_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
tv_processor_obj = dependencies.get("tv_processor")
|
||||
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event):
|
||||
print(f"INFO: Processing single season: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_season(series_path, scan_path)
|
||||
@@ -814,15 +817,21 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
# Single episode processing
|
||||
season_path = scan_path.parent
|
||||
series_path = season_path.parent
|
||||
if nfo_manager.parse_imdb_from_path(series_path):
|
||||
tv_processor_obj = dependencies.get("tv_processor")
|
||||
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(series_path, sonarr_client, shutdown_event):
|
||||
print(f"INFO: Processing single episode: {scan_path}")
|
||||
try:
|
||||
tv_processor.process_episode_file(series_path, season_path, scan_path)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed processing episode {scan_path}: {e}")
|
||||
else:
|
||||
# Check if this path itself is a series (has IMDb ID in the directory name)
|
||||
if nfo_manager.parse_imdb_from_path(scan_path):
|
||||
# Check if this path itself is a series (has IMDb ID in directory name or NFO files)
|
||||
tv_processor_obj = dependencies.get("tv_processor")
|
||||
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if nfo_manager.parse_imdb_from_path_with_nfo_fallback(scan_path, sonarr_client, shutdown_event):
|
||||
try:
|
||||
# Determine force_scan based on scan mode
|
||||
force_scan = (scan_mode == "full")
|
||||
@@ -842,11 +851,36 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
# Count total series first for progress tracking
|
||||
tv_series_list = []
|
||||
for item in scan_path.iterdir():
|
||||
# Check for shutdown signal during series discovery
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping series discovery")
|
||||
return
|
||||
|
||||
if (item.is_dir() and
|
||||
not item.name.lower().startswith('season') and
|
||||
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE) and
|
||||
nfo_manager.parse_imdb_from_path(item)):
|
||||
not re.match(r'^season\s+\d+$', item.name, re.IGNORECASE)):
|
||||
|
||||
# Check for IMDb ID (enhanced with NFO fallback)
|
||||
tv_processor_obj = dependencies.get("tv_processor")
|
||||
sonarr_client = tv_processor_obj.sonarr if tv_processor_obj and hasattr(tv_processor_obj, 'sonarr') else None
|
||||
imdb_id = nfo_manager.parse_imdb_from_path_with_nfo_fallback(item, sonarr_client, shutdown_event)
|
||||
if imdb_id:
|
||||
tv_series_list.append(item)
|
||||
else:
|
||||
# Log missing IMDb ID for TV series
|
||||
try:
|
||||
db = dependencies.get("db")
|
||||
db.add_missing_imdb(
|
||||
file_path=str(item),
|
||||
media_type="tv",
|
||||
folder_name=item.name,
|
||||
filename=None,
|
||||
notes=f"TV series directory without IMDb ID detected during scan"
|
||||
)
|
||||
print(f"⚠️ Missing IMDb ID: TV series {item.name} - logged for manual review")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to log missing IMDb for TV series {item.name}: {e}")
|
||||
|
||||
tv_series_count = len(tv_series_list)
|
||||
update_scan_status("tv", tv_series_total=tv_series_count)
|
||||
@@ -894,8 +928,31 @@ async def manual_scan(background_tasks: BackgroundTasks, path: Optional[str] = N
|
||||
# Count total movies first for progress tracking
|
||||
movie_list = []
|
||||
for item in scan_path.iterdir():
|
||||
if item.is_dir() and nfo_manager.find_movie_imdb_id(item):
|
||||
# Check for shutdown signal during movie discovery
|
||||
shutdown_event = dependencies.get("shutdown_event")
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
print("INFO: ⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie discovery")
|
||||
return
|
||||
|
||||
if item.is_dir():
|
||||
# Check for IMDb ID
|
||||
imdb_id = nfo_manager.find_movie_imdb_id(item)
|
||||
if imdb_id:
|
||||
movie_list.append(item)
|
||||
else:
|
||||
# Log missing IMDb ID for movie
|
||||
try:
|
||||
db = dependencies.get("db")
|
||||
db.add_missing_imdb(
|
||||
file_path=str(item),
|
||||
media_type="movie",
|
||||
folder_name=item.name,
|
||||
filename=None,
|
||||
notes=f"Movie directory without IMDb ID detected during scan"
|
||||
)
|
||||
print(f"⚠️ Missing IMDb ID: Movie {item.name} - logged for manual review")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to log missing IMDb for movie {item.name}: {e}")
|
||||
|
||||
movie_total_count = len(movie_list)
|
||||
update_scan_status(movies_total=movie_total_count)
|
||||
@@ -2170,6 +2227,11 @@ def register_routes(app, dependencies: dict):
|
||||
async def _health() -> HealthResponse:
|
||||
return await health(dependencies)
|
||||
|
||||
@app.get("/health/simple")
|
||||
async def _health_simple():
|
||||
"""Simple health check for Docker without external dependencies"""
|
||||
return {"status": "healthy", "service": "nfoguard-core"}
|
||||
|
||||
@app.get("/stats")
|
||||
async def _get_stats():
|
||||
return await get_stats(dependencies)
|
||||
@@ -2386,7 +2448,7 @@ def register_routes(app, dependencies: dict):
|
||||
print(f"📊 Found {len(missing_tv_nfo_files)} TV NFO files missing dateadded elements")
|
||||
|
||||
# Convert TV file paths to episode information with direct NFO parsing
|
||||
def extract_imdb_from_nfo_content(nfo_file, is_episode=True):
|
||||
def extract_imdb_from_nfo_content(nfo_file, is_episode=True, verbose_logging=True):
|
||||
"""Extract IMDb ID directly from NFO file content - we already know the file exists"""
|
||||
imdb_id = "unknown"
|
||||
|
||||
@@ -2400,6 +2462,7 @@ def register_routes(app, dependencies: dict):
|
||||
if imdb_elem is not None and imdb_elem.text:
|
||||
imdb_text = imdb_elem.text.strip()
|
||||
if imdb_text.startswith('tt'):
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {imdb_text} in <imdb> tag: {os.path.basename(nfo_file)}")
|
||||
return imdb_text
|
||||
|
||||
@@ -2408,6 +2471,7 @@ def register_routes(app, dependencies: dict):
|
||||
if uniqueid.get("type") == "imdb" and uniqueid.text:
|
||||
imdb_text = uniqueid.text.strip()
|
||||
if imdb_text.startswith('tt'):
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {imdb_text} in <uniqueid type='imdb'>: {os.path.basename(nfo_file)}")
|
||||
return imdb_text
|
||||
|
||||
@@ -2416,11 +2480,13 @@ def register_routes(app, dependencies: dict):
|
||||
content_match = re.search(r'(tt\d{7,})', nfo_content)
|
||||
if content_match:
|
||||
imdb_text = content_match.group(1)
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {imdb_text} in NFO content: {os.path.basename(nfo_file)}")
|
||||
return imdb_text
|
||||
|
||||
except ET.ParseError as e:
|
||||
# Handle malformed XML by trying text-based search
|
||||
if verbose_logging:
|
||||
print(f"⚠️ NFO XML parse error in {os.path.basename(nfo_file)}: {e}")
|
||||
try:
|
||||
# Fallback: Read as text and search for IMDb patterns
|
||||
@@ -2431,12 +2497,15 @@ def register_routes(app, dependencies: dict):
|
||||
text_match = re.search(r'(tt\d{7,})', nfo_text)
|
||||
if text_match:
|
||||
imdb_text = text_match.group(1)
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {imdb_text} in NFO text content: {os.path.basename(nfo_file)}")
|
||||
return imdb_text
|
||||
except Exception as text_error:
|
||||
if verbose_logging:
|
||||
print(f"⚠️ Error reading NFO as text {nfo_file}: {text_error}")
|
||||
|
||||
except Exception as e:
|
||||
if verbose_logging:
|
||||
print(f"⚠️ Unexpected error parsing NFO {nfo_file}: {e}")
|
||||
|
||||
# Method 4: Fallback - check folder structure
|
||||
@@ -2445,6 +2514,7 @@ def register_routes(app, dependencies: dict):
|
||||
folder_name = os.path.basename(series_folder)
|
||||
folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE)
|
||||
if folder_match:
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}")
|
||||
return folder_match.group(1)
|
||||
else:
|
||||
@@ -2453,9 +2523,11 @@ def register_routes(app, dependencies: dict):
|
||||
folder_name = os.path.basename(movie_folder)
|
||||
folder_match = re.search(r'\[imdb-(tt\d+)\]', folder_name, re.IGNORECASE)
|
||||
if folder_match:
|
||||
if verbose_logging:
|
||||
print(f"✅ Found IMDb {folder_match.group(1)} in folder: {folder_name}")
|
||||
return folder_match.group(1)
|
||||
|
||||
if verbose_logging:
|
||||
print(f"❌ No IMDb ID found in {os.path.basename(nfo_file)}")
|
||||
return "unknown"
|
||||
|
||||
@@ -2560,7 +2632,9 @@ def register_routes(app, dependencies: dict):
|
||||
# Process missing movie NFO files
|
||||
print(f"📊 Found {len(missing_movie_nfo_files)} Movie NFO files missing dateadded elements")
|
||||
|
||||
for nfo_file in missing_movie_nfo_files:
|
||||
print(f"🎬 Processing {len(missing_movie_nfo_files)} movie NFO files for detailed information...")
|
||||
for i, nfo_file in enumerate(missing_movie_nfo_files):
|
||||
print(f" 📁 Movie {i+1}/{len(missing_movie_nfo_files)}: {nfo_file}")
|
||||
try:
|
||||
# Extract movie info from file path
|
||||
# Example: /media/Movies/movies/Knives Out (2019) [imdb-tt8946378]/movie.nfo
|
||||
@@ -2568,11 +2642,19 @@ def register_routes(app, dependencies: dict):
|
||||
movie_folder_name = os.path.basename(movie_dir)
|
||||
|
||||
# Extract IMDb ID from NFO content
|
||||
imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=False)
|
||||
imdb_id = extract_imdb_from_nfo_content(nfo_file, is_episode=False, verbose_logging=False)
|
||||
|
||||
# Clean movie title (remove year and imdb parts)
|
||||
clean_movie_title = re.sub(r'\s*\(\d{4}\)\s*\[imdb-[^]]+\]', '', movie_folder_name).strip()
|
||||
|
||||
# Log detailed movie information
|
||||
if imdb_id != "unknown":
|
||||
print(f"✅ Found IMDb {imdb_id} for movie: {clean_movie_title} ({movie_folder_name})")
|
||||
else:
|
||||
print(f"❌ No IMDb ID found for movie: {clean_movie_title} ({movie_folder_name})")
|
||||
print(f" 📁 Path: {movie_dir}")
|
||||
print(f" 📄 NFO: {os.path.basename(nfo_file)}")
|
||||
|
||||
missing_items["movies"].append({
|
||||
"imdb_id": imdb_id,
|
||||
"title": clean_movie_title,
|
||||
@@ -2693,17 +2775,43 @@ def register_routes(app, dependencies: dict):
|
||||
print(f" Episodes: {episodes_with_dates}/{total_episodes} found in DB, {episodes_missing_db} missing from DB")
|
||||
print(f" Movies: {movies_with_dates}/{total_movies} found in DB, {movies_missing_db} missing from DB")
|
||||
|
||||
total_missing = len(missing_items["episodes"]) + len(missing_items["movies"])
|
||||
print(f"✅ NFO repair scan complete: {total_missing} items missing dateadded elements")
|
||||
# Calculate comprehensive statistics
|
||||
total_nfo_files_missing = len(missing_tv_nfo_files) + len(missing_movie_nfo_files)
|
||||
total_with_imdb_and_db = len(missing_items["episodes"]) + len(missing_items["movies"])
|
||||
|
||||
print(f"✅ NFO repair scan complete:")
|
||||
print(f" 📄 {total_nfo_files_missing} NFO files missing dateadded elements")
|
||||
print(f" 🎬 {len(missing_tv_nfo_files)} TV episodes, {len(missing_movie_nfo_files)} movies")
|
||||
print(f" 🔍 {total_with_imdb_and_db} items with IMDb IDs found in database (can be fixed)")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"total_missing": total_missing,
|
||||
"total_missing": total_with_imdb_and_db, # Items that can be fixed
|
||||
"total_nfo_files_missing": total_nfo_files_missing, # All NFO files missing dateadded
|
||||
"episodes_missing": len(missing_items["episodes"]),
|
||||
"movies_missing": len(missing_items["movies"]),
|
||||
"tv_nfo_files_missing": len(missing_tv_nfo_files),
|
||||
"movie_nfo_files_missing": len(missing_movie_nfo_files),
|
||||
"total_tv_files_checked": total_tv_files_checked,
|
||||
"total_movie_files_checked": total_movie_files_checked,
|
||||
"missing_items": missing_items
|
||||
"missing_items": missing_items,
|
||||
"statistics": {
|
||||
"nfo_files_scanned": {
|
||||
"tv": total_tv_files_checked,
|
||||
"movies": total_movie_files_checked,
|
||||
"total": total_tv_files_checked + total_movie_files_checked
|
||||
},
|
||||
"nfo_files_missing_dateadded": {
|
||||
"tv": len(missing_tv_nfo_files),
|
||||
"movies": len(missing_movie_nfo_files),
|
||||
"total": total_nfo_files_missing
|
||||
},
|
||||
"items_with_imdb_and_database": {
|
||||
"tv": len(missing_items["episodes"]),
|
||||
"movies": len(missing_items["movies"]),
|
||||
"total": total_with_imdb_and_db
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -2967,6 +3075,68 @@ def register_routes(app, dependencies: dict):
|
||||
async def _nfo_repair_fix():
|
||||
return await nfo_repair_fix()
|
||||
|
||||
@app.get("/admin/missing-imdb")
|
||||
async def get_missing_imdb():
|
||||
"""Get items missing IMDb IDs for manual review"""
|
||||
config = dependencies.get("config")
|
||||
db = dependencies.get("db")
|
||||
|
||||
if not config or not db:
|
||||
raise HTTPException(status_code=500, detail="Dependencies not available")
|
||||
|
||||
print("📋 Retrieving missing IMDb items from core container")
|
||||
|
||||
try:
|
||||
# Get missing items by type
|
||||
missing_tv = db.get_missing_imdb_items(media_type="tv", resolved=False)
|
||||
missing_movies = db.get_missing_imdb_items(media_type="movie", resolved=False)
|
||||
|
||||
# Format for web interface
|
||||
missing_items = []
|
||||
|
||||
for item in missing_tv:
|
||||
missing_items.append({
|
||||
"id": item["id"],
|
||||
"type": "TV Series",
|
||||
"folder_name": item["folder_name"],
|
||||
"file_path": item["file_path"],
|
||||
"discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None,
|
||||
"last_checked": item["last_checked"].isoformat() if item["last_checked"] else None,
|
||||
"check_count": item["check_count"],
|
||||
"notes": item["notes"]
|
||||
})
|
||||
|
||||
for item in missing_movies:
|
||||
missing_items.append({
|
||||
"id": item["id"],
|
||||
"type": "Movie",
|
||||
"folder_name": item["folder_name"],
|
||||
"file_path": item["file_path"],
|
||||
"discovered_at": item["discovered_at"].isoformat() if item["discovered_at"] else None,
|
||||
"last_checked": item["last_checked"].isoformat() if item["last_checked"] else None,
|
||||
"check_count": item["check_count"],
|
||||
"notes": item["notes"]
|
||||
})
|
||||
|
||||
print(f"✅ Found {len(missing_tv)} TV series and {len(missing_movies)} movies missing IMDb IDs")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"missing_items": missing_items,
|
||||
"summary": {
|
||||
"total_missing": len(missing_items),
|
||||
"tv_series": len(missing_tv),
|
||||
"movies": len(missing_movies)
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error retrieving missing IMDb items: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to retrieve missing IMDb items: {str(e)}"
|
||||
}
|
||||
|
||||
# ---------------------------
|
||||
# Core API - No Web Interface
|
||||
# ---------------------------
|
||||
|
||||
+63
-2
@@ -1597,14 +1597,18 @@ async def get_episodes_missing_nfo_dateadded(dependencies: dict):
|
||||
"total_episodes_checked": result.get('total_tv_files_checked', 0),
|
||||
"total_movies_checked": result.get('total_movie_files_checked', 0),
|
||||
"total_items_checked": result.get('total_tv_files_checked', 0) + result.get('total_movie_files_checked', 0),
|
||||
"missing_dateadded_count": result['total_missing'],
|
||||
"missing_dateadded_count": result['total_missing'], # Items that can be fixed
|
||||
"total_nfo_files_missing": result.get('total_nfo_files_missing', result['total_missing']), # All NFO files missing dateadded
|
||||
"tv_nfo_files_missing": result.get('tv_nfo_files_missing', 0),
|
||||
"movie_nfo_files_missing": result.get('movie_nfo_files_missing', 0),
|
||||
"items": all_missing[:50], # Limit display to first 50
|
||||
"debug_info": {
|
||||
"scan_method": "core_container_filesystem",
|
||||
"core_container_response": "success",
|
||||
"episodes_missing": result['episodes_missing'],
|
||||
"movies_missing": result['movies_missing'],
|
||||
"core_response_keys": list(result.keys())
|
||||
"core_response_keys": list(result.keys()),
|
||||
"statistics": result.get('statistics', {})
|
||||
}
|
||||
}
|
||||
else:
|
||||
@@ -1676,6 +1680,58 @@ async def fix_nfo_missing_dateadded(dependencies: dict):
|
||||
}
|
||||
|
||||
|
||||
async def get_missing_imdb_items(dependencies: dict):
|
||||
"""Get items missing IMDb IDs for manual review via core container"""
|
||||
config = dependencies["config"]
|
||||
|
||||
try:
|
||||
print("📋 Calling core container for missing IMDb items...")
|
||||
|
||||
# Call the core container's missing IMDb endpoint
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
core_url = f"http://nfoguard:{config.core_api_port}/admin/missing-imdb"
|
||||
print(f"🔍 DEBUG: Calling core container at: {core_url}")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=60.0) # 1 minute timeout
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(core_url) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
print(f"✅ Core container retrieved missing IMDb items: {result['summary']['total_missing']} items")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_missing": result['summary']['total_missing'],
|
||||
"tv_series_missing": result['summary']['tv_series'],
|
||||
"movies_missing": result['summary']['movies'],
|
||||
"items": result['missing_items'],
|
||||
"debug_info": {
|
||||
"scan_method": "core_container_database",
|
||||
"core_container_response": "success"
|
||||
}
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
print(f"❌ Core container missing IMDb retrieval failed: {response.status} - {error_text}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container retrieval failed: {response.status}",
|
||||
"message": f"Failed to retrieve missing IMDb items via core container: {response.status}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error calling core container for missing IMDb items: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Core container communication failed: {str(e)}",
|
||||
"message": f"Failed to retrieve missing IMDb items: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
async def bulk_update_nfo_files(dependencies: dict, imdb_ids: list = None, fix_all: bool = False):
|
||||
"""Update NFO files with missing dateadded elements from database"""
|
||||
db = dependencies["db"]
|
||||
@@ -1794,6 +1850,11 @@ def register_database_admin_routes(app, dependencies):
|
||||
"""Get episodes missing dateadded in NFO files"""
|
||||
return await get_episodes_missing_nfo_dateadded(dependencies)
|
||||
|
||||
@app.get("/api/admin/missing-imdb")
|
||||
async def api_missing_imdb_items():
|
||||
"""Get items missing IMDb IDs for manual review"""
|
||||
return await get_missing_imdb_items(dependencies)
|
||||
|
||||
@app.post("/api/admin/nfo/bulk-update")
|
||||
async def api_bulk_update_nfo_files(imdb_ids: list = None, fix_all: bool = False):
|
||||
"""Bulk update NFO files with missing dateadded via core container"""
|
||||
|
||||
@@ -150,6 +150,10 @@ class SonarrClient:
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get series information by Sonarr series ID"""
|
||||
return self._get(f"/series/{series_id}")
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
return self._get("/episode", {"seriesId": series_id}) or []
|
||||
|
||||
@@ -129,11 +129,32 @@ class NFOGuardDatabase:
|
||||
)
|
||||
""")
|
||||
|
||||
# Missing IMDb tracking table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS missing_imdb (
|
||||
id SERIAL PRIMARY KEY,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
media_type VARCHAR(20) NOT NULL,
|
||||
folder_name TEXT,
|
||||
filename TEXT,
|
||||
discovered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
check_count INTEGER DEFAULT 1,
|
||||
notes TEXT,
|
||||
resolved BOOLEAN DEFAULT FALSE,
|
||||
resolved_at TIMESTAMP,
|
||||
resolved_imdb_id VARCHAR(20)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for PostgreSQL
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_type ON missing_imdb(media_type)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_resolved ON missing_imdb(resolved)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)")
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
@@ -583,6 +604,75 @@ class NFOGuardDatabase:
|
||||
|
||||
return deleted_series
|
||||
|
||||
def add_missing_imdb(self, file_path: str, media_type: str, folder_name: str = None, filename: str = None, notes: str = None):
|
||||
"""Add or update a missing IMDb entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO missing_imdb (file_path, media_type, folder_name, filename, notes)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (file_path) DO UPDATE SET
|
||||
last_checked = CURRENT_TIMESTAMP,
|
||||
check_count = missing_imdb.check_count + 1,
|
||||
media_type = EXCLUDED.media_type,
|
||||
folder_name = EXCLUDED.folder_name,
|
||||
filename = EXCLUDED.filename,
|
||||
notes = EXCLUDED.notes
|
||||
""", (file_path, media_type, folder_name, filename, notes))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def get_missing_imdb_items(self, media_type: str = None, resolved: bool = False) -> List[Dict]:
|
||||
"""Get missing IMDb items, optionally filtered by type and resolution status"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT id, file_path, media_type, folder_name, filename,
|
||||
discovered_at, last_checked, check_count, notes,
|
||||
resolved, resolved_at, resolved_imdb_id
|
||||
FROM missing_imdb
|
||||
WHERE resolved = %s
|
||||
"""
|
||||
params = [resolved]
|
||||
|
||||
if media_type:
|
||||
query += " AND media_type = %s"
|
||||
params.append(media_type)
|
||||
|
||||
query += " ORDER BY last_checked DESC"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return cursor.fetchall()
|
||||
|
||||
def resolve_missing_imdb(self, file_path: str, imdb_id: str):
|
||||
"""Mark a missing IMDb item as resolved"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE missing_imdb
|
||||
SET resolved = TRUE,
|
||||
resolved_at = CURRENT_TIMESTAMP,
|
||||
resolved_imdb_id = %s
|
||||
WHERE file_path = %s
|
||||
""", (imdb_id, file_path))
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_missing_imdb(self, file_path: str) -> bool:
|
||||
"""Delete a missing IMDb entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM missing_imdb WHERE file_path = %s", (file_path,))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
|
||||
return deleted_count > 0
|
||||
|
||||
def close(self):
|
||||
"""Close all database connections"""
|
||||
if hasattr(self._local, 'connection'):
|
||||
|
||||
+72
-4
@@ -50,6 +50,70 @@ class NFOManager:
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]:
|
||||
"""
|
||||
Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files
|
||||
|
||||
1. First try to parse IMDb ID from directory path/name
|
||||
2. If not found, scan NFO files in directory for Sonarr series ID
|
||||
3. Use Sonarr API to lookup series and extract IMDb ID
|
||||
"""
|
||||
# Primary: Try directory name first
|
||||
imdb_id = self.parse_imdb_from_path(path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Check for shutdown signal before expensive operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Fallback: Check NFO files for Sonarr series ID
|
||||
if not sonarr_client or not sonarr_client.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Look for episode NFO files in the directory (and subdirectories)
|
||||
nfo_files = []
|
||||
if path.is_dir():
|
||||
# Check current directory and season subdirectories
|
||||
nfo_files.extend(list(path.glob("*.nfo")))
|
||||
for subdir in path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.lower().startswith('season'):
|
||||
nfo_files.extend(list(subdir.glob("*.nfo")))
|
||||
|
||||
# Extract Sonarr series ID from any NFO file
|
||||
for nfo_file in nfo_files:
|
||||
# Check for shutdown signal during NFO processing
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for Sonarr series ID
|
||||
for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'):
|
||||
sonarr_id = uniqueid.text
|
||||
if sonarr_id and sonarr_id.isdigit():
|
||||
# Check for shutdown signal before API call
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Look up series in Sonarr to get IMDb ID
|
||||
series_data = sonarr_client.get_series_by_id(int(sonarr_id))
|
||||
if series_data:
|
||||
imdb_id = series_data.get('imdbId')
|
||||
if imdb_id:
|
||||
return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id
|
||||
|
||||
except Exception as e:
|
||||
continue # Skip this NFO file and try the next one
|
||||
|
||||
except Exception as e:
|
||||
pass # Fallback failed, return None
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from NFO file content"""
|
||||
if not nfo_path.exists():
|
||||
@@ -154,8 +218,10 @@ class NFOManager:
|
||||
aired_elem = root.find('.//aired') # For TV episodes
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to nfo_file_existing
|
||||
source = "nfo_file_existing"
|
||||
|
||||
@@ -206,8 +272,10 @@ class NFOManager:
|
||||
aired_elem = root.find('.//aired')
|
||||
lockdata_elem = root.find('.//lockdata')
|
||||
|
||||
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
|
||||
if lockdata_elem is not None and lockdata_elem.text == "true":
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete episode NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# Extract original source from NFOGuard comment, default to episode_nfo_existing
|
||||
source = "episode_nfo_existing"
|
||||
|
||||
|
||||
@@ -123,3 +123,22 @@ networks:
|
||||
# - Webhooks remain responsive during long scans
|
||||
# - Independent scaling and resource allocation
|
||||
# - Separated concerns for maintenance and updates
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/simple"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 60s # Increased from 40s
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 30s # Increased from 10s
|
||||
@@ -1150,6 +1150,162 @@ textarea {
|
||||
border-color: #117a8b;
|
||||
}
|
||||
|
||||
/* Missing IMDb Items Styles */
|
||||
.missing-imdb-table-container {
|
||||
overflow-x: auto;
|
||||
max-height: 600px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.missing-imdb-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.missing-imdb-table thead {
|
||||
background-color: var(--bg-secondary);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.missing-imdb-table th,
|
||||
.missing-imdb-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.missing-imdb-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.missing-imdb-table td {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.missing-imdb-table .file-path {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.media-type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.media-type-badge.tv-series {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.media-type-badge.movie {
|
||||
background-color: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.missing-imdb-actions {
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.missing-imdb-actions ul {
|
||||
margin: 0.5rem 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.missing-imdb-actions li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.missing-imdb-actions code {
|
||||
background-color: var(--bg-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* NFO Statistics Styling */
|
||||
.nfo-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recommendation-box {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.recommendation-box ul {
|
||||
margin: 0.5rem 0 0 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.recommendation-box li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-box {
|
||||
background-color: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for admin tools */
|
||||
@media (max-width: 768px) {
|
||||
.table-container {
|
||||
@@ -1168,4 +1324,18 @@ textarea {
|
||||
.admin-tools {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.missing-imdb-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.missing-imdb-table th,
|
||||
.missing-imdb-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.missing-imdb-table .file-path {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -455,6 +455,9 @@
|
||||
<button class="btn btn-info" onclick="showEpisodesMissingNFODateadded()">
|
||||
<i class="fas fa-exclamation-triangle"></i> Episodes Missing NFO dateadded
|
||||
</button>
|
||||
<button class="btn btn-warning" onclick="showMissingImdbItems()">
|
||||
<i class="fas fa-search"></i> Items Missing IMDb IDs
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="exportDatabaseReport()">
|
||||
<i class="fas fa-download"></i> Export Database Report
|
||||
</button>
|
||||
|
||||
@@ -2013,13 +2013,35 @@ async function showEpisodesMissingNFODateadded() {
|
||||
const response = await apiCall('/api/admin/nfo/missing-dateadded');
|
||||
|
||||
if (response.success) {
|
||||
const totalFiles = response.total_nfo_files_missing || response.missing_dateadded_count;
|
||||
const fixableItems = response.missing_dateadded_count;
|
||||
const tvFiles = response.tv_nfo_files_missing || 0;
|
||||
const movieFiles = response.movie_nfo_files_missing || 0;
|
||||
|
||||
const html = `
|
||||
<h4>Episodes Missing NFO dateadded</h4>
|
||||
<p><strong>Found:</strong> ${response.missing_dateadded_count} episodes missing dateadded in NFO files</p>
|
||||
<p><strong>Total checked:</strong> ${response.total_episodes_checked} episodes</p>
|
||||
${response.missing_dateadded_count > 0 ?
|
||||
'<p><strong>Recommendation:</strong> Use the "NFO File Repair" tool to fix these issues.</p>' :
|
||||
'<p>✅ All NFO files are properly maintained!</p>'
|
||||
<h4>NFO Files Missing dateadded Elements</h4>
|
||||
<div class="nfo-stats-grid">
|
||||
<div class="stat-box">
|
||||
<div class="stat-number">${totalFiles}</div>
|
||||
<div class="stat-label">Total NFO Files Missing dateadded</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-number">${fixableItems}</div>
|
||||
<div class="stat-label">Can Be Fixed Automatically</div>
|
||||
</div>
|
||||
</div>
|
||||
<p><strong>Breakdown:</strong> ${tvFiles} TV episodes, ${movieFiles} movies</p>
|
||||
<p><strong>Scanned:</strong> ${response.total_episodes_checked} TV NFO files, ${response.total_movies_checked} movie NFO files</p>
|
||||
${totalFiles > 0 ? `
|
||||
<div class="recommendation-box">
|
||||
<p><strong>📋 Action Required:</strong></p>
|
||||
<ul>
|
||||
<li><strong>${fixableItems} items</strong> have IMDb IDs and database entries - use "NFO File Repair" tool to fix</li>
|
||||
${totalFiles > fixableItems ? `<li><strong>${totalFiles - fixableItems} items</strong> need IMDb IDs first - check "Items Missing IMDb IDs" tool</li>` : ''}
|
||||
</ul>
|
||||
</div>
|
||||
` :
|
||||
'<div class="success-box">✅ All NFO files are properly maintained!</div>'
|
||||
}
|
||||
`;
|
||||
resultsContent.innerHTML = html;
|
||||
@@ -2031,6 +2053,90 @@ async function showEpisodesMissingNFODateadded() {
|
||||
}
|
||||
}
|
||||
|
||||
async function showMissingImdbItems() {
|
||||
const resultsDiv = document.getElementById('admin-results');
|
||||
const resultsContent = document.getElementById('admin-results-content');
|
||||
|
||||
try {
|
||||
resultsContent.innerHTML = '<div class="loading">Checking for items missing IMDb IDs...</div>';
|
||||
resultsDiv.style.display = 'block';
|
||||
|
||||
const response = await apiCall('/api/admin/missing-imdb');
|
||||
|
||||
if (response.success) {
|
||||
let html = `
|
||||
<h4>Items Missing IMDb IDs</h4>
|
||||
<p><strong>Found:</strong> ${response.total_missing} items missing IMDb IDs</p>
|
||||
<p><strong>TV Series:</strong> ${response.tv_series_missing} | <strong>Movies:</strong> ${response.movies_missing}</p>
|
||||
`;
|
||||
|
||||
if (response.total_missing > 0) {
|
||||
html += `
|
||||
<p><strong>Recommendation:</strong> These items need manual IMDb ID assignment or folder/filename correction.</p>
|
||||
<div class="missing-imdb-table-container">
|
||||
<table class="missing-imdb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 10%">Type</th>
|
||||
<th style="width: 25%">Folder Name</th>
|
||||
<th style="width: 35%">File Path</th>
|
||||
<th style="width: 15%">Discovered</th>
|
||||
<th style="width: 10%">Checks</th>
|
||||
<th style="width: 5%">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
response.items.forEach(item => {
|
||||
const discoveredDate = item.discovered_at ? new Date(item.discovered_at).toLocaleDateString() : 'Unknown';
|
||||
const checkCount = item.check_count || 1;
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td>
|
||||
<span class="media-type-badge ${item.type.toLowerCase().replace(' ', '-')}">${item.type}</span>
|
||||
</td>
|
||||
<td title="${item.folder_name}">${item.folder_name}</td>
|
||||
<td title="${item.file_path}" class="file-path">${item.file_path}</td>
|
||||
<td>${discoveredDate}</td>
|
||||
<td>${checkCount}</td>
|
||||
<td>
|
||||
<button class="btn btn-small btn-info" title="Copy path to clipboard" onclick="copyToClipboard('${item.file_path}')">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="missing-imdb-actions">
|
||||
<p><strong>How to fix:</strong></p>
|
||||
<ul>
|
||||
<li>Add IMDb ID to folder name: <code>[imdb-tt1234567]</code></li>
|
||||
<li>Add IMDb ID to filename: <code>Movie.Name.2023.imdb-tt1234567.mkv</code></li>
|
||||
<li>Add IMDb ID to NFO file content</li>
|
||||
<li>Check folder structure and naming conventions</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += '<p>✅ All items have valid IMDb IDs!</p>';
|
||||
}
|
||||
|
||||
resultsContent.innerHTML = html;
|
||||
} else {
|
||||
resultsContent.innerHTML = `<div class="error">Error: ${response.message}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
resultsContent.innerHTML = `<div class="error">Failed to check missing IMDb items: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDatabaseReport() {
|
||||
alert('Database export functionality will be implemented in a future update.');
|
||||
}
|
||||
@@ -2093,3 +2199,29 @@ async function showRecentWebhookActivity() {
|
||||
resultsContent.innerHTML = `<div class="error">Failed to load webhook activity: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// Show a temporary success message
|
||||
const button = event.target.closest('button');
|
||||
const originalIcon = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
button.style.backgroundColor = '#4CAF50';
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalIcon;
|
||||
button.style.backgroundColor = '';
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
alert('Path copied to clipboard!');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user