Add drop folder monitoring feature
This commit is contained in:
parent
7dd6c9daea
commit
7c9a98925d
8 changed files with 426 additions and 4 deletions
|
|
@ -195,6 +195,11 @@ class ConfigManager:
|
||||||
"listenbrainz": {
|
"listenbrainz": {
|
||||||
"token": ""
|
"token": ""
|
||||||
},
|
},
|
||||||
|
"drop_folder": {
|
||||||
|
"enabled": False,
|
||||||
|
"watch_path": "",
|
||||||
|
"supported_formats": ['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a']
|
||||||
|
},
|
||||||
"logging": {
|
"logging": {
|
||||||
"path": "logs/app.log",
|
"path": "logs/app.log",
|
||||||
"level": "INFO"
|
"level": "INFO"
|
||||||
|
|
|
||||||
356
core/drop_monitor_folder.py
Normal file
356
core/drop_monitor_folder.py
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
"""
|
||||||
|
Drop Folder Monitor - Watches a folder for new music files and imports them
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
import shutil
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
from watchdog.observers import Observer
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
from config.settings import config_manager
|
||||||
|
|
||||||
|
logger = get_logger("drop_folder_monitor")
|
||||||
|
|
||||||
|
|
||||||
|
class DropFolderHandler(FileSystemEventHandler):
|
||||||
|
"""Handles file system events for the drop folder"""
|
||||||
|
|
||||||
|
def __init__(self, processor):
|
||||||
|
self.processor = processor
|
||||||
|
self.supported_formats = config_manager.get('drop_folder.supported_formats',
|
||||||
|
['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'])
|
||||||
|
|
||||||
|
def on_created(self, event):
|
||||||
|
"""Handle new file creation"""
|
||||||
|
if event.is_directory:
|
||||||
|
return
|
||||||
|
|
||||||
|
file_path = Path(event.src_path)
|
||||||
|
|
||||||
|
# Check if it's a supported audio file
|
||||||
|
if file_path.suffix.lower() in self.supported_formats:
|
||||||
|
logger.info(f"New file detected: {file_path.name}")
|
||||||
|
self.processor.queue_file(file_path)
|
||||||
|
|
||||||
|
|
||||||
|
class DropFolderProcessor:
|
||||||
|
"""Processes audio files dropped into the watch folder"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processing_queue = []
|
||||||
|
self._queue_lock = threading.Lock()
|
||||||
|
self.watch_path = None
|
||||||
|
self._observer = None
|
||||||
|
self._running = False
|
||||||
|
self._spotify_client = None
|
||||||
|
self._processor_thread = None
|
||||||
|
|
||||||
|
def _get_spotify_client(self):
|
||||||
|
"""Lazy load Spotify client only when needed"""
|
||||||
|
if self._spotify_client is None:
|
||||||
|
try:
|
||||||
|
from core.spotify_client import SpotifyClient
|
||||||
|
self._spotify_client = SpotifyClient()
|
||||||
|
if not self._spotify_client.is_authenticated():
|
||||||
|
logger.warning("Spotify not authenticated - metadata lookup disabled")
|
||||||
|
self._spotify_client = False # Mark as unavailable
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not initialize Spotify client: {e}")
|
||||||
|
self._spotify_client = False
|
||||||
|
return self._spotify_client if self._spotify_client else None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start monitoring the drop folder"""
|
||||||
|
watch_path = config_manager.get('drop_folder.watch_path')
|
||||||
|
enabled = config_manager.get('drop_folder.enabled', False)
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
logger.info("Drop folder monitoring is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not watch_path or not os.path.exists(watch_path):
|
||||||
|
logger.warning(f"Drop folder path not configured or doesn't exist: {watch_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.watch_path = Path(watch_path)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Set up file system watcher
|
||||||
|
event_handler = DropFolderHandler(self)
|
||||||
|
self._observer = Observer()
|
||||||
|
self._observer.schedule(event_handler, str(self.watch_path), recursive=False)
|
||||||
|
self._observer.start()
|
||||||
|
|
||||||
|
logger.info(f"Drop folder monitoring started: {self.watch_path}")
|
||||||
|
|
||||||
|
self._scan_existing_files()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop monitoring"""
|
||||||
|
self._running = False
|
||||||
|
if self._observer:
|
||||||
|
self._observer.stop()
|
||||||
|
self._observer.join()
|
||||||
|
self._observer = None
|
||||||
|
if hasattr(self, '_processor_thread') and self._processor_thread:
|
||||||
|
self._processor_thread.join(timeout=2)
|
||||||
|
self._processor_thread = None
|
||||||
|
logger.info("Drop folder monitoring stopped")
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""Reload configuration and restart if settings changed"""
|
||||||
|
enabled = config_manager.get('drop_folder.enabled', False)
|
||||||
|
watch_path = config_manager.get('drop_folder.watch_path')
|
||||||
|
|
||||||
|
# Check if we need to stop
|
||||||
|
if not enabled:
|
||||||
|
if self._observer:
|
||||||
|
logger.info("Drop folder disabled - stopping monitor")
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if we need to start or restart
|
||||||
|
new_path = Path(watch_path) if watch_path else None
|
||||||
|
needs_restart = self._observer is None or new_path != self.watch_path
|
||||||
|
|
||||||
|
if needs_restart:
|
||||||
|
logger.info("Drop folder config changed - (re)starting monitor")
|
||||||
|
self.stop()
|
||||||
|
if self.start():
|
||||||
|
self.start_background_processing()
|
||||||
|
|
||||||
|
def start_background_processing(self):
|
||||||
|
"""Start the async queue processor in a background thread.
|
||||||
|
Call this after start() returns True."""
|
||||||
|
def run_async_loop():
|
||||||
|
asyncio.run(self.process_queue())
|
||||||
|
|
||||||
|
self._processor_thread = threading.Thread(target=run_async_loop, daemon=True)
|
||||||
|
self._processor_thread.start()
|
||||||
|
logger.info("Background queue processor started")
|
||||||
|
|
||||||
|
def _scan_existing_files(self):
|
||||||
|
"""Scan for existing files in the drop folder"""
|
||||||
|
supported_formats = config_manager.get('drop_folder.supported_formats',
|
||||||
|
['.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'])
|
||||||
|
|
||||||
|
for file_path in self.watch_path.glob('*'):
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in supported_formats:
|
||||||
|
self.queue_file(file_path)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(self.processing_queue)} existing files to process")
|
||||||
|
|
||||||
|
def queue_file(self, file_path: Path):
|
||||||
|
"""Add file to processing queue"""
|
||||||
|
with self._queue_lock:
|
||||||
|
if file_path not in self.processing_queue:
|
||||||
|
self.processing_queue.append(file_path)
|
||||||
|
logger.debug(f"Queued for processing: {file_path.name}")
|
||||||
|
|
||||||
|
async def process_queue(self):
|
||||||
|
"""Process queued files"""
|
||||||
|
while self._running:
|
||||||
|
file_path = None
|
||||||
|
with self._queue_lock:
|
||||||
|
if self.processing_queue:
|
||||||
|
file_path = self.processing_queue.pop(0)
|
||||||
|
if file_path:
|
||||||
|
await self._process_file(file_path)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
def _read_file_metadata(self, file_path: Path) -> dict:
|
||||||
|
"""Read metadata from audio file using mutagen"""
|
||||||
|
try:
|
||||||
|
audio = MutagenFile(file_path, easy=True)
|
||||||
|
if audio is None:
|
||||||
|
return self._parse_filename_metadata(file_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'title': audio.get('title', [''])[0] or file_path.stem,
|
||||||
|
'artist': audio.get('artist', [''])[0] or 'Unknown Artist',
|
||||||
|
'album': audio.get('album', [''])[0] if audio.get('album') else None,
|
||||||
|
'genre': audio.get('genre', [''])[0] if audio.get('genre') else None,
|
||||||
|
'date': audio.get('date', [''])[0] if audio.get('date') else None,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not read metadata from {file_path.name}: {e}")
|
||||||
|
return self._parse_filename_metadata(file_path)
|
||||||
|
|
||||||
|
def _parse_filename_metadata(self, file_path: Path) -> dict:
|
||||||
|
"""Fallback: parse metadata from filename (Artist - Title.ext)"""
|
||||||
|
stem = file_path.stem
|
||||||
|
if ' - ' in stem:
|
||||||
|
parts = stem.split(' - ', 1)
|
||||||
|
return {'artist': parts[0].strip(), 'title': parts[1].strip(), 'album': None}
|
||||||
|
return {'title': stem, 'artist': 'Unknown Artist', 'album': None}
|
||||||
|
|
||||||
|
def _lookup_spotify_metadata(self, artist: str, title: str) -> Optional[Dict]:
|
||||||
|
"""Try to find better metadata from Spotify"""
|
||||||
|
spotify = self._get_spotify_client()
|
||||||
|
if not spotify:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Search Spotify with artist and title
|
||||||
|
query = f"artist:{artist} track:{title}"
|
||||||
|
tracks = spotify.search_tracks(query, limit=5)
|
||||||
|
|
||||||
|
if not tracks:
|
||||||
|
# Try simpler search
|
||||||
|
query = f"{artist} {title}"
|
||||||
|
tracks = spotify.search_tracks(query, limit=5)
|
||||||
|
|
||||||
|
if tracks:
|
||||||
|
# Find best match (first result is usually best)
|
||||||
|
best_track = tracks[0]
|
||||||
|
|
||||||
|
# Basic validation - check if artist name is somewhat similar
|
||||||
|
spotify_artist = best_track.artists[0] if best_track.artists else ''
|
||||||
|
if self._similarity(artist.lower(), spotify_artist.lower()) > 0.5:
|
||||||
|
logger.info(f" Spotify match: {best_track.name} by {spotify_artist}")
|
||||||
|
return {
|
||||||
|
'title': best_track.name,
|
||||||
|
'artist': spotify_artist,
|
||||||
|
'album': best_track.album_name,
|
||||||
|
'spotify_id': best_track.id,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.debug(f" Spotify result '{spotify_artist}' didn't match '{artist}'")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Spotify lookup failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _similarity(self, a: str, b: str) -> float:
|
||||||
|
"""Simple similarity check between two strings"""
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
# Check if one contains the other
|
||||||
|
if a in b or b in a:
|
||||||
|
return 0.8
|
||||||
|
# Count common words
|
||||||
|
words_a = set(a.split())
|
||||||
|
words_b = set(b.split())
|
||||||
|
if not words_a or not words_b:
|
||||||
|
return 0.0
|
||||||
|
common = words_a & words_b
|
||||||
|
return len(common) / max(len(words_a), len(words_b))
|
||||||
|
|
||||||
|
def _sanitize_filename(self, filename: str) -> str:
|
||||||
|
"""Sanitize filename for file system compatibility"""
|
||||||
|
# Replace invalid characters with underscores
|
||||||
|
sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||||
|
# Remove multiple spaces and trim
|
||||||
|
sanitized = re.sub(r'\s+', ' ', sanitized).strip()
|
||||||
|
# Limit length to avoid filesystem issues
|
||||||
|
return sanitized[:200] if len(sanitized) > 200 else sanitized
|
||||||
|
|
||||||
|
async def _wait_for_file_stable(self, file_path: Path, timeout: int = 30) -> bool:
|
||||||
|
"""Wait for file to finish being written (size stops changing)"""
|
||||||
|
if not file_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_size = -1
|
||||||
|
stable_count = 0
|
||||||
|
elapsed = 0
|
||||||
|
|
||||||
|
while elapsed < timeout:
|
||||||
|
try:
|
||||||
|
current_size = file_path.stat().st_size
|
||||||
|
if current_size == last_size and current_size > 0:
|
||||||
|
stable_count += 1
|
||||||
|
if stable_count >= 2: # Size stable for 2 checks
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
stable_count = 0
|
||||||
|
last_size = current_size
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
elapsed += 0.5
|
||||||
|
|
||||||
|
logger.warning(f"Timeout waiting for file to stabilize: {file_path.name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _process_file(self, file_path: Path):
|
||||||
|
"""Process a single audio file - organize into transfer folder structure"""
|
||||||
|
try:
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.warning(f"File no longer exists {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Wait for file to finish being copied/written
|
||||||
|
if not await self._wait_for_file_stable(file_path):
|
||||||
|
logger.warning(f"Skipping unstable file: {file_path.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Processing: {file_path.name}")
|
||||||
|
|
||||||
|
# Read metadata from file
|
||||||
|
metadata = self._read_file_metadata(file_path)
|
||||||
|
title = metadata.get('title', 'Unknown')
|
||||||
|
artist = metadata.get('artist', 'Unknown Artist')
|
||||||
|
album = metadata.get('album')
|
||||||
|
|
||||||
|
logger.info(f" File metadata - Title: {title}, Artist: {artist}, Album: {album or 'None'}")
|
||||||
|
|
||||||
|
# Try to enhance metadata with Spotify lookup (automatically skipped if not authenticated)
|
||||||
|
spotify_meta = self._lookup_spotify_metadata(artist, title)
|
||||||
|
if spotify_meta:
|
||||||
|
title = spotify_meta.get('title', title)
|
||||||
|
artist = spotify_meta.get('artist', artist)
|
||||||
|
album = spotify_meta.get('album', album)
|
||||||
|
logger.info(f" Enhanced metadata - Title: {title}, Artist: {artist}, Album: {album or 'None'}")
|
||||||
|
|
||||||
|
# Get transfer path from config
|
||||||
|
transfer_path = config_manager.get('soulseek.transfer_path', './Transfer')
|
||||||
|
transfer_dir = Path(transfer_path)
|
||||||
|
transfer_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create artist directory
|
||||||
|
artist_dir = transfer_dir / self._sanitize_filename(artist)
|
||||||
|
artist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Determine folder structure based on album info
|
||||||
|
file_ext = file_path.suffix
|
||||||
|
|
||||||
|
if album and album not in ['Unknown Album', 'None', '']:
|
||||||
|
# Album track: Transfer/Artist/Artist - Album/Title.ext
|
||||||
|
album_folder = f"{self._sanitize_filename(artist)} - {self._sanitize_filename(album)}"
|
||||||
|
album_dir = artist_dir / album_folder
|
||||||
|
album_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dest_filename = f"{self._sanitize_filename(title)}{file_ext}"
|
||||||
|
dest_path = album_dir / dest_filename
|
||||||
|
logger.info(f" -> Album track: {artist}/{album_folder}/{dest_filename}")
|
||||||
|
else:
|
||||||
|
# Single track: Transfer/Artist/Title.ext (no subfolder)
|
||||||
|
dest_filename = f"{self._sanitize_filename(title)}{file_ext}"
|
||||||
|
dest_path = artist_dir / dest_filename
|
||||||
|
logger.info(f" -> Single: {artist}/{dest_filename}")
|
||||||
|
|
||||||
|
# Handle existing file
|
||||||
|
if dest_path.exists():
|
||||||
|
dest_path.unlink()
|
||||||
|
|
||||||
|
# Move file to organized location
|
||||||
|
shutil.move(str(file_path), str(dest_path))
|
||||||
|
logger.info(f" Moved to: {dest_path}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing {file_path.name}: {e}")
|
||||||
7
main.py
7
main.py
|
|
@ -16,6 +16,7 @@ from core.plex_client import PlexClient
|
||||||
from core.jellyfin_client import JellyfinClient
|
from core.jellyfin_client import JellyfinClient
|
||||||
from core.navidrome_client import NavidromeClient
|
from core.navidrome_client import NavidromeClient
|
||||||
from core.soulseek_client import SoulseekClient
|
from core.soulseek_client import SoulseekClient
|
||||||
|
from core.drop_monitor_folder import DropFolderProcessor
|
||||||
|
|
||||||
from ui.sidebar import ModernSidebar
|
from ui.sidebar import ModernSidebar
|
||||||
from ui.pages.dashboard import DashboardPage
|
from ui.pages.dashboard import DashboardPage
|
||||||
|
|
@ -91,6 +92,9 @@ class MainWindow(QMainWindow):
|
||||||
self.jellyfin_client = JellyfinClient()
|
self.jellyfin_client = JellyfinClient()
|
||||||
self.navidrome_client = NavidromeClient()
|
self.navidrome_client = NavidromeClient()
|
||||||
self.soulseek_client = SoulseekClient()
|
self.soulseek_client = SoulseekClient()
|
||||||
|
self.drop_folder_processor = DropFolderProcessor()
|
||||||
|
if self.drop_folder_processor.start():
|
||||||
|
self.drop_folder_processor.start_background_processing()
|
||||||
|
|
||||||
self.status_thread = None
|
self.status_thread = None
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
@ -398,6 +402,9 @@ class MainWindow(QMainWindow):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error closing Soulseek client: {e}")
|
logger.error(f"Error closing Soulseek client: {e}")
|
||||||
|
|
||||||
|
if hasattr(self, 'drop_folder_processor'):
|
||||||
|
self.drop_folder_processor.stop()
|
||||||
|
|
||||||
# Close database connection
|
# Close database connection
|
||||||
try:
|
try:
|
||||||
logger.info("Closing database connection...")
|
logger.info("Closing database connection...")
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,8 @@ yt-dlp>=2024.12.13
|
||||||
# Lyrics support
|
# Lyrics support
|
||||||
lrclibapi>=0.3.1
|
lrclibapi>=0.3.1
|
||||||
|
|
||||||
|
# Drop-in folder support
|
||||||
|
watchdog>=4.0.0
|
||||||
|
|
||||||
# Optional: MQTT support (for future features)
|
# Optional: MQTT support (for future features)
|
||||||
asyncio-mqtt>=0.16.0
|
asyncio-mqtt>=0.16.0
|
||||||
|
|
@ -12,3 +12,4 @@ unidecode>=1.3.8
|
||||||
yt-dlp>=2024.12.13
|
yt-dlp>=2024.12.13
|
||||||
Flask>=3.0.0
|
Flask>=3.0.0
|
||||||
lrclibapi>=0.3.1
|
lrclibapi>=0.3.1
|
||||||
|
watchdog>=4.0.0
|
||||||
|
|
@ -37,6 +37,7 @@ from core.matching_engine import MusicMatchingEngine
|
||||||
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
||||||
from core.web_scan_manager import WebScanManager
|
from core.web_scan_manager import WebScanManager
|
||||||
from core.lyrics_client import lyrics_client
|
from core.lyrics_client import lyrics_client
|
||||||
|
from core.drop_monitor_folder import DropFolderProcessor
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
from services.sync_service import PlaylistSyncService
|
from services.sync_service import PlaylistSyncService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -148,10 +149,13 @@ try:
|
||||||
'navidrome_client': navidrome_client
|
'navidrome_client': navidrome_client
|
||||||
}
|
}
|
||||||
web_scan_manager = WebScanManager(media_clients, delay_seconds=60)
|
web_scan_manager = WebScanManager(media_clients, delay_seconds=60)
|
||||||
|
|
||||||
|
# Initialize drop folder processor (will be started in __main__ if enabled)
|
||||||
|
drop_folder_processor = DropFolderProcessor()
|
||||||
print("✅ Core service clients and scan manager initialized.")
|
print("✅ Core service clients and scan manager initialized.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
print(f"🔴 FATAL: Error initializing service clients: {e}")
|
||||||
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = None
|
spotify_client = plex_client = jellyfin_client = navidrome_client = soulseek_client = tidal_client = matching_engine = sync_service = web_scan_manager = drop_folder_processor = None
|
||||||
|
|
||||||
# --- Global Streaming State Management ---
|
# --- Global Streaming State Management ---
|
||||||
# Thread-safe state tracking for streaming functionality
|
# Thread-safe state tracking for streaming functionality
|
||||||
|
|
@ -2252,7 +2256,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||||
|
|
||||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz']:
|
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'drop_folder']:
|
||||||
if service in new_settings:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
config_manager.set(f'{service}.{key}', value)
|
||||||
|
|
@ -2273,6 +2277,9 @@ def handle_settings():
|
||||||
soulseek_client.reload_settings()
|
soulseek_client.reload_settings()
|
||||||
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
|
# Reload drop folder processor settings (start/stop based on enabled flag)
|
||||||
|
if drop_folder_processor:
|
||||||
|
drop_folder_processor.reload_config()
|
||||||
print("✅ Service clients re-initialized with new settings.")
|
print("✅ Service clients re-initialized with new settings.")
|
||||||
return jsonify({"success": True, "message": "Settings saved successfully."})
|
return jsonify({"success": True, "message": "Settings saved successfully."})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -22882,6 +22889,14 @@ if __name__ == '__main__':
|
||||||
start_watchlist_auto_scanning()
|
start_watchlist_auto_scanning()
|
||||||
print("✅ Automatic watchlist scanning started (5 minute initial delay, 24 hour cycles)")
|
print("✅ Automatic watchlist scanning started (5 minute initial delay, 24 hour cycles)")
|
||||||
|
|
||||||
|
# Start drop folder monitoring if enabled
|
||||||
|
print("🔧 Checking drop folder monitoring...")
|
||||||
|
if drop_folder_processor and drop_folder_processor.start():
|
||||||
|
drop_folder_processor.start_background_processing()
|
||||||
|
print("✅ Drop folder monitoring started")
|
||||||
|
else:
|
||||||
|
print("ℹ️ Drop folder monitoring not enabled or not configured")
|
||||||
|
|
||||||
# Initialize app start time for uptime tracking
|
# Initialize app start time for uptime tracking
|
||||||
import time
|
import time
|
||||||
app.start_time = time.time()
|
app.start_time = time.time()
|
||||||
|
|
|
||||||
|
|
@ -3066,6 +3066,33 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Drop Folder Settings -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<h3>📂 Drop Folder</h3>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="drop-folder-enabled">
|
||||||
|
Enable drop folder monitoring
|
||||||
|
</label>
|
||||||
|
<div class="setting-help-text">
|
||||||
|
Automatically import audio files dropped into a watched folder.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Watch Folder Path:</label>
|
||||||
|
<div class="path-input-group">
|
||||||
|
<input type="text" id="drop-folder-path" placeholder="C:/Music/DropFolder">
|
||||||
|
<button class="browse-button" onclick="browsePath('drop_folder')">Browse</button>
|
||||||
|
</div>
|
||||||
|
<div class="setting-help-text">
|
||||||
|
Audio files placed here will be automatically processed and moved to your transfer folder.
|
||||||
|
Metadata will be enhanced via Spotify if connected.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Playlist Sync Settings -->
|
<!-- Playlist Sync Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>Playlist Sync Settings</h3>
|
<h3>Playlist Sync Settings</h3>
|
||||||
|
|
|
||||||
|
|
@ -1704,6 +1704,10 @@ async function loadSettingsData() {
|
||||||
// Populate Playlist Sync settings
|
// Populate Playlist Sync settings
|
||||||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
||||||
|
|
||||||
|
// Populate Drop Folder settings
|
||||||
|
document.getElementById('drop-folder-enabled').checked = settings.drop_folder?.enabled === true;
|
||||||
|
document.getElementById('drop-folder-path').value = settings.drop_folder?.watch_path || '';
|
||||||
|
|
||||||
// Populate Logging information (read-only)
|
// Populate Logging information (read-only)
|
||||||
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
||||||
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
||||||
|
|
@ -2096,6 +2100,10 @@ async function saveSettings() {
|
||||||
},
|
},
|
||||||
playlist_sync: {
|
playlist_sync: {
|
||||||
create_backup: document.getElementById('create-backup').checked
|
create_backup: document.getElementById('create-backup').checked
|
||||||
|
},
|
||||||
|
drop_folder: {
|
||||||
|
enabled: document.getElementById('drop-folder-enabled').checked,
|
||||||
|
watch_path: document.getElementById('drop-folder-path').value
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue