diff --git a/README.md b/README.md index 272cc16..031df3d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ red-plex now includes a comprehensive web-based GUI for users who prefer a visua - **⚙️ Configuration Management**: View and edit all settings (API keys, Plex config, rate limits) through web forms - **🎨 Collage Operations**: Convert new collages and view existing collections - **🔖 Bookmark Operations**: Convert bookmarks from RED and OPS trackers +- **🏷️ Site Tags Operations**: Scan albums using album/artist names and create tag-based collections - **🗄️ Database Management**: View statistics, update albums, reset tables - **⚡ Real-time Updates**: Live status updates during long operations via WebSocket - **📱 Mobile-Responsive**: Bootstrap-based design that works on all devices @@ -45,12 +46,6 @@ red-plex gui --debug The web interface provides the same functionality as the CLI commands but with a user-friendly visual interface, real-time progress updates, and intuitive navigation. -## What are RED and OPS? - -- **Redacted (RED)**: A private music tracker focused on high-quality audio files -- **Orpheus Network (OPS)**: Another private music tracker with curated content -- Both use the Gazelle framework and offer "collages" (curated collections) and personal bookmarks - ## Table of Contents - [Prerequisites](#prerequisites) - [Installation](#installation) @@ -62,7 +57,10 @@ The web interface provides the same functionality as the CLI commands but with a - [Usage & Commands](#usage--commands) - [Configuration Commands](#configuration-commands) - [Collages](#collages) + - [Upstream Sync](#upstream-sync) - [Bookmarks](#bookmarks) + - [Site Tags](#site-tags) + - [Remote Mappings](#remote-mappings) - [Fetch Mode (-fm)](#fetch-mode--fm) - [Database Commands](#database-commands) - [Examples](#examples) @@ -152,9 +150,10 @@ OPS: seconds: 15 ``` -### Getting Your Plex Token +### Getting Your Plex HTTPs URL -Visit: https://plex.tv/api/resources?includeHttps=1&X-Plex-Token={YOUR_TOKEN} +Visit: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token +Also visit: https://plex.tv/api/resources?includeHttps=1&X-Plex-Token={YOUR_TOKEN} ## Overview @@ -169,7 +168,10 @@ Visit: https://plex.tv/api/resources?includeHttps=1&X-Plex-Token={YOUR_TOKEN} - **Multi-Site**: Works with Redacted ("red") and Orpheus Network ("ops"). - **Web Interface**: Modern Flask-based GUI with Bootstrap styling and real-time updates. - **Collections from Collages/Bookmarks**: Create or update entire Plex collections for each collage or bookmarked set. -- **Local SQLite Database**: All data (albums, collages, bookmarks) is kept in one DB, no more CSV. +- **Site Tags Mapping**: Map your Plex albums to site groups using album and artist names, then create collections based on specific tags. +- **Upstream Sync**: Push local Plex collection changes back to upstream collages on RED, enabling bidirectional synchronization between Plex collections and site collages. +- **Remote Mappings**: Core functionality that links Plex collections with site group IDs, enabling features like site tags and upstream sync. +- **Local SQLite Database**: All data (albums, collages, bookmarks, site mappings) is kept in one DB, no more CSV. - **Two Fetch Modes**: Choose between `torrent_name` (default) for direct path matching or `query` for metadata-based searches in Plex. - **Configurable Logging**: Choose between INFO, DEBUG, etc., in `config.yml`. - **Rate Limiting**: Respects site rate limits and retries on errors. @@ -216,6 +218,40 @@ red-plex collages convert [COLLAGE_IDS] --site [red|ops] --fetch-mode [torrent_n red-plex collages update --fetch-mode [torrent_name|query] ``` +### Upstream Sync + +**⚠️ Important Prerequisites:** +- Only works with **RED** (Redacted) - OPS doesn't have the required API operations +- You must **own** the collages on the tracker site +- First **convert the collage** from the site using `red-plex collages convert` (even if the collage is empty) +- Run **remote mappings scan** to link Plex items with site group IDs: `red-plex db remote-mappings scan -s red` + +**How it works:** +- Pushes local Plex collection changes back to upstream collages on RED +- Uses fuzzy string matching to find albums on the site (may cause occasional mismatches) +- Shows confirmation dialog with exactly what will be added before making changes +- **Never deletes** anything from upstream collages, only adds missing items +- If you update your Plex library, run a new remote-mappings scan to link new albums + +```bash +# Sync all collections to upstream collages +red-plex collages update --push + +# Sync specific collections to upstream collages +red-plex collages update 12345 67890 --push + +# Alternative flag name +red-plex collages update --update-upstream +``` + +**Scanning Process:** +- Can be cancelled anytime with **Ctrl+C** and resumed later +- Each mapping is saved to the database during scanning (no data loss) +- Scans latest added entries in Plex, so interrupted scans can continue from where they left off +- ⚠️ **Current limitation**: If you have many unmatched albums (e.g., 200 albums with no matches), they'll appear in the scan queue each time. This is being improved to ignore previously failed matches. + +**Available in both CLI and Web Interface** + ### Bookmarks ```bash @@ -226,6 +262,32 @@ red-plex bookmarks convert --site [red|ops] --fetch-mode [torrent_name|query] red-plex bookmarks update --fetch-mode [torrent_name|query] ``` +### Site Tags + +```bash +# Create collections from albums matching specific tags +red-plex extras site-tags convert --tags [tag1,tag2,...] --collection-name [name] +``` + +### Remote Mappings + +Remote mappings are the core functionality that links your Plex music library with site group IDs, enabling features like site tags and upstream sync. + +```bash +# Scan albums and create remote mappings using album and artist names +red-plex db remote-mappings scan --site [red|ops] [--always-skip] + +# Reset remote mappings (clears the relationship data) +red-plex db remote-mappings reset +``` + +**Scanning Features:** +- **Interruptible**: Can be cancelled with **Ctrl+C** at any time and resumed later +- **No data loss**: Each mapping is saved to database immediately during the scan +- **Incremental**: Processes latest Plex additions first, so you can resume interrupted scans +- **Fuzzy matching**: Uses string similarity to match Plex albums with site releases +- ⚠️ **Current limitation**: Albums with no matches will reappear in future scans (improvement planned) + ### Fetch Mode (-fm) The `--fetch-mode` (or `-fm`) option controls how red-plex locates albums in Plex: @@ -249,6 +311,10 @@ red-plex db collections reset # Clear the collage collections table # Manage bookmarks table red-plex db bookmarks reset # Clear the bookmark collections table + +# Manage remote mappings (core feature for site tags and upstream sync) +red-plex db remote-mappings scan --site [red|ops] # Create Plex-to-site mappings +red-plex db remote-mappings reset # Clear remote mapping data ``` ## Examples @@ -264,6 +330,16 @@ red-plex collages convert 1111 2222 3333 --site ops # From bookmarks (RED or OPS), default mode red-plex bookmarks convert --site red + +# Remote mappings - scan albums and create mappings using album/artist names +red-plex db remote-mappings scan --site red + +# Site tags - create collection from specific tags +red-plex extras site-tags convert --tags "electronic,ambient" --collection-name "Electronic Ambient" + +# Upstream sync - push Plex collection changes back to RED collages (RED only) +red-plex collages update 12345 --push # Sync specific collage +red-plex collages update --push # Sync all collages ``` ### Updating Collections @@ -277,6 +353,9 @@ red-plex bookmarks update # Update albums from Plex red-plex db albums update + +# Sync collections to upstream collages (RED only) +red-plex collages update --push ``` ### Using Query Fetch Mode @@ -305,9 +384,21 @@ red-plex collages convert 12345 67890 --site red # 4. Create collection from your bookmarks red-plex bookmarks convert --site red -# 5. Later, update all collections with new releases +# 5. Scan albums for remote mappings (required for site tags and upstream sync) +red-plex db remote-mappings scan --site red + +# 6. Create collections from specific tags +red-plex extras site-tags convert --tags "electronic,downtempo" --collection-name "Electronic Downtempo" + +# 7. Sync collections back to upstream collages (RED only, requires ownership) +red-plex collages update --push + +# 8. Later, update all collections with new releases red-plex collages update red-plex bookmarks update + +# 9. If you add new music to Plex, re-scan for new mappings +red-plex db remote-mappings scan --site red ``` #### Web Interface @@ -325,7 +416,13 @@ red-plex gui # 6. Use the Bookmarks page to convert your bookmarks -# 7. Return to Database page later to update all collections +# 7. Use the Remote Mappings page to scan albums and create mappings + +# 8. Use Site Tags to create collections from specific tags + +# 9. Use the Collages page to sync collections to upstream (RED only) + +# 10. Return to Database page later to update all collections ``` ## Configuration Details @@ -365,13 +462,6 @@ SECTION_NAME: Music ### Common Issues -#### "No module named 'plexapi'" Error -```bash -pip install plexapi -# or -pip install red-plex --upgrade -``` - #### Authentication Errors - Verify your API keys are correct in `config.yml` - Check that your Plex token is valid @@ -417,6 +507,16 @@ pip install red-plex --upgrade - `INFO`: Standard information (default) - `WARNING`: Minimal output - **Collection Updates**: When you run `collages update` or `bookmarks update`, new albums are added to existing Plex collections, but removed items from tracker collages are not automatically removed from Plex collections +- **Upstream Sync (RED Only)**: + - Only works with RED (Redacted) - OPS doesn't support the required API operations + - You must own the collages you want to sync to + - Convert collages from the site first, even if they're empty + - Run `red-plex db remote-mappings scan --site red` to create the necessary mappings + - Uses fuzzy string matching which may occasionally cause mismatches + - Never deletes items from upstream collages, only adds missing ones + - Can be interrupted with Ctrl+C and resumed later with no data loss + - Re-scan after adding new music to your Plex library +- **Remote Mappings**: Core functionality that links Plex items with site group IDs. Required for both site tags and upstream sync features ## Contributing @@ -431,5 +531,3 @@ pip install -e . ``` --- - -**Disclaimer**: This tool is for personal use with your own music library and tracker accounts. Respect the rules and terms of service of the private trackers you use. \ No newline at end of file diff --git a/SITE_TAGS_USAGE.md b/SITE_TAGS_USAGE.md new file mode 100644 index 0000000..cf1682f --- /dev/null +++ b/SITE_TAGS_USAGE.md @@ -0,0 +1,90 @@ +# Site Tags Feature Usage + +This document explains how to use the new site tags functionality to create Plex collections based on site tags. + +## Overview + +The site tags feature allows you to: +1. Scan your Plex albums and map them to site groups based on filename searches +2. Create Plex collections from albums that match specific tags + +## Prerequisites + +1. Ensure your Plex server is configured and accessible +2. Configure your RED/OPS API credentials in the config file +3. Populate your albums database with `red-plex db albums update` + +## Usage + +### 1. Scan Albums for Site Tags + +This command scans your albums and creates mappings to site groups: + +```bash +# Scan albums for RED +red-plex extras site-tags scan --site red + +# Scan albums for OPS +red-plex extras site-tags scan --site ops +``` + +The scan process: +- Fetches track file paths from each album in Plex +- Searches the site using filenames +- If multiple matches are found, prompts you to choose +- Creates mappings between Plex rating_key and site group_id + tags +- Is incremental - only processes albums not yet scanned + +### 2. Create Collections from Tags + +Create Plex collections based on tag filters: + +```bash +# Create a collection of electronic music +red-plex extras site-tags convert --site red --tags "electronic" --collection-name "Electronic Music" + +# Create a collection with multiple tag requirements +red-plex extras site-tags convert --site red --tags "drum.and.bass,liquid" --collection-name "Liquid DNB" +``` + +### 3. Reset Site Tag Mappings + +If you need to reset the mappings: + +```bash +# Reset mappings for a specific site +red-plex extras site-tags reset --site red + +# Reset all mappings +red-plex extras site-tags reset +``` + +## Examples + +### Example Workflow + +1. **Initial scan:** + ```bash + red-plex db albums update + red-plex extras site-tags scan --site red + ``` + +2. **Create genre-based collections:** + ```bash + red-plex extras site-tags convert --site red --tags "electronic" --collection-name "Electronic" + red-plex extras site-tags convert --site red --tags "jazz" --collection-name "Jazz" + red-plex extras site-tags convert --site red --tags "rock" --collection-name "Rock" + ``` + +3. **Create specific sub-genre collections:** + ```bash + red-plex extras site-tags convert --site red --tags "drum.and.bass,liquid" --collection-name "Liquid DNB" + red-plex extras site-tags convert --site red --tags "ambient,dark" --collection-name "Dark Ambient" + ``` + +## Notes + +- The scan process handles filename variations (e.g., removes track numbers like "1. " from the beginning) +- Multiple matches require user confirmation to ensure accuracy +- Collections are created/updated in Plex - existing collections with the same name will be replaced +- The tag matching uses AND logic - all specified tags must be present for an album to match \ No newline at end of file diff --git a/red_plex/domain/models.py b/red_plex/domain/models.py index 423cd96..4bbe99d 100644 --- a/red_plex/domain/models.py +++ b/red_plex/domain/models.py @@ -30,6 +30,7 @@ class TorrentGroup: artists: List[str] = field(default_factory=list) album_name: str = "" file_paths: List[str] = field(default_factory=list) + tags: List[str] = field(default_factory=list) def __hash__(self) -> int: return hash((self.id, tuple(self.artists), self.album_name, tuple(self.file_paths))) diff --git a/red_plex/infrastructure/cli/cli.py b/red_plex/infrastructure/cli/cli.py index f539e3d..763ffe6 100644 --- a/red_plex/infrastructure/cli/cli.py +++ b/red_plex/infrastructure/cli/cli.py @@ -1,30 +1,16 @@ """Collection creator CLI.""" -import os -import subprocess import sys -from typing import List import click -import yaml -from red_plex.domain.models import Collection -from red_plex.infrastructure.config.config import ( - CONFIG_FILE_PATH, - load_config, - save_config, - ensure_config_exists -) -from red_plex.infrastructure.config.models import Configuration +from red_plex.infrastructure.cli.commands.bookmarks import bookmarks +from red_plex.infrastructure.cli.commands.collages import collages +from red_plex.infrastructure.cli.commands.config import config +from red_plex.infrastructure.cli.commands.db import db +from red_plex.infrastructure.cli.commands.extras import extras +from red_plex.infrastructure.cli.commands.gui import gui from red_plex.infrastructure.db.local_database import LocalDatabase -from red_plex.infrastructure.logger.logger import logger, configure_logger -from red_plex.infrastructure.plex.plex_manager import PlexManager -from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI -from red_plex.infrastructure.service.collection_processor import CollectionProcessingService -from red_plex.use_case.create_collection.album_fetch_mode import AlbumFetchMode -from red_plex.use_case.create_collection.query.query_sync_collection import ( - QuerySyncCollectionUseCase) -from red_plex.use_case.create_collection.torrent_name.torrent_name_sync_collection import ( - TorrentNameCollectionCreatorUseCase) +from red_plex.infrastructure.logger.logger import configure_logger @click.group() @@ -35,459 +21,13 @@ def cli(ctx): ctx.obj['db'] = LocalDatabase() -# config -@cli.group() -def config(): - """View or edit configuration settings.""" - - -# config show -@config.command('show') -def show_config(): - """Display the current configuration.""" - config_data = load_config() - path_with_config = ( - f"Configuration path: {CONFIG_FILE_PATH}\n\n" + - yaml.dump(config_data.to_dict(), default_flow_style=False) - ) - click.echo(path_with_config) - - -# config edit -@config.command('edit') -def edit_config(): - """Open the configuration file in the default editor.""" - # Ensure the configuration file exists - ensure_config_exists() - - # Default to 'nano' if EDITOR is not set - editor = os.environ.get('EDITOR', 'notepad' if os.name == 'nt' else 'nano') - click.echo(f"Opening config file at {CONFIG_FILE_PATH}...") - try: - subprocess.call([editor, CONFIG_FILE_PATH]) - except FileNotFoundError: - message = f"Editor '{editor}' not found. \ - Please set the EDITOR environment variable to a valid editor." - logger.error(message) - click.echo(message) - except Exception as exc: # pylint: disable=W0718 - logger.exception('Failed to open editor: %s', exc) - click.echo(f"An error occurred while opening the editor: {exc}") - - -# config reset -@config.command('reset') -def reset_config(): - """Reset the configuration to default values.""" - if click.confirm('Are you sure you want to reset the configuration to default values?'): - save_config(Configuration.default()) - click.echo(f"Configuration reset to default values at {CONFIG_FILE_PATH}") - - -# collages -@cli.group('collages') -def collages(): - """Possible operations with site collages.""" - - -# collages update -@collages.command('update') -@click.pass_context -@click.option( - '--fetch-mode', '-fm', - type=click.Choice(['torrent_name', 'query']), - default='torrent_name', - show_default=True, - help=( - '(Optional) Album lookup strategy:\n' - '\n- torrent_name: uses torrent dir name to search in Plex, ' - 'if you don\'t use Beets/Lidarr \n' - '\n- query: uses queries to Plex instead of searching by path name ' - '(if you use Beets/Lidarr)\n' - ) -) -def update_collages(ctx, fetch_mode: str): - """Synchronize all stored collections with their source collages.""" - fetch_mode = map_fetch_mode(fetch_mode) - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - all_collages = local_database.get_all_collage_collections() - - if not all_collages: - click.echo("No collages found in the db.") - return - - # Initialize PlexManager once, populate its db once - plex_manager = PlexManager(local_database) - if not plex_manager: - return - plex_manager.populate_album_table() - - update_collections_from_collages( - local_database=local_database, - collage_list=all_collages, - plex_manager=plex_manager, - fetch_bookmarks=False, - fetch_mode=fetch_mode) - except Exception as exc: # pylint: disable=W0718 - logger.exception('Failed to update stored collections: %s', exc) - click.echo(f"An error occurred while updating stored collections: {exc}") - - -# convert collection -@collages.command('convert') -@click.argument('collage_ids', nargs=-1) -@click.option('--site', '-s', - type=click.Choice(['red', 'ops']), - required=True, - help='Specify the site: red (Redacted) or ops (Orpheus).') -@click.option( - '--fetch-mode', '-fm', - type=click.Choice(['torrent_name', 'query'], case_sensitive=False), # Added case_sensitive - default='torrent_name', - show_default=True, - help=( - '(Optional) Album lookup strategy:\n' - '\n- torrent_name: uses torrent dir name (original behavior).\n' - '\n- query: uses Plex queries (Beets/Lidarr friendly).\n' - ) -) -@click.pass_context -def convert_collages(ctx, collage_ids, site, fetch_mode): - """ - Create/Update Plex collections from given COLLAGE_IDS. - """ - if not collage_ids: - click.echo("Please provide at least one COLLAGE_ID.") - ctx.exit(1) # Exit with an error code - - album_fetch_mode_enum = map_fetch_mode(fetch_mode) - - # --- Dependency Setup --- - local_database = ctx.obj.get('db') - if not local_database: - click.echo("Error: Database not initialized.", err=True) - ctx.exit(1) - - plex_manager, gazelle_api = None, None - try: - plex_manager = PlexManager(db=local_database) - gazelle_api = GazelleAPI(site) - except Exception as e: # pylint: disable=W0718 - logger.error("Failed to initialize dependencies: %s", e, exc_info=True) - click.echo(f"Error: Failed to initialize dependencies - {e}", err=True) - ctx.exit(1) - - # --- Service Instantiation and Execution --- - processor = CollectionProcessingService(local_database, plex_manager, gazelle_api) - - # Call the service, passing the necessary functions from click - processor.process_collages( - collage_ids=collage_ids, - album_fetch_mode=album_fetch_mode_enum, - echo_func=click.echo, - confirm_func=click.confirm # Pass the actual click.confirm - ) - - click.echo("Processing finished.") - - -# bookmarks -@cli.group() -def bookmarks(): - """Possible operations with your site bookmarks.""" - - -# bookmarks update -@bookmarks.command('update') -@click.pass_context -@click.option( - '--fetch-mode', '-fm', - type=click.Choice(['torrent_name', 'query']), - default='torrent_name', - show_default=True, - help=( - '(Optional) Album lookup strategy:\n' - '\n- torrent_name: uses torrent dir name to search in Plex, ' - 'if you don\'t use Beets/Lidarr \n' - '\n- query: uses queries to Plex instead of searching by path name ' - '(if you use Beets/Lidarr)\n' - ) -) -def update_bookmarks_collection(ctx, fetch_mode: str): - """Synchronize all stored bookmarks with their source collages.""" - fetch_mode = map_fetch_mode(fetch_mode) - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - all_bookmarks = local_database.get_all_bookmark_collections() - - if not all_bookmarks: - click.echo("No bookmarks found in the db.") - return - - plex_manager = PlexManager(local_database) - if not plex_manager: - return - plex_manager.populate_album_table() - - update_collections_from_collages( - local_database, - all_bookmarks, - plex_manager, - fetch_bookmarks=True) - - except Exception as exc: # pylint: disable=W0718 - logger.exception('Failed to update stored bookmarks: %s', exc) - click.echo(f"An error occurred while updating stored bookmarks: {exc}") - - -# bookmarks convert -@bookmarks.command('convert') -@click.option('--site', '-s', - type=click.Choice(['red', 'ops'], case_sensitive=False), - required=True, - help='Specify the site: red (Redacted) or ops (Orpheus).') -@click.option( - '--fetch-mode', '-fm', - type=click.Choice(['torrent_name', 'query'], case_sensitive=False), - default='torrent_name', - show_default=True, - help=( - '(Optional) Album lookup strategy:\n' - '\n- torrent_name: uses torrent dir name (original behavior).\n' - '\n- query: uses Plex queries (Beets/Lidarr friendly).\n' - ) -) -@click.pass_context -def convert_collection_from_bookmarks(ctx, site: str, fetch_mode: str): - """ - Create/Update a Plex collection based on your site bookmarks. - """ - album_fetch_mode_enum = map_fetch_mode(fetch_mode) - - # --- Dependency Setup --- - local_database = ctx.obj.get('db') - if not local_database: - click.echo("Error: Database not initialized.", err=True) - ctx.exit(1) - - plex_manager, gazelle_api = None, None - try: - plex_manager = PlexManager(db=local_database) - gazelle_api = GazelleAPI(site) # Create GazelleAPI based on site - except Exception as e: # pylint: disable=W0718 - logger.error("Failed to initialize dependencies: %s", e, exc_info=True) - click.echo(f"Error: Failed to initialize dependencies - {e}", err=True) - ctx.exit(1) - - # --- Service Instantiation and Execution --- - processor = CollectionProcessingService(local_database, plex_manager, gazelle_api) - - try: - # Call the specific bookmark processing method - processor.process_bookmarks( - album_fetch_mode=album_fetch_mode_enum, - echo_func=click.echo, - confirm_func=click.confirm - ) - except Exception as exc: # pylint: disable=W0718 - logger.exception( - 'Failed to create collection from bookmarks on site %s: %s', - site.upper(), exc - ) - click.echo( - f'Failed to create collection from bookmarks on site {site.upper()}: {exc}', - err=True - ) - ctx.exit(1) - - click.echo("Bookmark processing finished.") - - -# db -@cli.group() -def db(): - """Manage database.""" - - -# db location -@db.command('location') -@click.pass_context -def db_location(ctx): - """Returns the location to the database.""" - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - db_path = local_database.db_path - if os.path.exists(db_path): - click.echo(f"Database exists at: {db_path}") - else: - click.echo("Database file does not exist.") - - -# db albums -@db.group('albums') -def db_albums(): - """Manage albums inside database.""" - - -# db albums reset -@db_albums.command('reset') -@click.pass_context -def db_albums_reset(ctx): - """Resets albums table from database.""" - if click.confirm('Are you sure you want to reset the db?'): - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - local_database.reset_albums() - click.echo("Albums table has been reset successfully.") - except Exception as exc: # pylint: disable=W0718 - click.echo(f"An error occurred while resetting the album table: {exc}") - - -@db_albums.command('update') -@click.pass_context -def db_albums_update(ctx): - """Updates albums table from Plex.""" - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - plex_manager = PlexManager(db=local_database) - plex_manager.populate_album_table() - click.echo("Albums table has been updated successfully.") - except Exception as exc: # pylint: disable=W0703 - click.echo(f"An error occurred while updating the album table: {exc}") - - -# db collections -@db.group('collections') -def db_collections(): - """Manage albums inside database.""" - - -# db collections reset -@db_collections.command('reset') -@click.pass_context -def db_collections_reset(ctx): - """Resets collections table from database.""" - if click.confirm('Are you sure you want to reset the collection db?'): - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - local_database.reset_collage_collections() - click.echo("Collage collection db has been reset successfully.") - except Exception as exc: # pylint: disable=W0718 - logger.exception('Failed to reset collage collection db: %s', exc) - click.echo( - f"An error occurred while resetting the collage collection db: {exc}") - - -# db bookmarks -@db.group('bookmarks') -def db_bookmarks(): - """Manage bookmarks inside database.""" - - -# db bookmarks reset -@db_bookmarks.command('reset') -@click.pass_context -def db_bookmarks_reset(ctx): - """Resets bookmarks table from database.""" - if click.confirm('Are you sure you want to reset the collection bookmarks db?'): - try: - local_database = ctx.obj.get('db', None) - local_database: LocalDatabase - local_database.reset_bookmark_collections() - click.echo("Collection bookmarks db has been reset successfully.") - except Exception as exc: # pylint: disable=W0718 - logger.exception('Failed to reset collection bookmarks db: %s', exc) - click.echo(f"An error occurred while resetting the collection bookmarks db: {exc}") - - -def update_collections_from_collages(local_database: LocalDatabase, - collage_list: List[Collection], - plex_manager: PlexManager, - fetch_bookmarks=False, - fetch_mode: AlbumFetchMode = AlbumFetchMode.TORRENT_NAME): - """ - Forces the update of each collage (force_update=True) - """ - - for collage in collage_list: - logger.info('Updating collection for collage "%s"...', collage.name) - gazelle_api = GazelleAPI(collage.site) - - if AlbumFetchMode.TORRENT_NAME == fetch_mode: - collection_creator = TorrentNameCollectionCreatorUseCase(local_database, - plex_manager, - gazelle_api) - result = collection_creator.execute( - collage_id=collage.external_id, - site=collage.site, - fetch_bookmarks=fetch_bookmarks, - force_update=True - ) - else: - collection_creator = QuerySyncCollectionUseCase(local_database, - plex_manager, - gazelle_api) - result = collection_creator.execute( - collage_id=collage.external_id, - site=collage.site, - fetch_bookmarks=fetch_bookmarks, - force_update=True - ) - - if result.response_status is None: - logger.info('No valid data found for collage "%s".', collage.name) - else: - logger.info('Collection for collage "%s" created/updated successfully with %s entries.', - collage.name, len(result.albums)) - - -# gui -@cli.command() -@click.option('--host', '-h', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)') -@click.option('--port', '-p', default=8000, type=int, help='Port to bind to (default: 8000)') -@click.option('--debug', is_flag=True, help='Run in debug mode with auto-reloading.') -def gui(host, port, debug): - """Launches the web interface using the Gunicorn production server.""" - - # Build the bind address for Gunicorn - bind_address = f"{host}:{port}" - - click.echo(f"🚀 Launching Gunicorn server at http://{bind_address}") - - # Create the list of arguments for the Gunicorn command - command = [ - 'gunicorn', - '--worker-class', 'eventlet', - '-w', '1', - '--bind', bind_address, - ] - - # If the --debug flag is passed, add Gunicorn's reload option - if debug: - click.echo(" -> Debug mode on (auto-reload enabled).") - command.append('--reload') - - command.append('wsgi:app') - - try: - # Execute the Gunicorn command - subprocess.run(command, check=True) - except FileNotFoundError: - click.echo( - "Error: 'gunicorn' command not found.", - err=True - ) - click.echo( - "Please make sure you have installed it with: pip install gunicorn eventlet", - err=True - ) - sys.exit(1) +# Add all command groups +cli.add_command(config) +cli.add_command(collages) +cli.add_command(bookmarks) +cli.add_command(db) +cli.add_command(gui) +cli.add_command(extras) @cli.result_callback() @@ -499,13 +39,6 @@ def finalize_cli(ctx, _result, *_args, **_kwargs): local_database.close() -def map_fetch_mode(fetch_mode) -> AlbumFetchMode: - """Map the fetch mode string to an AlbumFetchMode enum.""" - if fetch_mode == 'query': - return AlbumFetchMode.QUERY - return AlbumFetchMode.TORRENT_NAME - - def main(): """Actual entry point for the CLI when installed.""" if 'gui' not in sys.argv: diff --git a/red_plex/infrastructure/cli/commands/bookmarks.py b/red_plex/infrastructure/cli/commands/bookmarks.py new file mode 100644 index 0000000..3781c3d --- /dev/null +++ b/red_plex/infrastructure/cli/commands/bookmarks.py @@ -0,0 +1,124 @@ +"""Bookmark management CLI commands.""" + +import click + +from red_plex.infrastructure.cli.utils import update_collections_from_collages, map_fetch_mode +from red_plex.infrastructure.db.local_database import LocalDatabase +from red_plex.infrastructure.logger.logger import logger +from red_plex.infrastructure.plex.plex_manager import PlexManager +from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI +from red_plex.infrastructure.service.collection_processor import CollectionProcessingService + + +@click.group() +def bookmarks(): + """Possible operations with your site bookmarks.""" + + +@bookmarks.command('update') +@click.pass_context +@click.option( + '--fetch-mode', '-fm', + type=click.Choice(['torrent_name', 'query']), + default='torrent_name', + show_default=True, + help=( + '(Optional) Album lookup strategy:\n' + '\n- torrent_name: uses torrent dir name to search in Plex, ' + 'if you don\'t use Beets/Lidarr \n' + '\n- query: uses queries to Plex instead of searching by path name ' + '(if you use Beets/Lidarr)\n' + ) +) +# pylint: disable=R0801 +def update_bookmarks_collection(ctx, fetch_mode: str): + """Synchronize all stored bookmarks with their source collages.""" + # Import here to avoid circular imports with cli.py + + fetch_mode = map_fetch_mode(fetch_mode) + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + all_bookmarks = local_database.get_all_bookmark_collections() + + if not all_bookmarks: + click.echo("No bookmarks found in the db.") + return + + plex_manager = PlexManager(local_database) + if not plex_manager: + return + plex_manager.populate_album_table() + + update_collections_from_collages( + local_database, + all_bookmarks, + plex_manager, + fetch_bookmarks=True) + + except Exception as exc: # pylint: disable=W0718 + logger.exception('Failed to update stored bookmarks: %s', exc) + click.echo(f"An error occurred while updating stored bookmarks: {exc}") + + +@bookmarks.command('convert') +@click.option('--site', '-s', + type=click.Choice(['red', 'ops'], case_sensitive=False), + required=True, + help='Specify the site: red (Redacted) or ops (Orpheus).') +@click.option( + '--fetch-mode', '-fm', + type=click.Choice(['torrent_name', 'query'], case_sensitive=False), + default='torrent_name', + show_default=True, + help=( + '(Optional) Album lookup strategy:\n' + '\n- torrent_name: uses torrent dir name (original behavior).\n' + '\n- query: uses Plex queries (Beets/Lidarr friendly).\n' + ) +) +@click.pass_context +# pylint: disable=R0801 +def convert_collection_from_bookmarks(ctx, site: str, fetch_mode: str): + """ + Create/Update a Plex collection based on your site bookmarks. + """ + album_fetch_mode_enum = map_fetch_mode(fetch_mode) + + # --- Dependency Setup --- + local_database = ctx.obj.get('db') + if not local_database: + click.echo("Error: Database not initialized.", err=True) + ctx.exit(1) + + plex_manager, gazelle_api = None, None + try: + plex_manager = PlexManager(db=local_database) + gazelle_api = GazelleAPI(site) # Create GazelleAPI based on site + except Exception as e: # pylint: disable=W0718 + logger.error("Failed to initialize dependencies: %s", e, exc_info=True) + click.echo(f"Error: Failed to initialize dependencies - {e}", err=True) + ctx.exit(1) + + # --- Service Instantiation and Execution --- + processor = CollectionProcessingService(local_database, plex_manager, gazelle_api) + + try: + # Call the specific bookmark processing method + processor.process_bookmarks( + album_fetch_mode=album_fetch_mode_enum, + echo_func=click.echo, + confirm_func=click.confirm + ) + except Exception as exc: # pylint: disable=W0718 + logger.exception( + 'Failed to create collection from bookmarks on site %s: %s', + site.upper(), exc + ) + click.echo( + f'Failed to create collection from bookmarks on site {site.upper()}: {exc}', + err=True + ) + ctx.exit(1) + + click.echo("Bookmark processing finished.") diff --git a/red_plex/infrastructure/cli/commands/collages.py b/red_plex/infrastructure/cli/commands/collages.py new file mode 100644 index 0000000..4265e0e --- /dev/null +++ b/red_plex/infrastructure/cli/commands/collages.py @@ -0,0 +1,173 @@ +"""Collage management CLI commands.""" + +import click + +from red_plex.infrastructure.cli.utils import (update_collections_from_collages, + map_fetch_mode, + push_collections_to_upstream) +from red_plex.infrastructure.db.local_database import LocalDatabase +from red_plex.infrastructure.logger.logger import logger +from red_plex.infrastructure.plex.plex_manager import PlexManager +from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI +from red_plex.infrastructure.service.collection_processor import CollectionProcessingService + + +@click.group('collages') +def collages(): + """Possible operations with site collages.""" + + +@collages.command('update') +@click.pass_context +@click.argument('collage_ids', nargs=-1) +@click.option( + '--fetch-mode', '-fm', + type=click.Choice(['torrent_name', 'query']), + default='torrent_name', + show_default=True, + help=( + '(Optional) Album lookup strategy:\n' + '\n- torrent_name: uses torrent dir name to search in Plex, ' + 'if you don\'t use Beets/Lidarr \n' + '\n- query: uses queries to Plex instead of searching by path name ' + '(if you use Beets/Lidarr)\n' + ) +) +@click.option( + '--push', '--update-upstream', + is_flag=True, + default=False, + help='Push local collection changes back to upstream collages on the site' +) +# pylint: disable=R0912 +def update_collages(ctx, collage_ids, fetch_mode: str, push: bool): + """ + Synchronize stored collections with their source collages. + + If COLLAGE_IDS are provided, only those collages will be processed. + If no COLLAGE_IDS are provided, all stored collages will be processed. + """ + # Import here to avoid circular imports with cli.py + + fetch_mode = map_fetch_mode(fetch_mode) + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + + if collage_ids: + # Filter to only the specified collage IDs + all_collages = local_database.get_all_collage_collections() + collage_ids_set = set(collage_ids) + filtered_collages = [c for c in all_collages if c.external_id in collage_ids_set] + + if not filtered_collages: + click.echo(f"No collages found in the database with IDs: {', '.join(collage_ids)}") + return + + # Check if any requested IDs were not found + found_ids = {c.external_id for c in filtered_collages} + missing_ids = collage_ids_set - found_ids + if missing_ids: + click.echo(f"Warning: Collage IDs not found in database: {', '.join(missing_ids)}") + + target_collages = filtered_collages + else: + # Process all collages + target_collages = local_database.get_all_collage_collections() + + if not target_collages: + click.echo("No collages found to process.") + return + + # Initialize PlexManager once, populate its db once + plex_manager = PlexManager(local_database) + if not plex_manager: + return + plex_manager.populate_album_table() + + if push: + # Push mode: sync local collections to upstream + logger.info("Pushing local collection updates to upstream collages...") + if collage_ids: + logger.info("Processing specific collages: %s", + ', '.join(c.name for c in target_collages)) + success = push_collections_to_upstream( + local_database=local_database, + collage_list=target_collages, + plex_manager=plex_manager + ) + if success: + logger.info("All collections successfully synced to upstream.") + else: + logger.info("Some collections failed to sync. Check logs for details.") + else: + if collage_ids: + click.echo(f"Updating specific collages: " + f"{', '.join(c.name for c in target_collages)}") + update_collections_from_collages( + local_database=local_database, + collage_list=target_collages, + plex_manager=plex_manager, + fetch_bookmarks=False, + fetch_mode=fetch_mode) + + except Exception as exc: # pylint: disable=W0718 + logger.exception('Failed to update stored collections: %s', exc) + click.echo(f"An error occurred while updating stored collections: {exc}") + + +@collages.command('convert') +@click.argument('collage_ids', nargs=-1) +@click.option('--site', '-s', + type=click.Choice(['red', 'ops']), + required=True, + help='Specify the site: red (Redacted) or ops (Orpheus).') +@click.option( + '--fetch-mode', '-fm', + type=click.Choice(['torrent_name', 'query'], case_sensitive=False), # Added case_sensitive + default='torrent_name', + show_default=True, + help=( + '(Optional) Album lookup strategy:\n' + '\n- torrent_name: uses torrent dir name (original behavior).\n' + '\n- query: uses Plex queries (Beets/Lidarr friendly).\n' + ) +) +@click.pass_context +def convert_collages(ctx, collage_ids, site, fetch_mode): + """ + Create/Update Plex collections from given COLLAGE_IDS. + """ + if not collage_ids: + click.echo("Please provide at least one COLLAGE_ID.") + ctx.exit(1) # Exit with an error code + + album_fetch_mode_enum = map_fetch_mode(fetch_mode) + + # --- Dependency Setup --- + local_database = ctx.obj.get('db') + if not local_database: + click.echo("Error: Database not initialized.", err=True) + ctx.exit(1) + + plex_manager, gazelle_api = None, None + try: + plex_manager = PlexManager(db=local_database) + gazelle_api = GazelleAPI(site) + except Exception as e: # pylint: disable=W0718 + logger.error("Failed to initialize dependencies: %s", e, exc_info=True) + click.echo(f"Error: Failed to initialize dependencies - {e}", err=True) + ctx.exit(1) + + # --- Service Instantiation and Execution --- + processor = CollectionProcessingService(local_database, plex_manager, gazelle_api) + + # Call the service, passing the necessary functions from click + processor.process_collages( + collage_ids=collage_ids, + album_fetch_mode=album_fetch_mode_enum, + echo_func=click.echo, + confirm_func=click.confirm # Pass the actual click.confirm + ) + + click.echo("Processing finished.") diff --git a/red_plex/infrastructure/cli/commands/config.py b/red_plex/infrastructure/cli/commands/config.py new file mode 100644 index 0000000..7d7cda8 --- /dev/null +++ b/red_plex/infrastructure/cli/commands/config.py @@ -0,0 +1,61 @@ +"""Configuration management CLI commands.""" + +import os +import subprocess + +import click +import yaml + +from red_plex.infrastructure.config.config import ( + CONFIG_FILE_PATH, + load_config, + save_config, + ensure_config_exists +) +from red_plex.infrastructure.config.models import Configuration +from red_plex.infrastructure.logger.logger import logger + + +@click.group() +def config(): + """View or edit configuration settings.""" + + +@config.command('show') +def show_config(): + """Display the current configuration.""" + config_data = load_config() + path_with_config = ( + f"Configuration path: {CONFIG_FILE_PATH}\n\n" + + yaml.dump(config_data.to_dict(), default_flow_style=False) + ) + click.echo(path_with_config) + + +@config.command('edit') +def edit_config(): + """Open the configuration file in the default editor.""" + # Ensure the configuration file exists + ensure_config_exists() + + # Default to 'nano' if EDITOR is not set + editor = os.environ.get('EDITOR', 'notepad' if os.name == 'nt' else 'nano') + click.echo(f"Opening config file at {CONFIG_FILE_PATH}...") + try: + subprocess.call([editor, CONFIG_FILE_PATH]) + except FileNotFoundError: + message = f"Editor '{editor}' not found. \ + Please set the EDITOR environment variable to a valid editor." + logger.error(message) + click.echo(message) + except Exception as exc: # pylint: disable=W0718 + logger.exception('Failed to open editor: %s', exc) + click.echo(f"An error occurred while opening the editor: {exc}") + + +@config.command('reset') +def reset_config(): + """Reset the configuration to default values.""" + if click.confirm('Are you sure you want to reset the configuration to default values?'): + save_config(Configuration.default()) + click.echo(f"Configuration reset to default values at {CONFIG_FILE_PATH}") diff --git a/red_plex/infrastructure/cli/commands/db.py b/red_plex/infrastructure/cli/commands/db.py new file mode 100644 index 0000000..511304d --- /dev/null +++ b/red_plex/infrastructure/cli/commands/db.py @@ -0,0 +1,159 @@ +"""Database management CLI commands.""" + +import os + +import click + +from red_plex.infrastructure.cli.utils import get_database_from_context, create_plex_manager +from red_plex.infrastructure.db.local_database import LocalDatabase +from red_plex.infrastructure.logger.logger import logger +from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI +from red_plex.use_case.site_tags.site_tags_use_case import SiteTagsUseCase + + +@click.group() +def db(): + """Manage database.""" + + +@db.command('location') +@click.pass_context +def db_location(ctx): + """Returns the location to the database.""" + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + db_path = local_database.db_path + if os.path.exists(db_path): + click.echo(f"Database exists at: {db_path}") + else: + click.echo("Database file does not exist.") + + +@db.group('albums') +def db_albums(): + """Manage albums inside database.""" + + +@db_albums.command('reset') +@click.pass_context +def db_albums_reset(ctx): + """Resets albums table from database.""" + if click.confirm('Are you sure you want to reset the db?'): + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + local_database.reset_albums() + click.echo("Albums table has been reset successfully.") + except Exception as exc: # pylint: disable=W0718 + click.echo(f"An error occurred while resetting the album table: {exc}") + + +@db_albums.command('update') +@click.pass_context +def db_albums_update(ctx): + """Updates albums table from Plex.""" + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + plex_manager = create_plex_manager(local_database) + plex_manager.populate_album_table() + click.echo("Albums table has been updated successfully.") + except Exception as exc: # pylint: disable=W0703 + click.echo(f"An error occurred while updating the album table: {exc}") + + +@db.group('collections') +def db_collections(): + """Manage albums inside database.""" + + +@db_collections.command('reset') +@click.pass_context +def db_collections_reset(ctx): + """Resets collections table from database.""" + if click.confirm('Are you sure you want to reset the collection db?'): + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + local_database.reset_collage_collections() + click.echo("Collage collection db has been reset successfully.") + except Exception as exc: # pylint: disable=W0718 + logger.exception('Failed to reset collage collection db: %s', exc) + click.echo( + f"An error occurred while resetting the collage collection db: {exc}") + + +@db.group('bookmarks') +def db_bookmarks(): + """Manage bookmarks inside database.""" + + +@db_bookmarks.command('reset') +@click.pass_context +def db_bookmarks_reset(ctx): + """Resets bookmarks table from database.""" + if click.confirm('Are you sure you want to reset the collection bookmarks db?'): + try: + local_database = ctx.obj.get('db', None) + local_database: LocalDatabase + local_database.reset_bookmark_collections() + click.echo("Collection bookmarks db has been reset successfully.") + except Exception as exc: # pylint: disable=W0718 + logger.exception('Failed to reset collection bookmarks db: %s', exc) + click.echo(f"An error occurred while resetting the collection bookmarks db: {exc}") + + +@db.group('remote-mappings') +def db_remote_mappings(): + """Manage core remote mappings between Plex items and site group IDs.""" + + +@db_remote_mappings.command('scan') +@click.option('--site', '-s', + type=click.Choice(['red', 'ops'], case_sensitive=False), + required=True, + help='Specify the site: red (Redacted) or ops (Orpheus).') +@click.option('--always-skip', is_flag=True, help='Always skip albums with multiple matches.') +@click.pass_context +def scan_albums(ctx, site: str, always_skip: bool): + """ + Scan albums and create remote mappings by searching filenames on the site. + This is an incremental process - only unscanned albums will be processed. + """ + try: + # Get dependencies from context using shared utilities + local_database = get_database_from_context(ctx) + plex_manager = create_plex_manager(local_database) + gazelle_api = GazelleAPI(site) + + # Ensure albums table is populated + click.echo("Updating album database from Plex...") + plex_manager.populate_album_table() + + # Create use case and execute scan + site_tags_use_case = SiteTagsUseCase(local_database, plex_manager, gazelle_api) + site_tags_use_case.scan_albums_for_site_tags( + echo_func=click.echo, + confirm_func=click.confirm, + always_skip=always_skip + ) + + except Exception as e: # pylint: disable=W0703 + logger.exception("Error during album scan: %s", e) + click.echo(f"Error during album scan: {e}", err=True) + ctx.exit(1) + + +@db_remote_mappings.command('reset') +@click.pass_context +def reset_remote_mappings(ctx): + """Reset remote mappings. Use with caution!""" + if click.confirm('Are you sure you want to reset remote mappings?'): + try: + local_database = get_database_from_context(ctx) + local_database.reset_tag_mappings() + click.echo("Remote mappings have been reset successfully.") + except Exception as e: # pylint: disable=W0703 + logger.exception("Error resetting remote mappings: %s", e) + click.echo(f"Error resetting remote mappings: {e}", err=True) + ctx.exit(1) diff --git a/red_plex/infrastructure/cli/commands/extras.py b/red_plex/infrastructure/cli/commands/extras.py new file mode 100644 index 0000000..ab83e7c --- /dev/null +++ b/red_plex/infrastructure/cli/commands/extras.py @@ -0,0 +1,61 @@ +"""Extra CLI commands for advanced features.""" + +import click + +from red_plex.infrastructure.logger.logger import logger +from red_plex.use_case.site_tags.site_tags_use_case import SiteTagsUseCase +from red_plex.infrastructure.cli.utils import get_database_from_context, create_plex_manager + + +@click.group() +def extras(): + """Extra features and advanced functionality.""" + +@extras.group('site-tags') +def site_tags(): + """Site tags functionality for creating collections from tagged albums.""" + + +@site_tags.command('convert') +@click.option('--tags', '-t', + required=True, + help='Comma-separated list of tags to filter by.') +@click.option('--collection-name', '-n', + required=True, + help='Name for the Plex collection to create/update.') +@click.pass_context +def convert_tags_to_collection(ctx, tags: str, collection_name: str): + """ + Create a Plex collection from albums matching the specified tags. + """ + try: + # Parse tags + tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()] + if not tag_list: + click.echo("Error: No valid tags provided.", err=True) + ctx.exit(1) + + # Get dependencies from context using shared utility + local_database = get_database_from_context(ctx) + plex_manager = create_plex_manager(local_database) + + # Create the use case and execute conversion + # No need of gazelle_api here since we're using the local database + site_tags_use_case = SiteTagsUseCase(local_database=local_database, + plex_manager=plex_manager) + success = site_tags_use_case.create_collection_from_tags( + tags=tag_list, + collection_name=collection_name, + echo_func=click.echo + ) + + if success: + click.echo("Collection creation completed successfully.") + else: + click.echo("Collection creation failed.", err=True) + ctx.exit(1) + + except Exception as e: # pylint: disable=W0703 + logger.exception("Error during collection creation: %s", e) + click.echo(f"Error during collection creation: {e}", err=True) + ctx.exit(1) diff --git a/red_plex/infrastructure/cli/commands/gui.py b/red_plex/infrastructure/cli/commands/gui.py new file mode 100644 index 0000000..676e43d --- /dev/null +++ b/red_plex/infrastructure/cli/commands/gui.py @@ -0,0 +1,42 @@ +"""GUI launcher CLI command.""" +import subprocess +import sys + +import click + +from red_plex.infrastructure.logger.logger import logger + + +@click.command() +@click.option('--host', '-h', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)') +@click.option('--port', '-p', default=8000, type=int, help='Port to bind to (default: 8000)') +@click.option('--debug', is_flag=True, help='Run in debug mode with auto-reloading.') +def gui(host, port, debug): + """Launches the web interface using the Gunicorn production server.""" + + # Build the bind address for Gunicorn + bind_address = f"{host}:{port}" + + click.echo(f"🚀 Launching Gunicorn server at http://{bind_address}") + + # Create the list of arguments for the Gunicorn command + command = [ + 'gunicorn', + '--worker-class', 'eventlet', + '-w', '1', + '--bind', bind_address, + ] + + # If the --debug flag is passed, add Gunicorn's reload option + if debug: + logger.info("Debug mode enabled: auto-reloading will be active.") + command.append('--reload') + + command.append('wsgi:app') + + try: + # Execute the Gunicorn command + subprocess.run(command, check=True) + except FileNotFoundError: + logger.error("Gunicorn is not installed. Please install it with: pip install gunicorn") + sys.exit(1) diff --git a/red_plex/infrastructure/cli/utils.py b/red_plex/infrastructure/cli/utils.py new file mode 100644 index 0000000..6a7728e --- /dev/null +++ b/red_plex/infrastructure/cli/utils.py @@ -0,0 +1,195 @@ +"""Shared utilities for CLI commands.""" +from typing import List + +import click + +from red_plex.domain.models import Collection +from red_plex.infrastructure.db.local_database import LocalDatabase +from red_plex.infrastructure.logger.logger import logger +from red_plex.infrastructure.plex.plex_manager import PlexManager +from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI +from red_plex.use_case.create_collection.album_fetch_mode import AlbumFetchMode +from red_plex.use_case.create_collection.query.query_sync_collection import ( + QuerySyncCollectionUseCase) +from red_plex.use_case.create_collection.torrent_name.torrent_name_sync_collection import ( + TorrentNameCollectionCreatorUseCase) +from red_plex.use_case.upstream_sync.upstream_sync_use_case import UpstreamSyncUseCase + + +def get_database_from_context(ctx) -> LocalDatabase: + """ + Get database instance from Click context with error handling. + + Args: + ctx: Click context object + + Returns: + LocalDatabase instance + + Raises: + SystemExit: If database is not initialized + """ + local_database = ctx.obj.get('db') + if not local_database: + click.echo("Error: Database not initialized.", err=True) + ctx.exit(1) + return local_database + + +def create_plex_manager(local_database: LocalDatabase) -> PlexManager: + """ + Create a PlexManager instance. + + Args: + local_database: LocalDatabase instance + + Returns: + PlexManager instance + """ + return PlexManager(db=local_database) + + +def map_fetch_mode(fetch_mode: str) -> AlbumFetchMode: + """Map the fetch mode string to an AlbumFetchMode enum.""" + if fetch_mode == 'query': + return AlbumFetchMode.QUERY + return AlbumFetchMode.TORRENT_NAME + + +def update_collections_from_collages(local_database: LocalDatabase, + collage_list: List[Collection], + plex_manager: PlexManager, + fetch_bookmarks=False, + fetch_mode: AlbumFetchMode = AlbumFetchMode.TORRENT_NAME): + """ + Forces the update of each collage (force_update=True) + """ + + for collage in collage_list: + logger.info('Updating collection for collage "%s"...', collage.name) + gazelle_api = GazelleAPI(collage.site) + + if AlbumFetchMode.TORRENT_NAME == fetch_mode: + collection_creator = TorrentNameCollectionCreatorUseCase(local_database, + plex_manager, + gazelle_api) + result = collection_creator.execute( + collage_id=collage.external_id, + site=collage.site, + fetch_bookmarks=fetch_bookmarks, + force_update=True + ) + else: + collection_creator = QuerySyncCollectionUseCase(local_database, + plex_manager, + gazelle_api) + result = collection_creator.execute( + collage_id=collage.external_id, + site=collage.site, + fetch_bookmarks=fetch_bookmarks, + force_update=True + ) + + if result.response_status is None: + logger.info('No valid data found for collage "%s".', collage.name) + else: + logger.info('Collection for collage "%s" created/updated successfully with %s entries.', + collage.name, len(result.albums)) + + +# pylint: disable=R0914,W0718,R0912,R0915 +def push_collections_to_upstream(local_database: LocalDatabase, + collage_list: List[Collection], + plex_manager: PlexManager) -> bool: + """ + Push local collection updates back to upstream collages. + + Args: + local_database: Database instance + collage_list: List of collections to sync upstream + plex_manager: Plex manager instance + + Returns: + True if all syncs were successful, False otherwise + """ + # Initialize upstream sync use case + upstream_sync = UpstreamSyncUseCase(local_database, plex_manager) + + # Get preview of what would be synced + preview_response = upstream_sync.get_sync_preview(collage_list) + + if not preview_response.success: + logger.error('Failed to get sync preview: %s', preview_response.error_message) + return False + + if not preview_response.preview_data: + logger.info('No collages need syncing - all are up to date') + return True + + # Show confirmation dialog for each collage + selected_albums = {} + + for collage_preview in preview_response.preview_data: + click.echo( + f'\nCollage "{collage_preview.collage_name}" ({collage_preview.external_id}) ' + f'will have {len(collage_preview.albums_to_add)} new items added:') + + # Show albums with numbers + for i, album in enumerate(collage_preview.albums_to_add, 1): + click.echo(f' {i}. {album.display_name}') + + # Ask for album selection + click.echo('\nSpecify which albums to add (e.g., "1,3,4" for albums 1, 3, and 4)') + click.echo('Leave empty to add all albums, or "skip" to skip this collage:') + + selection = click.prompt('Albums to add', type=str, default='', show_default=False) + selection = selection.strip() + + if selection.lower() == 'skip': + click.echo('Skipping this collage.') + continue + + # Parse selection + selected_group_ids = [album.group_id + for album in collage_preview.albums_to_add] # Default to all + if selection: + try: + # Parse comma-separated numbers + selected_indices = [int(x.strip()) - 1 for x in selection.split(',') + if x.strip().isdigit()] + + # Validate indices + valid_indices = [i for i in selected_indices + if 0 <= i < len(collage_preview.albums_to_add)] + + if not valid_indices: + click.echo('No valid album numbers specified. Adding all albums.') + else: + selected_group_ids = [collage_preview.albums_to_add[i].group_id + for i in valid_indices] + click.echo(f'Selected {len(selected_group_ids)} album(s) to add.') + + except ValueError: + click.echo('Invalid selection format. Adding all albums.') + + selected_albums[collage_preview.collage_id] = selected_group_ids + + if not selected_albums: + logger.info('No albums selected for sync') + return True + + # Filter collages to only sync those with selected albums + collages_to_sync = [c for c in collage_list if c.id in selected_albums] + + # Perform the sync + sync_response = upstream_sync.sync_collections_upstream(collages_to_sync, selected_albums) + + # Log results + logger.info('Upstream sync completed: %d/%d collections synced successfully', + sync_response.synced_collages, sync_response.total_collages) + + if sync_response.errors: + for error in sync_response.errors: + logger.error(error) + + return sync_response.success diff --git a/red_plex/infrastructure/constants/constants.py b/red_plex/infrastructure/constants/constants.py new file mode 100644 index 0000000..f2a819a --- /dev/null +++ b/red_plex/infrastructure/constants/constants.py @@ -0,0 +1,12 @@ +""" This module stores constants""" + +ALBUM_TAGS = [ + "EP", "E.P", "E.P.", "Single", "Album", "Soundtrack", "Anthology", + "Compilation", "Live Album", "Remix", "Bootleg", "Interview", + "Mixtape", "Demo", "Concert Recording", "DJ Mix", "Original Mix", + "Remastered", "Deluxe Edition", "Limited Edition", "Bonus Track", + "Instrumental", "Acapella" +] + +VARIOUS_ARTISTS_TAGS = [ + "various artists", "va", "v.a", "v/a", "various"] diff --git a/red_plex/infrastructure/db/albums.py b/red_plex/infrastructure/db/albums.py new file mode 100644 index 0000000..6078fc6 --- /dev/null +++ b/red_plex/infrastructure/db/albums.py @@ -0,0 +1,215 @@ +"""Album database operations.""" + +from collections import defaultdict +from datetime import datetime +from typing import List, Optional + +from red_plex.domain.models import Album +from red_plex.infrastructure.logger.logger import logger + + +class AlbumDatabaseManager: + """Manages album-related database operations.""" + + def __init__(self, conn): + self.conn = conn + + def insert_or_update_album(self, album: Album) -> None: + """ + Insert or update an album, including its artists, in the database. + Uses a transaction to ensure atomicity. + """ + logger.debug("Inserting/updating album with ID %s", album.id) + with self.conn: + # 1. Insert or replace the album itself + self.conn.execute( + """ + INSERT OR REPLACE INTO albums(album_id, name, path, added_at) + VALUES (?, ?, ?, ?) + """, + (album.id, + album.name, + album.path, + album.added_at.isoformat() if album.added_at else None) + ) + + # 2. Delete old artist associations for this album + self.conn.execute("DELETE FROM album_artists WHERE album_id = ?", (album.id,)) + + # 3. Insert artists and their new associations + if album.artists: + # Filter out None and empty artist names + valid_artists = [artist + for artist in album.artists + if artist is not None and artist.strip()] + + if valid_artists: + # Insert artists if they don't exist (IGNORE on conflict) + self.conn.executemany( + "INSERT OR IGNORE INTO artists(artist_name) VALUES (?)", + [(artist,) for artist in valid_artists] + ) + + # Get artist IDs + cur = self.conn.cursor() + artist_ids = dict(cur.execute( + f"SELECT artist_name, artist_id FROM artists WHERE artist_name IN " + f"({','.join('?' * len(valid_artists))})", + valid_artists + )) + + # Insert into linking table + self.conn.executemany( + "INSERT INTO album_artists(album_id, artist_id) VALUES (?, ?)", + [(album.id, artist_ids[name]) for name in valid_artists] + ) + + def insert_albums_bulk(self, albums: List[Album]) -> None: + """ + Inserts or updates a list of albums in bulk using a single transaction. + Handles albums, artists, and their relationships efficiently. + """ + logger.debug("Inserting/updating %d albums in bulk.", len(albums)) + + album_rows = [] + all_artists = set() + album_id_to_artists = {} + + for album in albums: + album_rows.append(( + album.id, + album.name, + album.path, + album.added_at.isoformat() if album.added_at else None + )) + if album.artists: + # Filter out None and empty artist names + valid_artists = [artist + for artist in album.artists + if artist is not None and artist.strip()] + if valid_artists: + all_artists.update(valid_artists) + album_id_to_artists[album.id] = valid_artists + + with self.conn: + # 1. Insert/update all albums + self.conn.executemany( + "INSERT OR REPLACE INTO albums(album_id, name, path, added_at) VALUES (?, ?, ?, ?)", + album_rows + ) + + album_ids = [album.id for album in albums] + + # 2. Delete all existing artist links for the albums being updated + self.conn.execute( + f"DELETE FROM album_artists WHERE album_id IN ({','.join('?' * len(album_ids))})", + album_ids + ) + + # 3. Insert all new unique artists + if all_artists: + self.conn.executemany( + "INSERT OR IGNORE INTO artists(artist_name) VALUES (?)", + [(artist,) for artist in all_artists] + ) + + # 4. Get all required artist IDs in one query + cur = self.conn.cursor() + artist_name_to_id = dict(cur.execute( + f"SELECT artist_name, artist_id FROM artists WHERE artist_name IN " + f"({','.join('?' * len(all_artists))})", + list(all_artists) + )) + + # 5. Prepare and insert all album-artist links + album_artist_links = [] + for album_id, artist_names in album_id_to_artists.items(): + for name in artist_names: + if name in artist_name_to_id: + album_artist_links.append((album_id, artist_name_to_id[name])) + + self.conn.executemany( + "INSERT INTO album_artists(album_id, artist_id) VALUES (?, ?)", + album_artist_links + ) + + def get_album(self, album_id: str) -> Optional[Album]: + """ + Retrieve a single album by its ID, including its list of artists. + """ + cur = self.conn.cursor() + + # Get album details + cur.execute("SELECT album_id, name, path, added_at " + "FROM albums WHERE album_id = ?", (album_id,)) + row = cur.fetchone() + if not row: + return None + + _id, _name, _path, _added_at_str = row + added_at = datetime.fromisoformat(_added_at_str) if _added_at_str else None + + # Get associated artists + cur.execute(""" + SELECT ar.artist_name + FROM artists ar + JOIN album_artists aa ON ar.artist_id = aa.artist_id + WHERE aa.album_id = ? + """, (album_id,)) + artists = [row[0] for row in cur.fetchall()] + + return Album(id=_id, name=_name, path=_path, added_at=added_at, artists=artists) + + def get_all_albums(self) -> List[Album]: + """ + Retrieve all albums from the database, including their lists of artists. + This is done efficiently to avoid the N+1 query problem. + """ + cur = self.conn.cursor() + + # 1. Fetch all albums + cur.execute("SELECT album_id, name, path, added_at FROM albums") + album_rows = cur.fetchall() + if not album_rows: + return [] + + # 2. Fetch all artist relationships in a single query + artist_map = defaultdict(list) + cur.execute(""" + SELECT aa.album_id, ar.artist_name + FROM album_artists aa + JOIN artists ar ON aa.artist_id = ar.artist_id + """) + for album_id, artist_name in cur.fetchall(): + artist_map[album_id].append(artist_name) + + # 3. Construct Album objects + albums = [] + for _id, _name, _path, _added_at_str in album_rows: + added_at = datetime.fromisoformat(_added_at_str) if _added_at_str else None + albums.append( + Album(id=_id, name=_name, path=_path, added_at=added_at, artists=artist_map[_id]) + ) + return albums + + def delete_album(self, album_id: str) -> None: + """ + Delete an album. Associated artist links are removed automatically + due to 'ON DELETE CASCADE' in the foreign key constraint. + """ + logger.debug("Deleting album with ID %s", album_id) + with self.conn: + self.conn.execute("DELETE FROM albums WHERE album_id = ?", (album_id,)) + # Note: We might want to clean up orphan artists later, but for now, this is fine. + + def reset_albums(self): + """ + Deletes all records from 'albums', 'album_artists', and 'artists'. + """ + logger.info("Resetting albums: dropping and recreating tables.") + with self.conn: + self.conn.execute("DROP TABLE IF EXISTS album_artists") + self.conn.execute("DROP TABLE IF EXISTS artists") + self.conn.execute("DROP TABLE IF EXISTS albums") + # Note: We need to recreate tables elsewhere since this manager + # doesn't handle table creation diff --git a/red_plex/infrastructure/db/base.py b/red_plex/infrastructure/db/base.py new file mode 100644 index 0000000..d172c0c --- /dev/null +++ b/red_plex/infrastructure/db/base.py @@ -0,0 +1,135 @@ +"""Base database functionality for LocalDatabase.""" + +import os +import sqlite3 + +from red_plex.infrastructure.db.utils.csv_to_db_migrator import CsvToDbMigrator + + +#pylint: disable=R0903 +class BaseDatabaseManager: + """Base database manager with connection and table creation functionality.""" + + def __init__(self): + self.db_path = self._get_database_directory() + os.makedirs(self.db_path, exist_ok=True) + db_file_path = os.path.join(self.db_path, 'red_plex.db') + + # If the database file doesn't exist, create and run migrations + if not os.path.isfile(db_file_path): + # Create a temp connection for initialization + self.conn = sqlite3.connect(db_file_path) + self.conn.execute("PRAGMA journal_mode=WAL;") + # Migrate existing CSV data into the new DB + # This can be removed in future releases + migrator = CsvToDbMigrator(db_file_path=db_file_path) + migrator.migrate_from_csv_to_db() + self.conn.commit() + else: + self.conn = sqlite3.connect(db_file_path) + self.conn.execute("PRAGMA journal_mode=WAL;") + self._create_tables() + + def _create_tables(self): + """Create necessary tables if they do not exist.""" + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS albums ( + album_id TEXT PRIMARY KEY, + name TEXT, + path TEXT NOT NULL, + added_at TEXT + ); + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS artists ( + artist_id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_name TEXT NOT NULL UNIQUE + ); + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS album_artists ( + album_id TEXT, + artist_id INTEGER, + PRIMARY KEY (album_id, artist_id), + FOREIGN KEY (album_id) REFERENCES albums(album_id) ON DELETE CASCADE, + FOREIGN KEY (artist_id) REFERENCES artists(artist_id) ON DELETE CASCADE + ); + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS collage_collections ( + rating_key TEXT PRIMARY KEY, + name TEXT, + site TEXT, + external_id TEXT + ); + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS bookmark_collections ( + rating_key TEXT PRIMARY KEY, + site TEXT + ); + """) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS collection_torrent_groups ( + rating_key TEXT, + group_id INTEGER + ); + """) + + # Tables for site tag mappings + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS rating_key_group_id_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rating_key TEXT NOT NULL, + group_id INTEGER NOT NULL, + site TEXT NOT NULL, + UNIQUE(rating_key, group_id, site) + ); + """) + + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS site_tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tag_name TEXT NOT NULL UNIQUE + ); + """) + + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS mapping_tags ( + mapping_id INTEGER, + tag_id INTEGER, + PRIMARY KEY (mapping_id, tag_id), + FOREIGN KEY (mapping_id) REFERENCES rating_key_group_id_mappings(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES site_tags(id) ON DELETE CASCADE + ); + """) + + self.conn.execute("DROP TABLE IF EXISTS beets_mappings;") + + @staticmethod + def _get_database_directory(): + """ + Return the directory path where the database file should live, + based on each OS's convention for local application data. + """ + if os.name == 'nt': # Windows + # Typically, LOCALAPPDATA or APPDATA for user-level application data + return os.path.join( + os.getenv('LOCALAPPDATA', os.path.expanduser('~\\AppData\\Local')), + 'red-plex' + ) + try: + if os.uname().sysname == 'Darwin': # macOS + + # Commonly used for persistent data: + return os.path.join(os.path.expanduser('~/Library/Application Support'), 'red-plex') + except ImportError: + pass + + # Linux / other Unix: use ~/.local/share/red-plex by XDG spec + data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) + return os.path.join(data_home, 'red-plex') + + def close(self): + """Close the database connection.""" + self.conn.close() diff --git a/red_plex/infrastructure/db/collection.py b/red_plex/infrastructure/db/collection.py new file mode 100644 index 0000000..ba71f1e --- /dev/null +++ b/red_plex/infrastructure/db/collection.py @@ -0,0 +1,310 @@ +"""Collection database operations for collage and bookmark collections.""" + +from typing import List, Optional, Set + +from red_plex.domain.models import Collection, TorrentGroup +from red_plex.infrastructure.logger.logger import logger + + +class CollectionDatabaseManager: + """Manages collection-related database operations.""" + + def __init__(self, conn): + self.conn = conn + + def insert_or_update_collage_collection(self, coll: Collection) -> None: + """ + Insert or update a collage-based collection, along with its torrent groups. + We'll do an upsert in collage_collections, then remove all old group_ids + from collection_torrent_groups for that rating_key, and re-insert them. + """ + logger.debug("Inserting/updating collage collection with rating_key %s", coll.id) + self.conn.execute( + """ + INSERT OR REPLACE INTO collage_collections(rating_key, name, site, external_id) + VALUES (?, ?, ?, ?) + """, + (coll.id, coll.name, coll.site, coll.external_id) + ) + # Remove old group_ids for that rating_key + self.conn.execute( + "DELETE FROM collection_torrent_groups WHERE rating_key = ?", + (coll.id,) + ) + # Insert new group_ids + if coll.torrent_groups: + group_data = [(coll.id, tg.id) for tg in coll.torrent_groups] + self.conn.executemany( + "INSERT INTO collection_torrent_groups(rating_key, group_id) VALUES(?, ?)", + group_data + ) + self.conn.commit() + + def merge_torrent_groups_for_collage_collection(self, rating_key: str, + new_group_ids: Set[int]) -> None: + """ + Merges a new set of torrent group IDs with the existing ones for a collage collection. + + This method fetches the current torrent groups for a given collection, + merges them with the new set of group IDs provided, and updates the + database with the combined list, ensuring no duplicates. + + Args: + rating_key: The ID of the collage collection to update. + new_group_ids: A set of new torrent group IDs to merge. + """ + logger.debug( + "Merging torrent groups for collage collection with rating_key %s", + rating_key + ) + + # First, ensure the collage collection exists. + cur = self.conn.cursor() + cur.execute( + "SELECT 1 FROM collage_collections WHERE rating_key = ?", (rating_key,) + ) + if not cur.fetchone(): + logger.warning( + "Attempted to merge torrent groups for a non-existent collage collection: %s", + rating_key + ) + return + + # Get existing group IDs and form a merged set to handle duplicates. + existing_group_ids = set(self._get_torrent_group_ids_for(rating_key)) + merged_group_ids = existing_group_ids.union(new_group_ids) + + # If there's no change, we can skip the database write. + if merged_group_ids == existing_group_ids: + logger.debug( + "No new torrent groups to add for rating_key %s. Skipping update.", + rating_key + ) + return + + # Use a transaction to perform the delete and bulk insert atomically. + try: + # Remove all old group_ids for the rating_key + self.conn.execute( + "DELETE FROM collection_torrent_groups WHERE rating_key = ?", + (rating_key,) + ) + + # Insert the new merged set of group_ids + if merged_group_ids: + data_to_insert = [(rating_key, group_id) for group_id in merged_group_ids] + self.conn.executemany( + "INSERT INTO collection_torrent_groups(rating_key, group_id) VALUES(?, ?)", + data_to_insert + ) + + self.conn.commit() + logger.debug( + "Successfully merged and updated torrent groups for rating_key %s. " + "Total groups: %d", + rating_key, + len(merged_group_ids) + ) + except self.conn.Error as e: + self.conn.rollback() + logger.error( + "Database error while merging torrent groups for rating_key %s: %s", + rating_key, + e + ) + raise e + + def get_collage_collection(self, rating_key: str) -> Optional[Collection]: + """ + Retrieve a single collage-based collection (and associated group_ids) by rating_key. + Returns a Collection or None if not found. + """ + cur = self.conn.cursor() + # Get collage collection fields + cur.execute( + """ + SELECT rating_key, name, site, external_id + FROM collage_collections + WHERE rating_key = ? + """, + (rating_key,) + ) + row = cur.fetchone() + if not row: + return None + rating_key_val, name, site, external_id = row + # Get associated group_ids + group_ids = self._get_torrent_group_ids_for(rating_key_val) + return Collection( + id=rating_key_val, + external_id=external_id, + name=name, + torrent_groups=[TorrentGroup(id=gid) for gid in group_ids], + site=site + ) + + def get_all_collage_collections(self) -> List[Collection]: + """ + Retrieve all collage-based collections from the DB, + along with their torrent groups. + """ + cur = self.conn.cursor() + cur.execute("SELECT rating_key, name, site, external_id FROM collage_collections") + rows = cur.fetchall() + collections = [] + for (rk, name, site, external_id) in rows: + group_ids = self._get_torrent_group_ids_for(rk) + collections.append(Collection( + id=rk, + external_id=external_id, + name=name, + torrent_groups=[TorrentGroup(id=gid) for gid in group_ids], + site=site + )) + return collections + + def delete_collage_collection(self, rating_key: str) -> None: + """ + Delete a collage-based collection and associated torrent group mappings. + """ + logger.debug("Deleting collage collection with rating_key %s", rating_key) + self.conn.execute( + "DELETE FROM collage_collections WHERE rating_key = ?", + (rating_key,) + ) + self.conn.execute( + "DELETE FROM collection_torrent_groups WHERE rating_key = ?", + (rating_key,) + ) + self.conn.commit() + + def reset_collage_collections(self): + """ + Deletes all records from 'collage_collections' and + their associated rows in 'collection_torrent_groups'. + """ + logger.info("Resetting collage collections (deleting all rows in 'collage_collections').") + self.conn.execute("DELETE FROM collage_collections") + logger.info("Removing associated torrent groups for collage collections.") + self.conn.execute(""" + DELETE FROM collection_torrent_groups + WHERE rating_key NOT IN (SELECT rating_key FROM bookmark_collections) + """) + + self.conn.commit() + + def insert_or_update_bookmark_collection(self, coll: Collection) -> None: + """ + Insert or update a bookmark-based collection (in bookmark_collections), + then remove all old group_ids from 'collection_torrent_groups' for that rating_key + and re-insert them. + """ + logger.debug("Inserting/updating bookmark collection with rating_key %s", coll.id) + self.conn.execute( + """ + INSERT OR REPLACE INTO bookmark_collections(rating_key, site) + VALUES (?, ?) + """, + (coll.id, coll.site) + ) + # Remove old group_ids for that rating_key + self.conn.execute( + "DELETE FROM collection_torrent_groups WHERE rating_key = ?", + (coll.id,) + ) + # Insert new group_ids + if coll.torrent_groups: + group_data = [(coll.id, tg.id) for tg in coll.torrent_groups] + self.conn.executemany( + "INSERT INTO collection_torrent_groups(rating_key, group_id) VALUES(?, ?)", + group_data + ) + self.conn.commit() + + def get_bookmark_collection(self, rating_key: str) -> Optional[Collection]: + """ + Retrieve a single bookmark collection (plus group_ids) by rating_key. + Returns a Collection or None if not found. + """ + cur = self.conn.cursor() + cur.execute( + """ + SELECT rating_key, site + FROM bookmark_collections + WHERE rating_key = ? + """, + (rating_key,) + ) + row = cur.fetchone() + if not row: + return None + rating_key_val, site = row + group_ids = self._get_torrent_group_ids_for(rating_key_val) + # We can store the name as something like f"{site.upper()} Bookmarks" + return Collection( + id=rating_key_val, + name=f"{site.upper()} Bookmarks", + site=site, + torrent_groups=[TorrentGroup(id=gid) for gid in group_ids] + ) + + def get_all_bookmark_collections(self) -> List[Collection]: + """ + Retrieve all bookmark collections from the DB, + along with their torrent groups. + """ + cur = self.conn.cursor() + cur.execute("SELECT rating_key, site FROM bookmark_collections") + rows = cur.fetchall() + bookmarks = [] + for (rk, site) in rows: + group_ids = self._get_torrent_group_ids_for(rk) + bookmarks.append(Collection( + id=rk, + name=f"{site.upper()} Bookmarks", + site=site, + torrent_groups=[TorrentGroup(id=gid) for gid in group_ids] + )) + return bookmarks + + def delete_bookmark_collection(self, rating_key: str) -> None: + """ + Delete a bookmark-based collection and associated torrent group mappings. + """ + logger.debug("Deleting bookmark collection with rating_key %s", rating_key) + self.conn.execute( + "DELETE FROM bookmark_collections WHERE rating_key = ?", + (rating_key,) + ) + self.conn.execute( + "DELETE FROM collection_torrent_groups WHERE rating_key = ?", + (rating_key,) + ) + self.conn.commit() + + def reset_bookmark_collections(self): + """ + Deletes all records from 'bookmark_collections' and + their associated rows in 'collection_torrent_groups'. + """ + logger.info("Resetting bookmark collections (deleting all rows in 'bookmark_collections').") + self.conn.execute("DELETE FROM bookmark_collections") + logger.info("Removing associated torrent groups for bookmark collections.") + self.conn.execute(""" + DELETE FROM collection_torrent_groups + WHERE rating_key NOT IN (SELECT rating_key FROM collage_collections) + """) + + self.conn.commit() + + def _get_torrent_group_ids_for(self, rating_key: str) -> List[int]: + """ + Retrieve all group_ids for a given rating_key from collection_torrent_groups. + """ + cur = self.conn.cursor() + cur.execute( + "SELECT group_id FROM collection_torrent_groups WHERE rating_key = ?", + (rating_key,) + ) + rows = cur.fetchall() + return [row[0] for row in rows] diff --git a/red_plex/infrastructure/db/local_database.py b/red_plex/infrastructure/db/local_database.py index 791c4a7..8458e21 100644 --- a/red_plex/infrastructure/db/local_database.py +++ b/red_plex/infrastructure/db/local_database.py @@ -1,17 +1,16 @@ """Defines the LocalDatabase class for managing a SQLite database.""" -import os -import sqlite3 -from collections import defaultdict -from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Set -from red_plex.domain.models import Album, Collection, TorrentGroup -from red_plex.infrastructure.db.utils.csv_to_db_migrator import CsvToDbMigrator -from red_plex.infrastructure.logger.logger import logger +from red_plex.domain.models import Album, Collection +from red_plex.infrastructure.db.albums import AlbumDatabaseManager +from red_plex.infrastructure.db.base import BaseDatabaseManager +from red_plex.infrastructure.db.collection import CollectionDatabaseManager +from red_plex.infrastructure.db.remote_mappings import RemoteMappingDatabaseManager -class LocalDatabase: +# pylint: disable=R0904 +class LocalDatabase(BaseDatabaseManager): """ A class for managing local persistent storage in a SQLite database. It creates or reuses a 'red_plex.db' file and provides CRUD operations for: @@ -19,522 +18,133 @@ class LocalDatabase: - collage_collections - bookmark_collections - collection_torrent_groups (relational table for group IDs) + - site tag mappings """ def __init__(self): - self.db_path = self._get_database_directory() - os.makedirs(self.db_path, exist_ok=True) - db_file_path = os.path.join(self.db_path, 'red_plex.db') - - # If the database file doesn't exist, create and run migrations - if not os.path.isfile(db_file_path): - # Create a temp connection for initialization - self.conn = sqlite3.connect(db_file_path) - self.conn.execute("PRAGMA journal_mode=WAL;") - self._create_tables() - # Migrate existing CSV data into the new DB - # This can be removed in future releases - migrator = CsvToDbMigrator(db_file_path=db_file_path) - migrator.migrate_from_csv_to_db() - self.conn.commit() - else: - self.conn = sqlite3.connect(db_file_path) - self.conn.execute("PRAGMA journal_mode=WAL;") - - def _create_tables(self): - """Create necessary tables if they do not exist.""" - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS albums ( - album_id TEXT PRIMARY KEY, - name TEXT, - path TEXT NOT NULL, - added_at TEXT - ); - """) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS artists ( - artist_id INTEGER PRIMARY KEY AUTOINCREMENT, - artist_name TEXT NOT NULL UNIQUE - ); - """) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS album_artists ( - album_id TEXT, - artist_id INTEGER, - PRIMARY KEY (album_id, artist_id), - FOREIGN KEY (album_id) REFERENCES albums(album_id) ON DELETE CASCADE, - FOREIGN KEY (artist_id) REFERENCES artists(artist_id) ON DELETE CASCADE - ); - """) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS collage_collections ( - rating_key TEXT PRIMARY KEY, - name TEXT, - site TEXT, - external_id TEXT - ); - """) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS bookmark_collections ( - rating_key TEXT PRIMARY KEY, - site TEXT - ); - """) - self.conn.execute(""" - CREATE TABLE IF NOT EXISTS collection_torrent_groups ( - rating_key TEXT, - group_id INTEGER - ); - """) - self.conn.execute("DROP TABLE IF EXISTS beets_mappings;") - - @staticmethod - def _get_database_directory(): - """ - Return the directory path where the database file should live, - based on each OS's convention for local application data. - """ - if os.name == 'nt': # Windows - # Typically, LOCALAPPDATA or APPDATA for user-level application data - return os.path.join( - os.getenv('LOCALAPPDATA', os.path.expanduser('~\\AppData\\Local')), - 'red-plex' - ) - try: - if os.uname().sysname == 'Darwin': # macOS - - # Commonly used for persistent data: - return os.path.join(os.path.expanduser('~/Library/Application Support'), 'red-plex') - except ImportError: - pass - - # Linux / other Unix: use ~/.local/share/red-plex by XDG spec - data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) - return os.path.join(data_home, 'red-plex') - - def close(self): - """Close the database connection.""" - self.conn.close() + super().__init__() + # Initialize specialized managers + self._album_manager = AlbumDatabaseManager(self.conn) + self._collection_manager = CollectionDatabaseManager(self.conn) + self._remote_mapping_manager = RemoteMappingDatabaseManager(self.conn) # -------------------------------------------------------------------------- # ALBUMS # -------------------------------------------------------------------------- def insert_or_update_album(self, album: Album) -> None: - """ - Insert or update an album, including its artists, in the database. - Uses a transaction to ensure atomicity. - """ - logger.debug("Inserting/updating album with ID %s", album.id) - with self.conn: - # 1. Insert or replace the album itself - self.conn.execute( - """ - INSERT OR REPLACE INTO albums(album_id, name, path, added_at) - VALUES (?, ?, ?, ?) - """, - (album.id, - album.name, - album.path, - album.added_at.isoformat() if album.added_at else None) - ) - - # 2. Delete old artist associations for this album - self.conn.execute("DELETE FROM album_artists WHERE album_id = ?", (album.id,)) - - # 3. Insert artists and their new associations - if album.artists: - # Insert artists if they don't exist (IGNORE on conflict) - self.conn.executemany( - "INSERT OR IGNORE INTO artists(artist_name) VALUES (?)", - [(artist,) for artist in album.artists] - ) - - # Get artist IDs - cur = self.conn.cursor() - artist_ids = dict(cur.execute( - f"SELECT artist_name, artist_id FROM artists WHERE artist_name IN " - f"({','.join('?' * len(album.artists))})", - album.artists - )) - - # Insert into linking table - self.conn.executemany( - "INSERT INTO album_artists(album_id, artist_id) VALUES (?, ?)", - [(album.id, artist_ids[name]) for name in album.artists] - ) + """Insert or update an album, including its artists, in the database.""" + return self._album_manager.insert_or_update_album(album) def insert_albums_bulk(self, albums: List[Album]) -> None: - """ - Inserts or updates a list of albums in bulk using a single transaction. - Handles albums, artists, and their relationships efficiently. - """ - logger.debug("Inserting/updating %d albums in bulk.", len(albums)) - - album_rows = [] - all_artists = set() - album_id_to_artists = {} - - for album in albums: - album_rows.append(( - album.id, - album.name, - album.path, - album.added_at.isoformat() if album.added_at else None - )) - if album.artists: - all_artists.update(album.artists) - album_id_to_artists[album.id] = album.artists - - with self.conn: - # 1. Insert/update all albums - self.conn.executemany( - "INSERT OR REPLACE INTO albums(album_id, name, path, added_at) VALUES (?, ?, ?, ?)", - album_rows - ) - - album_ids = [album.id for album in albums] - - # 2. Delete all existing artist links for the albums being updated - self.conn.execute( - f"DELETE FROM album_artists WHERE album_id IN ({','.join('?' * len(album_ids))})", - album_ids - ) - - # 3. Insert all new unique artists - if all_artists: - self.conn.executemany( - "INSERT OR IGNORE INTO artists(artist_name) VALUES (?)", - [(artist,) for artist in all_artists] - ) - - # 4. Get all required artist IDs in one query - cur = self.conn.cursor() - artist_name_to_id = dict(cur.execute( - f"SELECT artist_name, artist_id FROM artists WHERE artist_name IN " - f"({','.join('?' * len(all_artists))})", - list(all_artists) - )) - - # 5. Prepare and insert all album-artist links - album_artist_links = [] - for album_id, artist_names in album_id_to_artists.items(): - for name in artist_names: - album_artist_links.append((album_id, artist_name_to_id[name])) - - self.conn.executemany( - "INSERT INTO album_artists(album_id, artist_id) VALUES (?, ?)", - album_artist_links - ) + """Inserts or updates a list of albums in bulk using a single transaction.""" + return self._album_manager.insert_albums_bulk(albums) def get_album(self, album_id: str) -> Optional[Album]: - """ - Retrieve a single album by its ID, including its list of artists. - """ - cur = self.conn.cursor() - - # Get album details - cur.execute("SELECT album_id, name, path, added_at " - "FROM albums WHERE album_id = ?", (album_id,)) - row = cur.fetchone() - if not row: - return None - - _id, _name, _path, _added_at_str = row - added_at = datetime.fromisoformat(_added_at_str) if _added_at_str else None - - # Get associated artists - cur.execute(""" - SELECT ar.artist_name - FROM artists ar - JOIN album_artists aa ON ar.artist_id = aa.artist_id - WHERE aa.album_id = ? - """, (album_id,)) - artists = [row[0] for row in cur.fetchall()] - - return Album(id=_id, name=_name, path=_path, added_at=added_at, artists=artists) + """Retrieve a single album by its ID, including its list of artists.""" + return self._album_manager.get_album(album_id) def get_all_albums(self) -> List[Album]: - """ - Retrieve all albums from the database, including their lists of artists. - This is done efficiently to avoid the N+1 query problem. - """ - cur = self.conn.cursor() - - # 1. Fetch all albums - cur.execute("SELECT album_id, name, path, added_at FROM albums") - album_rows = cur.fetchall() - if not album_rows: - return [] - - # 2. Fetch all artist relationships in a single query - artist_map = defaultdict(list) - cur.execute(""" - SELECT aa.album_id, ar.artist_name - FROM album_artists aa - JOIN artists ar ON aa.artist_id = ar.artist_id - """) - for album_id, artist_name in cur.fetchall(): - artist_map[album_id].append(artist_name) - - # 3. Construct Album objects - albums = [] - for _id, _name, _path, _added_at_str in album_rows: - added_at = datetime.fromisoformat(_added_at_str) if _added_at_str else None - albums.append( - Album(id=_id, name=_name, path=_path, added_at=added_at, artists=artist_map[_id]) - ) - return albums + """Retrieve all albums from the database, including their lists of artists.""" + return self._album_manager.get_all_albums() def delete_album(self, album_id: str) -> None: - """ - Delete an album. Associated artist links are removed automatically - due to 'ON DELETE CASCADE' in the foreign key constraint. - """ - logger.debug("Deleting album with ID %s", album_id) - with self.conn: - self.conn.execute("DELETE FROM albums WHERE album_id = ?", (album_id,)) - # Note: We might want to clean up orphan artists later, but for now, this is fine. + """Delete an album.""" + return self._album_manager.delete_album(album_id) def reset_albums(self): - """ - Deletes all records from 'albums', 'album_artists', and 'artists'. - """ - logger.info("Resetting albums: dropping and recreating tables.") - with self.conn: - self.conn.execute("DROP TABLE IF EXISTS album_artists") - self.conn.execute("DROP TABLE IF EXISTS artists") - self.conn.execute("DROP TABLE IF EXISTS albums") + """Deletes all records from 'albums', 'album_artists', and 'artists'.""" + self._album_manager.reset_albums() + # Recreate tables after reset self._create_tables() - logger.info("Album-related tables have been successfully reset.") # -------------------------------------------------------------------------- # COLLAGE COLLECTIONS (and their groups) # -------------------------------------------------------------------------- def insert_or_update_collage_collection(self, coll: Collection) -> None: + """Insert or update a collage-based collection, along with its torrent groups.""" + return self._collection_manager.insert_or_update_collage_collection(coll) + + def merge_torrent_groups_for_collage_collection(self, rating_key: str, + new_group_ids: Set[int]) -> None: """ - Insert or update a collage-based collection, along with its torrent groups. - We'll do an upsert in collage_collections, then remove all old group_ids - from collection_torrent_groups for that rating_key, and re-insert them. + Merges a new set of torrent group IDs with existing ones for a collage collection. """ - logger.debug("Inserting/updating collage collection with rating_key %s", coll.id) - self.conn.execute( - """ - INSERT OR REPLACE INTO collage_collections(rating_key, name, site, external_id) - VALUES (?, ?, ?, ?) - """, - (coll.id, coll.name, coll.site, coll.external_id) + return self._collection_manager.merge_torrent_groups_for_collage_collection( + rating_key, + new_group_ids ) - # Remove old group_ids for that rating_key - self.conn.execute( - "DELETE FROM collection_torrent_groups WHERE rating_key = ?", - (coll.id,) - ) - # Insert new group_ids - if coll.torrent_groups: - for tg in coll.torrent_groups: - self.conn.execute( - "INSERT INTO collection_torrent_groups(rating_key, group_id) VALUES(?, ?)", - (coll.id, tg.id) - ) - self.conn.commit() def get_collage_collection(self, rating_key: str) -> Optional[Collection]: - """ - Retrieve a single collage-based collection (and associated group_ids) by rating_key. - Returns a Collection or None if not found. - """ - cur = self.conn.cursor() - # Get collage collection fields - cur.execute( - """ - SELECT rating_key, name, site, external_id - FROM collage_collections - WHERE rating_key = ? - """, - (rating_key,) - ) - row = cur.fetchone() - if not row: - return None - rating_key_val, name, site, external_id = row - # Get associated group_ids - group_ids = self._get_torrent_group_ids_for(rating_key_val) - return Collection( - id=rating_key_val, - external_id=external_id, - name=name, - torrent_groups=[TorrentGroup(id=gid) for gid in group_ids], - site=site - ) + """Retrieve a single collage-based collection by rating_key.""" + return self._collection_manager.get_collage_collection(rating_key) def get_all_collage_collections(self) -> List[Collection]: - """ - Retrieve all collage-based collections from the DB, - along with their torrent groups. - """ - cur = self.conn.cursor() - cur.execute("SELECT rating_key, name, site, external_id FROM collage_collections") - rows = cur.fetchall() - collections = [] - for (rk, name, site, external_id) in rows: - group_ids = self._get_torrent_group_ids_for(rk) - collections.append(Collection( - id=rk, - external_id=external_id, - name=name, - torrent_groups=[TorrentGroup(id=gid) for gid in group_ids], - site=site - )) - return collections + """Retrieve all collage-based collections from the DB.""" + return self._collection_manager.get_all_collage_collections() def delete_collage_collection(self, rating_key: str) -> None: - """ - Delete a collage-based collection and associated torrent group mappings. - """ - logger.debug("Deleting collage collection with rating_key %s", rating_key) - self.conn.execute( - "DELETE FROM collage_collections WHERE rating_key = ?", - (rating_key,) - ) - self.conn.execute( - "DELETE FROM collection_torrent_groups WHERE rating_key = ?", - (rating_key,) - ) - self.conn.commit() + """Delete a collage-based collection and associated torrent group mappings.""" + return self._collection_manager.delete_collage_collection(rating_key) def reset_collage_collections(self): - """ - Deletes all records from 'collage_collections' and - their associated rows in 'collection_torrent_groups'. - """ - logger.info("Resetting collage collections (deleting all rows in 'collage_collections').") - self.conn.execute("DELETE FROM collage_collections") - logger.info("Removing associated torrent groups for collage collections.") - self.conn.execute(""" - DELETE FROM collection_torrent_groups - WHERE rating_key NOT IN (SELECT rating_key FROM bookmark_collections) - """) - - self.conn.commit() + """Deletes all records from 'collage_collections'.""" + return self._collection_manager.reset_collage_collections() # -------------------------------------------------------------------------- # BOOKMARK COLLECTIONS # -------------------------------------------------------------------------- def insert_or_update_bookmark_collection(self, coll: Collection) -> None: - """ - Insert or update a bookmark-based collection (in bookmark_collections), - then remove all old group_ids from 'collection_torrent_groups' for that rating_key - and re-insert them. - """ - logger.debug("Inserting/updating bookmark collection with rating_key %s", coll.id) - self.conn.execute( - """ - INSERT OR REPLACE INTO bookmark_collections(rating_key, site) - VALUES (?, ?) - """, - (coll.id, coll.site) - ) - # Remove old group_ids for that rating_key - self.conn.execute( - "DELETE FROM collection_torrent_groups WHERE rating_key = ?", - (coll.id,) - ) - # Insert new group_ids - if coll.torrent_groups: - for tg in coll.torrent_groups: - self.conn.execute( - "INSERT INTO collection_torrent_groups(rating_key, group_id) VALUES(?, ?)", - (coll.id, tg.id) - ) - self.conn.commit() + """Insert or update a bookmark-based collection.""" + return self._collection_manager.insert_or_update_bookmark_collection(coll) def get_bookmark_collection(self, rating_key: str) -> Optional[Collection]: - """ - Retrieve a single bookmark collection (plus group_ids) by rating_key. - Returns a Collection or None if not found. - """ - cur = self.conn.cursor() - cur.execute( - """ - SELECT rating_key, site - FROM bookmark_collections - WHERE rating_key = ? - """, - (rating_key,) - ) - row = cur.fetchone() - if not row: - return None - rating_key_val, site = row - group_ids = self._get_torrent_group_ids_for(rating_key_val) - # We can store the name as something like f"{site.upper()} Bookmarks" - return Collection( - id=rating_key_val, - name=f"{site.upper()} Bookmarks", - site=site, - torrent_groups=[TorrentGroup(id=gid) for gid in group_ids] - ) + """Retrieve a single bookmark collection by rating_key.""" + return self._collection_manager.get_bookmark_collection(rating_key) def get_all_bookmark_collections(self) -> List[Collection]: - """ - Retrieve all bookmark collections from the DB, - along with their torrent groups. - """ - cur = self.conn.cursor() - cur.execute("SELECT rating_key, site FROM bookmark_collections") - rows = cur.fetchall() - bookmarks = [] - for (rk, site) in rows: - group_ids = self._get_torrent_group_ids_for(rk) - bookmarks.append(Collection( - id=rk, - name=f"{site.upper()} Bookmarks", - site=site, - torrent_groups=[TorrentGroup(id=gid) for gid in group_ids] - )) - return bookmarks + """Retrieve all bookmark collections from the DB.""" + return self._collection_manager.get_all_bookmark_collections() def delete_bookmark_collection(self, rating_key: str) -> None: - """ - Delete a bookmark-based collection and associated torrent group mappings. - """ - logger.debug("Deleting bookmark collection with rating_key %s", rating_key) - self.conn.execute( - "DELETE FROM bookmark_collections WHERE rating_key = ?", - (rating_key,) - ) - self.conn.execute( - "DELETE FROM collection_torrent_groups WHERE rating_key = ?", - (rating_key,) - ) - self.conn.commit() + """Delete a bookmark-based collection and associated torrent group mappings.""" + return self._collection_manager.delete_bookmark_collection(rating_key) def reset_bookmark_collections(self): - """ - Deletes all records from 'bookmark_collections' and - their associated rows in 'collection_torrent_groups'. - """ - logger.info("Resetting bookmark collections (deleting all rows in 'bookmark_collections').") - self.conn.execute("DELETE FROM bookmark_collections") - logger.info("Removing associated torrent groups for bookmark collections.") - self.conn.execute(""" - DELETE FROM collection_torrent_groups - WHERE rating_key NOT IN (SELECT rating_key FROM collage_collections) - """) - - self.conn.commit() + """Deletes all records from 'bookmark_collections'.""" + return self._collection_manager.reset_bookmark_collections() # -------------------------------------------------------------------------- - # HELPER: TORRENT GROUPS FOR A GIVEN RATING_KEY + # SITE TAG MAPPINGS # -------------------------------------------------------------------------- - def _get_torrent_group_ids_for(self, rating_key: str) -> List[int]: - """ - Retrieve all group_ids for a given rating_key from collection_torrent_groups. - """ - cur = self.conn.cursor() - cur.execute( - "SELECT group_id FROM collection_torrent_groups WHERE rating_key = ?", - (rating_key,) - ) - rows = cur.fetchall() - return [row[0] for row in rows] + def insert_site_tag_mapping(self, rating_key: str, + group_id: int, + site: str, + tags: List[str]) -> None: + """Insert or update a site tag mapping with its associated tags.""" + return self._remote_mapping_manager.insert_rating_key_group_id_mapping( + rating_key, + group_id, site, tags) + + def get_rating_keys_by_tags(self, tags: List[str]) -> List[str]: + """Get rating keys that have mappings containing all specified tags.""" + return self._remote_mapping_manager.get_rating_keys_by_tags(tags) + + def get_group_ids_by_rating_keys(self, rating_keys: List[str], site: str) -> List[str]: + """Get group IDs for given rating keys from a specific site.""" + return self._remote_mapping_manager.get_group_ids_by_rating_keys(rating_keys, site) + + def get_unscanned_albums(self) -> List[str]: + """Get rating keys from albums table that are not present in mappings.""" + return self._remote_mapping_manager.get_unscanned_albums() + + def get_site_tags_stats(self): + """Get statistics about site tag mappings.""" + return self._remote_mapping_manager.get_remote_mappings_stats() + + def get_recent_site_tag_mappings(self, limit: int = 20): + """Get recent site tag mappings for display.""" + return self._remote_mapping_manager.get_recent_remote_mappings(limit) + + def reset_tag_mappings(self): + """Reset site tag mappings.""" + return self._remote_mapping_manager.reset_remote_mappings() diff --git a/red_plex/infrastructure/db/remote_mappings.py b/red_plex/infrastructure/db/remote_mappings.py new file mode 100644 index 0000000..50bc692 --- /dev/null +++ b/red_plex/infrastructure/db/remote_mappings.py @@ -0,0 +1,189 @@ +"""Remote mapping database operations for rating key to group ID mappings.""" + +from typing import List + +from red_plex.infrastructure.logger.logger import logger + + +class RemoteMappingDatabaseManager: + """Manages remote mapping database operations between Plex rating keys and site group IDs.""" + + def __init__(self, conn): + self.conn = conn + + def insert_rating_key_group_id_mapping(self, rating_key: str, + group_id: int, + site: str, + tags: List[str]) -> None: + """ + Insert or update mapping with its associated tags. + """ + logger.debug("Inserting rating key -> group id mapping: " + "rating_key=%s, group_id=%s, site=%s", + rating_key, group_id, site) + + with self.conn: + # Insert or ignore the mapping + cur = self.conn.cursor() + cur.execute(""" + INSERT OR IGNORE INTO rating_key_group_id_mappings(rating_key, group_id, site) + VALUES (?, ?, ?) + """, (rating_key, group_id, site)) + + # Get the mapping ID + cur.execute(""" + SELECT id FROM rating_key_group_id_mappings + WHERE rating_key = ? AND group_id = ? AND site = ? + """, (rating_key, group_id, site)) + mapping_id = cur.fetchone()[0] + + # Delete existing tag associations for this mapping + cur.execute("DELETE FROM mapping_tags WHERE mapping_id = ?", (mapping_id,)) + + if tags: + # Insert tags if they don't exist + self.conn.executemany( + "INSERT OR IGNORE INTO site_tags(tag_name) VALUES (?)", + [(tag,) for tag in tags] + ) + + # Get tag IDs + tag_ids = dict(cur.execute( + f"SELECT tag_name, id FROM site_tags WHERE tag_name IN " + f"({','.join('?' * len(tags))})", + tags + )) + + # Insert tag associations + self.conn.executemany( + "INSERT INTO mapping_tags(mapping_id, tag_id) VALUES (?, ?)", + [(mapping_id, tag_ids[tag]) for tag in tags] + ) + + def get_rating_keys_by_tags(self, tags: List[str]) -> List[str]: + """ + Get rating keys that have mappings containing all specified tags. + """ + if not tags: + return [] + + cur = self.conn.cursor() + + placeholders = ','.join('?' * len(tags)) + cur.execute(f""" + SELECT DISTINCT stm.rating_key + FROM rating_key_group_id_mappings stm + JOIN mapping_tags mt ON stm.id = mt.mapping_id + JOIN site_tags st ON mt.tag_id = st.id + WHERE st.tag_name IN ({placeholders}) + GROUP BY stm.rating_key + HAVING COUNT(DISTINCT st.tag_name) = ? + """, tags + [len(tags)]) + + return [row[0] for row in cur.fetchall()] + + def get_unscanned_albums(self) -> List[str]: + """ + Get rating keys from albums table that are not present in + rating_key_group_id_mappings, ordered by most recently added. + """ + cur = self.conn.cursor() + cur.execute(""" + SELECT a.album_id + FROM albums a + LEFT JOIN rating_key_group_id_mappings stm ON a.album_id = stm.rating_key + WHERE stm.rating_key IS NULL + ORDER BY a.added_at DESC + """) + + return [row[0] for row in cur.fetchall()] + + def get_remote_mappings_stats(self): + """ + Get statistics about remote mappings. + Returns a tuple of (mapped_albums, total_tags, total_mappings). + """ + cur = self.conn.cursor() + cur.execute(""" + SELECT COUNT(DISTINCT stm.rating_key) as mapped_albums, + COUNT(DISTINCT st.tag_name) as total_tags, + COUNT(stm.id) as total_mappings + FROM rating_key_group_id_mappings stm + LEFT JOIN mapping_tags mt ON stm.id = mt.mapping_id + LEFT JOIN site_tags st ON mt.tag_id = st.id + """) + stats = cur.fetchone() + return (stats[0] or 0, stats[1] or 0, stats[2] or 0) + + def get_recent_remote_mappings(self, limit: int = 20): + """ + Get recent remote mappings for display. + Returns a list of dictionaries with album information. + """ + cur = self.conn.cursor() + cur.execute(""" + SELECT stm.rating_key, stm.group_id, stm.site, + a.name as album_name, + GROUP_CONCAT(DISTINCT ar.artist_name) as artists, + GROUP_CONCAT(DISTINCT st.tag_name) as tags + FROM rating_key_group_id_mappings stm + LEFT JOIN albums a ON stm.rating_key = a.album_id + LEFT JOIN album_artists aa ON a.album_id = aa.album_id + LEFT JOIN artists ar ON aa.artist_id = ar.artist_id + LEFT JOIN mapping_tags mt ON stm.id = mt.mapping_id + LEFT JOIN site_tags st ON mt.tag_id = st.id + GROUP BY stm.id + ORDER BY stm.id DESC + LIMIT ? + """, (limit,)) + + results = [] + for row in cur.fetchall(): + # Handle the comma separation manually since SQLite GROUP_CONCAT uses comma by default + artists = row[4].replace(',', ', ') if row[4] else 'Unknown Artist' + tags = row[5].replace(',', ', ') if row[5] else '' + + results.append({ + 'rating_key': row[0], + 'group_id': row[1], + 'site': row[2], + 'album_name': row[3] or 'Unknown Album', + 'artists': artists, + 'tags': tags + }) + return results + + def get_group_ids_by_rating_keys(self, rating_keys: List[str], site: str) -> List[str]: + """ + Get group IDs for given rating keys from a specific site. + + Args: + rating_keys: List of rating keys to look up + site: Site to filter by + + Returns: + List of group IDs as strings + """ + if not rating_keys: + return [] + + cur = self.conn.cursor() + placeholders = ','.join('?' * len(rating_keys)) + cur.execute(f""" + SELECT DISTINCT group_id + FROM rating_key_group_id_mappings + WHERE rating_key IN ({placeholders}) AND site = ? + """, rating_keys + [site.lower()]) + + return [str(row[0]) for row in cur.fetchall()] + + def reset_remote_mappings(self): + """ + Reset remote mappings. + """ + logger.info("Resetting remote mappings") + + with self.conn: + self.conn.execute("DELETE FROM mapping_tags") + self.conn.execute("DELETE FROM rating_key_group_id_mappings") + self.conn.execute("DELETE FROM site_tags") diff --git a/red_plex/infrastructure/gui/app.py b/red_plex/infrastructure/gui/app.py index 2425c4b..7072774 100644 --- a/red_plex/infrastructure/gui/app.py +++ b/red_plex/infrastructure/gui/app.py @@ -2,23 +2,20 @@ import logging import os -from flask import Flask, render_template, request, redirect, url_for, flash, g -from flask_socketio import SocketIO, emit +from flask import Flask, render_template, g +from flask_socketio import SocketIO -from red_plex.infrastructure.cli.cli import update_collections_from_collages -from red_plex.infrastructure.config.config import ( - load_config, - save_config -) -from red_plex.infrastructure.config.models import Configuration from red_plex.infrastructure.db.local_database import LocalDatabase +from red_plex.infrastructure.gui.routes.bookmarks import register_bookmarks_routes +from red_plex.infrastructure.gui.routes.collages import register_collages_routes +from red_plex.infrastructure.gui.routes.config import register_config_routes +from red_plex.infrastructure.gui.routes.database import register_database_routes +from red_plex.infrastructure.gui.routes.remote_mappings import register_remote_mappings_routes +from red_plex.infrastructure.gui.routes.site_tags import register_site_tags_routes from red_plex.infrastructure.logger.logger import configure_logger -from red_plex.infrastructure.plex.plex_manager import PlexManager -from red_plex.infrastructure.rest.gazelle.gazelle_api import GazelleAPI -from red_plex.infrastructure.service.collection_processor import CollectionProcessingService -from red_plex.use_case.create_collection.album_fetch_mode import AlbumFetchMode -# pylint: disable=W0703,W0718,R0914,R0915 + +# pylint: disable=W0703,W0718,R0914,R0915,W0511 class WebSocketHandler(logging.Handler): """Custom logging handler that sends log messages via WebSocket.""" @@ -75,380 +72,21 @@ def close_db(error): if error is not None: logger.error("Error during request teardown: %s", error) - def map_fetch_mode(fetch_mode_str) -> AlbumFetchMode: - """Map the fetch mode string to an AlbumFetchMode enum.""" - if fetch_mode_str == 'query': - return AlbumFetchMode.QUERY - return AlbumFetchMode.TORRENT_NAME - @app.route('/') def index(): """Home page.""" return render_template('index.html') - @app.route('/config') - def config_view(): - """View configuration.""" - try: - config_data = load_config() - return render_template('config.html', config=config_data.to_dict()) - except Exception as e: - flash(f'Error loading configuration: {str(e)}', 'error') - return render_template('config.html', config={}) - - @app.route('/config/edit', methods=['GET', 'POST']) - def config_edit(): - """Edit configuration.""" - if request.method == 'POST': - try: - config_data = request.form.to_dict() - - # Convert nested structure for site configs - sites_config = {} - for key, value in config_data.items(): - if key.startswith('RED_') or key.startswith('OPS_'): - site, field = key.split('_', 1) - if site not in sites_config: - sites_config[site] = {} - if field == 'RATE_LIMIT_CALLS': - sites_config[site].setdefault('RATE_LIMIT', {})['calls'] = int(value) - elif field == 'RATE_LIMIT_SECONDS': - sites_config[site].setdefault('RATE_LIMIT', {})['seconds'] = int(value) - else: - sites_config[site][field] = value - - # Build final config - final_config = { - 'LOG_LEVEL': config_data.get('LOG_LEVEL', 'INFO'), - 'PLEX_URL': config_data.get('PLEX_URL', ''), - 'PLEX_TOKEN': config_data.get('PLEX_TOKEN', ''), - 'SECTION_NAME': config_data.get('SECTION_NAME', 'Music'), - } - final_config.update(sites_config) - - # Save configuration - config = Configuration.from_dict(final_config) - save_config(config) - - flash('Configuration saved successfully!', 'success') - return redirect(url_for('config_view')) - except Exception as e: - flash(f'Error saving configuration: {str(e)}', 'error') - - try: - config_data = load_config() - return render_template('config_edit.html', config=config_data.to_dict()) - except Exception as e: - flash(f'Error loading configuration: {str(e)}', 'error') - return render_template('config_edit.html', config={}) - - @app.route('/collages') - def collages(): - """View collages.""" - try: - db = get_db() - collages = db.get_all_collage_collections() - return render_template('collages.html', collages=collages) - except Exception as e: - flash(f'Error loading collages: {str(e)}', 'error') - return render_template('collages.html', collages=[]) - - @app.route('/collages/convert', methods=['GET', 'POST']) - def collages_convert(): - """Convert collages.""" - if request.method == 'POST': - try: - collage_ids = request.form.get('collage_ids', '').split() - site = request.form.get('site') - fetch_mode = request.form.get('fetch_mode', 'torrent_name') - - if not collage_ids: - flash('Please provide at least one collage ID.', 'error') - return render_template('collages_convert.html') - - if not site: - flash('Please select a site.', 'error') - return render_template('collages_convert.html') - - # Start processing in background - def process_collages(): - logger = logging.getLogger('red_plex') - thread_db = None - try: - thread_db = LocalDatabase() - album_fetch_mode = map_fetch_mode(fetch_mode) - - with app.app_context(): - socketio.emit('status_update', - {'message': 'Starting collage conversion process...'}) - - logger.info("WebSocket logging is configured and ready.") - logger.info("Connecting to Plex server...") - - try: - plex_manager = PlexManager(db=thread_db) - except Exception as e: - logger.error('Failed to initialize PlexManager: %s', e) - with app.app_context(): - socketio.emit('status_update', { - 'message': f'Failed to connect to Plex server: {str(e)}', - 'error': True - }) - return - - logger.info("Successfully connected to Plex server.") - - gazelle_api = GazelleAPI(site) - processor = CollectionProcessingService(thread_db, - plex_manager, - gazelle_api) - - def web_echo(message): - logger.info(message) - - def web_confirm(message): - logger.info('Auto-confirming: %s', message) - return True - - processor.process_collages( - collage_ids=collage_ids, - album_fetch_mode=album_fetch_mode, - echo_func=web_echo, - confirm_func=web_confirm - ) - - with app.app_context(): - socketio.emit('status_update', { - 'message': 'Collage processing completed successfully!', - 'finished': True - }) - - except Exception as e: - logger.critical('An unhandled error occurred: %s', e, exc_info=True) - with app.app_context(): - socketio.emit('status_update', { - 'message': f'Error: {str(e)}', - 'error': True - }) - finally: - if thread_db: - thread_db.close() - - socketio.start_background_task(target=process_collages) - - flash('Processing started! Check the status below.', 'info') - return render_template('collages_convert.html', - processing=True) - - except Exception as e: - flash(f'Error starting collage conversion: {str(e)}', 'error') - - return render_template('collages_convert.html') - - @app.route('/bookmarks') - def bookmarks(): - """View bookmarks.""" - try: - db = get_db() - bookmarks = db.get_all_bookmark_collections() - return render_template('bookmarks.html', bookmarks=bookmarks) - except Exception as e: - flash(f'Error loading bookmarks: {str(e)}', 'error') - return render_template('bookmarks.html', bookmarks=[]) - - @app.route('/bookmarks/convert', methods=['GET', 'POST']) - def bookmarks_convert(): - """Convert bookmarks.""" - if request.method == 'POST': - try: - site = request.form.get('site') - fetch_mode = request.form.get('fetch_mode', 'torrent_name') - - if not site: - flash('Please select a site.', 'error') - return render_template('bookmarks_convert.html') - - # Start processing in background - def process_bookmarks(): - logger = logging.getLogger('red_plex') - thread_db = None - try: - thread_db = LocalDatabase() - album_fetch_mode = map_fetch_mode(fetch_mode) - - with app.app_context(): - socketio.emit('status_update', - {'message': 'Starting bookmark conversion process...'}) - - gazelle_api = GazelleAPI(site) - plex_manager = PlexManager(db=thread_db) - processor = CollectionProcessingService(thread_db, - plex_manager, - gazelle_api) - - def web_echo(message): - logger.info(message) - - def web_confirm(message): - logger.info('Auto-confirming: %s', message) - return True - - processor.process_bookmarks( - album_fetch_mode=album_fetch_mode, - echo_func=web_echo, - confirm_func=web_confirm - ) - - with app.app_context(): - socketio.emit('status_update', { - 'message': 'Bookmark processing completed successfully!', - 'finished': True - }) - except Exception as e: - logger.critical( - 'An unhandled error occurred during bookmark processing: %s', - e, - exc_info=True) - with app.app_context(): - socketio.emit('status_update', { - 'message': f'Error: {str(e)}', - 'error': True - }) - finally: - if thread_db: - thread_db.close() - - socketio.start_background_task(target=process_bookmarks) - - flash('Processing started! Check the status below.', 'info') - return render_template('bookmarks_convert.html', - processing=True) - - except Exception as e: - flash(f'Error starting bookmark conversion: {str(e)}', 'error') - - return render_template('bookmarks_convert.html') - - @app.route('/database') - def database(): - """View database status.""" - try: - db = get_db() - db_path = db.db_path - db_exists = os.path.exists(db_path) - - # Get some basic stats - stats = {} - if db_exists: - try: - stats['albums'] = len(db.get_all_albums()) - stats['collages'] = len(db.get_all_collage_collections()) - stats['bookmarks'] = len(db.get_all_bookmark_collections()) - except Exception as e: - logger.warning('Error getting database stats: %s', e) - stats = {'albums': 0, 'collages': 0, 'bookmarks': 0} - else: - stats = {'albums': 0, 'collages': 0, 'bookmarks': 0} - - return render_template('database.html', - db_path=db_path, - db_exists=db_exists, - stats=stats) - except Exception as e: - flash(f'Error loading database status: {str(e)}', 'error') - return render_template('database.html', - db_path="Unknown", - db_exists=False, - stats={}) - - @app.route('/database/albums/update', methods=['POST']) - def database_albums_update(): - """Update albums from Plex and update collections from collages.""" - try: - def update_albums(): - logger = logging.getLogger('red_plex') - thread_db = None - try: - thread_db = LocalDatabase() - - with app.app_context(): - socketio.emit('status_update', - {'message': 'Starting albums update from Plex...'}) - - plex_manager = PlexManager(db=thread_db) - - plex_manager.populate_album_table() - - with app.app_context(): - socketio.emit('status_update', - {'message': 'Albums update completed. ' - 'Starting collections update...'}) - - all_collages = thread_db.get_all_collage_collections() - if all_collages: - logger.info('Updating %s collage collections...', len(all_collages)) - - update_collections_from_collages( - local_database=thread_db, - collage_list=all_collages, - plex_manager=plex_manager, - fetch_bookmarks=False, - fetch_mode=AlbumFetchMode.TORRENT_NAME - ) - logger.info('Collections update from collages completed!') - else: - logger.info('No stored collages found to update.') - - with app.app_context(): - socketio.emit('status_update', { - 'message': 'Albums and collections update completed successfully!', - 'finished': True - }) - except Exception as e: - logger.critical('An unhandled error occurred during album update: %s', - e, - exc_info=True) - with app.app_context(): - socketio.emit('status_update', { - 'message': f'Error updating albums: {str(e)}', - 'error': True - }) - finally: - if thread_db: - thread_db.close() - - socketio.start_background_task(target=update_albums) - - flash('Albums and collections update started!', 'info') - except Exception as e: - flash(f'Error starting albums update: {str(e)}', 'error') - - return redirect(url_for('database')) - - @app.route('/database/