diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..843bd71 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,30 @@ +[run] +source = . +omit = + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */.venv/* + */env/* + */site-packages/* + setup.py + conftest.py +branch = True +relative_files = True + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abc.abstractmethod + +[html] +directory = htmlcov + +[xml] +output = coverage.xml diff --git a/.cursor/rules/acepace-rules.mdc b/.cursor/rules/acepace-rules.mdc new file mode 100644 index 0000000..655bf89 --- /dev/null +++ b/.cursor/rules/acepace-rules.mdc @@ -0,0 +1,297 @@ +--- +description: Ace-Pace development rules — tests, lint, git workflow, and project conventions +alwaysApply: true +--- + +# Ace-Pace Project Rules + +This file contains the development rules, guidelines, and technical reference for the Ace-Pace project. These rules should be followed by all AI agents working on this codebase. + +## Core Workflow Requirements + +When working on this project, you MUST: + +1. **Always update tests when making changes** + - Update existing tests if functionality changes + - Add new tests for new features + - **Always run tests and verify they pass** before completing work + - Fix any failing tests + - Run: `pytest` with coverage before completing work + +2. **Always check for linter and SonarQube problems** + - Run linter checks and fix any issues + - Check SonarQube for code quality issues + - Fix all identified problems + +3. **Update documentation when appropriate** + - Review README.md after significant changes + - Update function documentation when signatures change + - Keep technical documentation accurate + +## Git Workflow Rules + +**CRITICAL: Follow these git rules strictly:** + +- **ABSOLUTELY NEVER run destructive git operations** (e.g., `git reset --hard`, `rm`, `git checkout`/`git restore` to an older commit) unless the user gives an explicit, written instruction. Treat these commands as catastrophic; if you are even slightly unsure, stop and ask before touching them. + +- **Never use `git restore`** (or similar commands) to revert files you didn't author—coordinate with other agents instead so their in-progress work stays intact. + +- **Always double-check git status** before any commit + +- **Keep commits atomic**: commit only the files you touched and list each path explicitly + - For tracked files: `git commit -m "" -- path/to/file1 path/to/file2` + - For brand-new files: `git restore --staged :/ && git add "path/to/file1" "path/to/file2" && git commit -m "" -- path/to/file1 path/to/file2 + +- **Quote any git paths** containing brackets or parentheses (e.g., `src/app/[candidate]/**`) when staging or committing so the shell does not treat them as globs or subshells. + +- **When running `git rebase`**, avoid opening editors—export `GIT_EDITOR=:` and `GIT_SEQUENCE_EDITOR=:` (or pass `--no-edit`) so the default messages are used automatically. + +- **Never amend commits** unless you have explicit written approval in the task thread. + +- **Delete unused or obsolete files** when your changes make them irrelevant (refactors, feature removals, etc.), and revert files only when the change is yours or explicitly requested. + +- **Before attempting to delete a file** to resolve a local type/lint failure, stop and ask the user. Other agents are often editing adjacent files; deleting their work to silence an error is never acceptable without explicit approval. + +- **NEVER edit `.env`** or any environment variable files—only the user may change them. + +- **Coordinate with other agents** before removing their in-progress edits—don't revert or delete work you didn't author unless everyone agrees. + +- **Moving/renaming and restoring files** is allowed. + +## Code Quality Standards + +- Follow PEP 8 Python style guide +- Maintain cognitive complexity ≤ 15 per function +- Use descriptive variable names +- Use docstrings for functions +- Keep functions focused and single-purpose +- Use `_` prefix for private/internal helper functions +- Comprehensive test suite exists in `tests/` directory (100+ tests) +- Ensure all tests pass before completing work + +## Project Overview + +**Ace-Pace** is a Python-based tool designed to help users manage and organize their One-Pace anime library. It automates: +- Identifying which One-Pace episodes are already in the user's local library +- Detecting missing episodes +- Automatically downloading missing episodes via BitTorrent clients +- Renaming local files to match official One-Pace naming conventions +- Maintaining a database of episode metadata and file checksums + +## Core Functionality + +### Episode Discovery and Indexing +- Scrapes Nyaa.si torrent tracker for One-Pace episodes +- Extracts CRC32 checksums from episode filenames or torrent file lists +- **Quality Filtering**: Only extracts episodes with 1080p quality + - Episodes without quality markers are excluded + - Episodes with quality other than 1080p are excluded + - Quality filtering is applied in both `fetch_episodes_metadata()` and `fetch_crc32_links()` + - Filtering is case-insensitive (accepts 1080P, etc.) +- **URL Parameter Support**: Both `fetch_episodes_metadata()` and `update_episodes_index_db()` accept a `base_url` parameter + - Quality filtering still applies regardless of URL parameters +- Builds and maintains an episodes index database (`episodes_index.db`) +- Supports both single-file and multi-file torrent structures +- Handles pagination to fetch all available episodes + +### Local Library Management +- Scans local directories recursively for video files (`.mkv`, `.mp4`, `.avi`) +- Calculates CRC32 checksums for local video files +- **Path Normalization**: All file paths are normalized before storage and lookup + - Uses `normalize_file_path()` to resolve symlinks and convert to absolute paths + - Ensures consistent path representation across different OS and environments + - Prevents cache misses when same file is accessed via different path representations + - **CRITICAL**: Always use `normalize_file_path()` before storing/querying file paths in database +- Caches CRC32 values in `crc32_files.db` to avoid recalculating +- Tracks file paths and their corresponding checksums (using normalized paths) + +### Missing Episode Detection +- Fetches episode list from Nyaa.si using the provided URL (default: One-Pace 1080p search) +- **Quality Filtering**: `fetch_crc32_links()` applies quality filtering via `_process_crc32_row()` + - Only accepts episodes with 1080p quality + - Requires "[One Pace]" marker in filename +- Compares local CRC32 checksums against fetched episodes (using normalized paths) +- Generates a CSV report (`Ace-Pace_Missing.csv`) listing missing episodes +- Uses `fetch_crc32_links()` for real-time fetching, not the cached episodes index + +### Automated Downloading +- Integrates with BitTorrent clients (Transmission, qBittorrent) +- Adds missing episodes to client via magnet links +- Supports custom download folders, tags, and categories +- Prevents duplicate torrent additions by checking existing torrents + +### File Renaming +- Matches local files to episodes index by CRC32 +- Renames files to match official One-Pace naming conventions +- Sanitizes filenames to remove problematic characters +- Updates database with new file paths after renaming (using normalized paths) + +## Technical Architecture + +### Databases + +#### `crc32_files.db` +- **Table: `crc32_cache`** + - `file_path` (TEXT, PRIMARY KEY): Normalized absolute path to local video file + - `crc32` (TEXT, UNIQUE): CRC32 checksum of the file + - **Note**: File paths are normalized using `normalize_file_path()` before storage +- **Table: `metadata`** + - `key` (TEXT, PRIMARY KEY): Metadata key + - `value` (TEXT): Metadata value + - Stores: `last_folder`, `last_run`, `last_checked_page`, `last_db_export`, `last_missing_export` + +#### `episodes_index.db` +- **Table: `episodes_index`** + - `crc32` (TEXT, PRIMARY KEY): CRC32 checksum from episode + - `title` (TEXT): Episode title/filename + - `page_link` (TEXT): URL to Nyaa.si torrent page +- **Table: `metadata`** + - `key` (TEXT, PRIMARY KEY): Metadata key + - `value` (TEXT): Metadata value + - Stores: `episodes_db_last_update` + +### Key Algorithms + +#### CRC32 Calculation +- Reads video files in 8KB chunks +- Uses Python's `zlib.crc32()` for incremental calculation +- Formats result as uppercase 8-character hexadecimal string +- Caches results to avoid redundant calculations +- Uses normalized file paths for cache lookups to ensure consistency + +#### CRC32 Extraction from Filenames +- Uses regex pattern: `\[([A-Fa-f0-9]{8})\]` +- Extracts CRC32 from square brackets in filenames +- Takes the last match if multiple CRC32s are present +- Validates that filename contains "[One Pace]" marker + +## Key Public API Functions + +- `main()`: Entry point for the application +- `init_db(suppress_messages=False)`: Initializes the local CRC32 cache database +- `init_episodes_db()`: Initializes the episodes index database +- `get_config_dir()`: Gets config directory (Docker: `ACEPACE_CONFIG_DIR_DOCKER` default `/config`; local: `ACEPACE_CONFIG_DIR_LOCAL` default `.`) +- `_get_default_media_dir()`: Default media folder (Docker: `ACEPACE_MEDIA_DIR_DOCKER` default `/media`; local: `ACEPACE_MEDIA_DIR_LOCAL` default empty) +- `get_config_path(filename)`: Gets full path to a config file in the appropriate config directory +- `normalize_file_path(file_path)`: Normalizes file path for consistent storage and lookup + - **CRITICAL**: Always use this before storing/querying file paths in database +- `get_metadata(conn, key)`: Retrieves metadata value from database +- `set_metadata(conn, key, value)`: Stores metadata value in database +- `get_episodes_metadata(conn, key)`: Retrieves episodes database metadata +- `set_episodes_metadata(conn, key, value)`: Stores episodes database metadata +- `fetch_episodes_metadata(base_url=None)`: Fetches episodes from Nyaa.si + - `base_url`: Optional Nyaa.si search URL (defaults to One-Pace search without quality filter) + - Quality filtering (1080p only) is always applied regardless of URL +- `update_episodes_index_db(base_url=None, force_update=False)`: Updates the episodes index database + - `base_url`: Optional Nyaa.si search URL (passed to `fetch_episodes_metadata()`) + - `force_update`: If True, force update even if recently updated. If False, skip if updated within last 10 minutes +- `fetch_crc32_links(base_url)`: Fetches CRC32 links from a Nyaa.si URL + - Applies quality filtering (1080p only) via `_process_crc32_row()` +- `fetch_title_by_crc32(crc32)`: Searches for a title by CRC32 +- `calculate_local_crc32(folder, conn)`: Calculates CRC32 for local files + - Uses normalized paths for database storage and lookup +- `rename_local_files(conn, dry_run=False)`: Renames local files based on episodes index; when dry_run=True only prints plan + - Uses normalized paths when updating database after renaming +- `_ensure_crc32_cache_complete(folder, conn)`: Ensures CRC32 cache has all video files in folder; runs `calculate_local_crc32` if any are missing (used before rename) +- `export_db_to_csv(conn)`: Exports database to CSV +- `load_crc32_to_title_from_index()`: Loads CRC32-to-title mapping + +## Private Helper Functions + +Helper functions are prefixed with `_` to indicate they are internal implementation details. + +**Extraction functions** (`_extract_*`): Extract data from HTML/structures +- `_extract_title_link_from_row(row)`: Extracts title link from table row +- `_extract_filenames_from_folder_structure(filelist_div)`: Extracts filenames from folder structure +- `_extract_filenames_from_torrent_page(torrent_soup)`: Extracts filenames from torrent page +- `_extract_matching_titles_from_rows(rows, crc32)`: Extracts titles matching CRC32 + +**Processing functions** (`_process_*`): Process data structures +- `_process_fname_entry(fname_text, ...)`: Processes filename entry to extract CRC32 +- `_process_torrent_page(page_link, ...)`: Processes torrent page to extract CRC32 +- `_process_episode_row(row, ...)`: Processes table row to extract episode info +- `_process_crc32_row(row, ...)`: Processes row to extract CRC32 for missing episodes + +**Validation functions** (`_is_*`, `_validate_*`): Validate inputs/data +- `_is_valid_quality(fname_text)`: Checks if filename has valid quality (1080p only) +- `_validate_url(url)`: Validates URL points to valid Nyaa domain + +**Command handlers** (`_handle_*`): Handle specific command-line operations +- `_handle_download_command(args)`: Handles the `--download` command +- `_handle_rename_command(conn, base_url=None, dry_run=False, folder=None)`: Handles the `--rename` command; calls `_ensure_crc32_cache_complete(folder, conn)` when folder is set, then renames + - `base_url`: Optional URL parameter passed to `update_episodes_index_db()` if update is needed + - `folder`: Media folder for CRC32 cache check (version-specific default when run from main) +- `_handle_main_commands(args, conn, folder)`: Routes and handles main commands + +## Docker Support + +- **Docker Mode**: Detected via `RUN_DOCKER` environment variable +- **Non-Interactive Operation**: In Docker mode, skips user prompts and uses defaults +- **Default Folder**: Uses `ACEPACE_MEDIA_DIR_DOCKER` (default `/media`) in Docker; `ACEPACE_MEDIA_DIR_LOCAL` (default empty) locally +- **Config/Data Paths**: `ACEPACE_CONFIG_DIR_DOCKER` (default `/config`), `ACEPACE_CONFIG_DIR_LOCAL` (default `.`) +- **Config Directory**: Uses `/config` directory in Docker mode for databases and CSV files + - Local mode uses current directory (`.`) + - Config directory is automatically created if it doesn't exist +- **Message Suppression**: In Docker mode, suppresses informational messages for automated commands +- **Environment Variables**: Supports configuration via Docker environment variables + - `DOWNLOAD`: Set to "true" to download missing episodes after generating report + - `RENAME`: Set to "true" to rename local files under `/media` (non-interactive; use `DRY_RUN=true` to simulate) + - `TORRENT_CLIENT`: BitTorrent client type (default: transmission) + - `TORRENT_HOST`: Client host address (default: localhost) + - `TORRENT_PORT`: Client port number (default: 9091 for transmission, 8080 for qBittorrent) + - `TORRENT_USER`: Client authentication username (optional) + - `TORRENT_PASSWORD`: Client authentication password (optional) + - `NYAA_URL`: Custom Nyaa.si search URL (optional, defaults to 1080p search) + - `EPISODES_UPDATE`: Set to "true" to update episodes index on container start + - `DB`: Set to "true" to export database on container start + - `RUN_DOCKER`: Flag to enable Docker mode (non-interactive) + - `DEBUG`: Enable debug output (set to `true`, `1`, `yes`, or `on`) + +## Critical Implementation Notes + +1. **CRC32 is the primary identifier** - All episode matching relies on CRC32 checksums +2. **Always use `normalize_file_path()`** before storing/querying file paths in database + - Ensures consistent path representation across different OS and environments + - Critical for consistent behavior between Python and Docker versions + - Resolves symlinks and converts to absolute paths +3. **Quality filtering (1080p only)** is always applied regardless of URL parameters + - Applied in both `fetch_episodes_metadata()` and `fetch_crc32_links()` via `_is_valid_quality()` + - Requires "[One Pace]" marker in filename +4. **Config directory handling**: Use `get_config_dir()` and `get_config_path()` for consistent file location handling + - Returns `/config` in Docker mode, `.` in local mode + - Automatically creates directory if it doesn't exist +5. **URL parameter consistency**: Both `fetch_episodes_metadata()` and `fetch_crc32_links()` accept URL parameters + - Always pass `args.url` to ensure consistent URL usage across functions +6. **Docker download logic**: Use `DOWNLOAD=true` environment variable to enable downloads + - Missing episodes CSV is always generated/updated before download (if download enabled) +7. **Default connection values**: In Docker mode, use defaults if not specified via environment variables + - Client: transmission + - Host: localhost + - Port: 9091 (transmission) or 8080 (qbittorrent) +8. **Debug mode**: Use `DEBUG` environment variable to control troubleshooting output + - Defaults to `false` (no debug output) + - Set to `true`, `1`, `yes`, or `on` to enable + - All debug output uses `debug_print()` function which checks `DEBUG_MODE` flag + +## BitTorrent Client Abstraction + +- Abstract base class `Client` (in `clients.py`) defines interface using `abc.ABC` +- Concrete implementations: `QBittorrentClient`, `TransmissionClient` +- Factory function `get_client(client_name, host, port, username, password)` instantiates appropriate client +- Methods: `add_torrents(magnets, download_folder, tags, category)` +- **qBittorrentClient**: Uses `qbittorrentapi` library, checks for existing torrents by info hash, supports tags and categories +- **TransmissionClient**: Uses Transmission RPC API via HTTP requests, handles session ID management, does not support tags/categories + +## Error Handling + +- Network errors: HTTP request failures are caught and logged, continues processing remaining items +- File system errors: Checks for file existence before operations, handles permission errors gracefully +- Database errors: Uses `INSERT OR REPLACE` for idempotent operations, handles connection failures +- Rate limiting: Uses `time.sleep(0.2)` between requests + +## Testing + +- Comprehensive test suite exists in `tests/` directory (100+ tests) +- Run `pytest` with coverage before completing work +- Ensure all tests pass +- Test coverage includes: clients, CRC32, database, debug mode, episodes, file operations, main commands, missing detection, path normalization diff --git a/.gemini/qbittorrent-api.md b/.gemini/qbittorrent-api.md new file mode 100644 index 0000000..dc9d07b --- /dev/null +++ b/.gemini/qbittorrent-api.md @@ -0,0 +1,1817 @@ +### Install qBittorrent API + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/README.md + +Installs the qBittorrent API client library using pip. + +```bash +python -m pip install qbittorrent-api +``` + +-------------------------------- + +### Install qbittorrent-api via pip + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Installs the qbittorrent-api package from PyPI using pip. This is the standard method for installing Python packages. + +```console +python -m pip install qbittorrent-api +``` + +-------------------------------- + +### Install qbittorrent-api from main branch + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Installs the qbittorrent-api package directly from the main branch of the GitHub repository. This is useful for accessing the latest development features or bug fixes. + +```console +pip install git+https://github.com/rmartin16/qbittorrent-api.git@main#egg=qbittorrent-api +``` + +-------------------------------- + +### Install a specific qbittorrent-api version + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Installs a specific version of the qbittorrent-api package from PyPI. Useful for ensuring compatibility with older projects or testing specific releases. + +```console +python -m pip install qbittorrent-api==2024.3.60 +``` + +-------------------------------- + +### Full Async Example with qbittorrent-api + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/async.rst + +A complete example showing how to initialize the qbittorrentapi client and fetch application build information asynchronously using `asyncio.to_thread`. This code can be run in the Python REPL. + +```python +import asyncio +import qbittorrentapi + +qbt_client = qbittorrentapi.Client() + +async def fetch_qbt_info(): + return await asyncio.to_thread(qbt_client.app_build_info) + +print(asyncio.run(fetch_qbt_info())) +``` + +-------------------------------- + +### qBittorrent Web API Reference + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/README.md + +Provides an overview of the qBittorrent Web API, detailing its version compatibility and features. It also links to comprehensive user guides and API references. + +```APIDOC +qBittorrent Web API Client + +Project: /rmartin16/qbittorrent-api + +Description: +Python client implementation for qBittorrent Web API. +Supports qBittorrent versions up to v5.1.2 (Web API v2.11.4). +Features: +- Implements the entire qBittorrent Web API. +- Automatically handles qBittorrent version checking for endpoint support. +- Automatically requests a new authentication cookie if the current one expires. + +Resources: +- User Guide and API Reference: https://qbittorrent-api.readthedocs.io/ +- qBittorrent GitHub Wiki (Web API): https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1) + +API Endpoints Overview: +(Note: Specific endpoint details are extensive and found in the linked documentation. This section summarizes the scope.) +- Application Information: Retrieve details about the qBittorrent application, including version, build info, and Web API version. +- Authentication: Log in, log out, and manage authentication cookies. +- Torrents Management: + - Add torrents (by URL or content). + - Retrieve torrent information (all, by hash, by state). + - Control torrent states (start, stop, pause, resume, delete). + - Manage torrent content (select/deselect files). + - Set torrent properties (download/upload limits, priority, category). +- Downloads Management: + - Control download limits. +- Peers Management: + - Retrieve peer information for torrents. +- Trackers Management: + - Update trackers for torrents. +- Search Management: + - Initiate and retrieve search results. +- Filters Management: + - Manage torrent filters. +- Tags Management: + - Manage tags for torrents. +- Options Management: + - Retrieve and modify qBittorrent settings. +- Web Server Management: + - Control the Web Server. +- RSS Feed Management: + - Manage RSS feeds. +- Transfer List Management: + - Control transfer list operations. + +Error Handling: +- Handles `qbittorrentapi.LoginFailed` for authentication errors. +- Automatically retries authentication on expiration. +``` + +-------------------------------- + +### SearchPluginsList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a list of installed search plugins. This structure allows for managing and querying the available search plugins within qBittorrent. + +```APIDOC +qbittorrentapi.search.SearchPluginsList: + __init__(...) + Initializes the SearchPluginsList. + + # Members are typically SearchPlugin objects, each representing an installed search plugin. +``` + +-------------------------------- + +### SearchPlugin + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a single installed search plugin, providing details about its name, version, and status. This is used to manage and interact with individual search plugins. + +```APIDOC +qbittorrentapi.search.SearchPlugin: + __init__(...) + Initializes the SearchPlugin. + + # Attributes typically include: + # - name (str): The name of the search plugin. + # - version (str): The version of the search plugin. + # - enabled (bool): Whether the plugin is currently enabled. +``` + +-------------------------------- + +### Basic qbittorrent-api Client Usage + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Demonstrates the basic usage of the qbittorrentapi Client, including instantiation, login, logout, and retrieving application information. + +```python +import qbittorrentapi + +# instantiate a Client using the appropriate WebUI configuration +conn_info = dict( + host="localhost", + port=8080, + username="admin", + password="adminadmin", +) +qbt_client = qbittorrentapi.Client(**conn_info) + +# the Client will automatically acquire/maintain a logged-in state +# in line with any request. therefore, this is not strictly necessary; +# however, you may want to test the provided login credentials. +try: + qbt_client.auth_log_in() +except qbittorrentapi.LoginFailed as e: + print(e) + +# if the Client will not be long-lived or many Clients may be created +# in a relatively short amount of time, be sure to log out: +qbt_client.auth_log_out() + +# or use a context manager: +with qbittorrentapi.Client(**conn_info) as qbt_client: + if qbt_client.torrents_add(urls="...") != "Ok.": + raise Exception("Failed to add torrent.") + +# display qBittorrent info +print(f"qBittorrent: {qbt_client.app.version}") +print(f"qBittorrent Web API: {qbt_client.app.web_api_version}") +for k, v in qbt_client.app.build_info.items(): + print(f"{k}: {v}") + +# retrieve and show all torrents +for torrent in qbt_client.torrents_info(): + print(f"{torrent.hash[-6:]}: {torrent.name} ({torrent.state})") + +# stop all torrents +qbt_client.torrents.stop.all() +``` + +-------------------------------- + +### Client Instantiation with Credentials + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Demonstrates how to instantiate the qbittorrentapi.client.Client with host, username, and password. + +```python +from qbittorrentapi import Client + +qbt_client = Client(host="localhost:8080", username='...', password='...') +``` + +-------------------------------- + +### qBittorrent Client Initialization and Torrent Management + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Demonstrates how to initialize the qBittorrent client and iterate through active torrents to perform common operations like setting location, reannouncing, and adjusting upload limits. + +```python +import qbittorrentapi + +qbt_client = qbittorrentapi.Client(host='localhost:8080', username='admin', password='adminadmin') + +for torrent in qbt_client.torrents.info.active(): + torrent.set_location(location='/home/user/torrents/') + torrent.reannounce() + torrent.upload_limit = -1 +``` + +-------------------------------- + +### Configuring HTTPAdapter Arguments + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Illustrates setting arguments for the requests.Session.HTTPAdapter during client instantiation using the HTTPADAPTER_ARGS parameter. + +```python +from qbittorrentapi import Client + +qbt_client = Client(host="localhost:8080", username='...', password='...', HTTPADAPTER_ARGS={"pool_connections": 100, "pool_maxsize": 100}) +``` + +-------------------------------- + +### qbittorrentapi.client.Client API + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/client.rst + +Provides comprehensive documentation for the Client class, including its methods for authentication, torrent management, and client configuration. This serves as the main entry point for interacting with the qBittorrent Web API. + +```APIDOC +Client: + __init__(host: str, port: int = 8080, username: str = None, password: str = None, **kwargs) + Initializes the qBittorrent API client. + Parameters: + host: The hostname or IP address of the qBittorrent instance. + port: The port number for the Web UI (default is 8080). + username: The username for authentication. + password: The password for authentication. + **kwargs: Additional keyword arguments for advanced configuration. + + auth_log_in() + Logs into the qBittorrent Web API. + Returns: True if login is successful, False otherwise. + + auth_log_out() + Logs out of the qBittorrent Web API. + Returns: True if logout is successful, False otherwise. + + get_torrent_list(status_filter: str = 'all', category: str = None, tag: str = None, sort: str = 'name', reverse: bool = False) -> list + Retrieves a list of torrents. + Parameters: + status_filter: Filter torrents by status (e.g., 'downloading', 'completed', 'paused', 'all'). + category: Filter torrents by category. + tag: Filter torrents by tag. + sort: Field to sort torrents by (e.g., 'name', 'size', 'progress'). + reverse: If True, sort in descending order. + Returns: A list of torrent dictionaries. + + add_torrent(torrent_files: list, urls: list = None, save_path: str = None, category: str = None, tags: str = None, is_paused: bool = False) + Adds one or more torrents to qBittorrent. + Parameters: + torrent_files: A list of torrent file contents (bytes). + urls: A list of magnet links or URLs to .torrent files. + save_path: The directory to save the torrents. + category: The category to assign to the torrents. + tags: Comma-separated string of tags to assign. + is_paused: If True, the torrents will be added in a paused state. + + pause_torrent(torrent_hash: str) + Pauses a specific torrent. + Parameters: + torrent_hash: The hash of the torrent to pause. + + resume_torrent(torrent_hash: str) + Resumes a paused torrent. + Parameters: + torrent_hash: The hash of the torrent to resume. + + delete_torrent(torrent_hash: str, delete_files: bool = False) + Deletes a torrent. + Parameters: + torrent_hash: The hash of the torrent to delete. + delete_files: If True, also deletes the torrent's data files. + + get_app_preferences() -> dict + Retrieves the application preferences. + Returns: A dictionary containing application settings. + + set_app_preferences(prefs: dict) + Sets the application preferences. + Parameters: + prefs: A dictionary of preferences to update. + + get_connection_status() -> dict + Retrieves the connection status of the client. + Returns: A dictionary with connection status information. + + shutdown_client() + Shuts down the qBittorrent client. + + reboot_client() + Reboots the qBittorrent client. + + get_web_api_version() -> str + Retrieves the version of the Web API. + Returns: The Web API version string. +``` + +-------------------------------- + +### qbittorrentapi.app.Application Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents the qBittorrent application and exposes methods for managing its settings, preferences, and retrieving information like build details and network interfaces. + +```APIDOC +qbittorrentapi.app.Application: + Manages qBittorrent application settings and retrieves information. + Excludes methods like app, application, webapiVersion, buildInfo, setPreferences, defaultSavePath, setCookies, networkInterfaceAddressList, networkInterfaceList, sendTestEmail, getDirectoryContent. +``` + +-------------------------------- + +### qBittorrent API Client Usage + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/README.md + +Demonstrates how to instantiate a qBittorrent API client, log in, log out, and interact with various API endpoints like adding torrents, retrieving application info, and managing torrent states. + +```python +import qbittorrentapi + +# instantiate a Client using the appropriate WebUI configuration +conn_info = dict( + host="localhost", + port=8080, + username="admin", + password="adminadmin", +) +qbt_client = qbittorrentapi.Client(**conn_info) + +# the Client will automatically acquire/maintain a logged-in state +# in line with any request. therefore, this is not strictly necessary; +# however, you may want to test the provided login credentials. +try: + qbt_client.auth_log_in() +except qbittorrentapi.LoginFailed as e: + print(e) + +# if the Client will not be long-lived or many Clients may be created +# in a relatively short amount of time, be sure to log out: +qbt_client.auth_log_out() + +# or use a context manager: +with qbittorrentapi.Client(**conn_info) as qbt_client: + if qbt_client.torrents_add(urls="...") != "Ok.": + raise Exception("Failed to add torrent.") + +# display qBittorrent info +print(f"qBittorrent: {qbt_client.app.version}") +print(f"qBittorrent Web API: {qbt_client.app.web_api_version}") +for k, v in qbt_client.app.build_info.items(): + print(f"{k}: {v}") + +# retrieve and show all torrents +for torrent in qbt_client.torrents_info(): + print(f"{torrent.hash[-6:]}: {torrent.name} ({torrent.state})") + +# stop all torrents +qbt_client.torrents.stop.all() +``` + +-------------------------------- + +### qbittorrentapi.app.BuildInfoDictionary + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a dictionary containing build information for the qBittorrent application. + +```APIDOC +qbittorrentapi.app.BuildInfoDictionary: + Dictionary structure for qBittorrent build information. +``` + +-------------------------------- + +### Namespace-Based Interaction with Web API + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Demonstrates a more organized and intuitive way to interact with the qBittorrent Web API using namespaces, allowing for easier management of preferences and torrent operations. + +```python +import qbittorrentapi +qbt_client = qbittorrentapi.Client(host='localhost:8080', username='admin', password='adminadmin') +# changing a preference +is_dht_enabled = qbt_client.app.preferences.dht +qbt_client.app.preferences = dict(dht=not is_dht_enabled) +# stopping all torrents +qbt_client.torrents.stop.all() +# retrieve different views of the log +qbt_client.log.main.warning() +``` + +-------------------------------- + +### Direct Method Calls for Web API Endpoints + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Illustrates how to interact with the qBittorrent Web API by directly calling methods on the client object, corresponding to individual API endpoints. + +```python +import qbittorrentapi +qbt_client = qbittorrentapi.Client(host='localhost:8080', username='admin', password='adminadmin') +qbt_client.app_version() +qbt_client.rss_rules() +qbt_client.torrents_info() +qbt_client.torrents_resume(torrent_hashes='...') +# and so on +``` + +-------------------------------- + +### Client Authentication with auth_log_in + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Shows how to explicitly log in a client instance using username and password. Authentication happens automatically for API requests. + +```python +qbt_client.auth_log_in(username='...', password='...') +``` + +-------------------------------- + +### Instantiate Client with Simple Responses + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/performance.rst + +Demonstrates how to instantiate the qbittorrentapi client with the SIMPLE_RESPONSES flag set to True to always receive simple JSON responses, improving performance by avoiding complex object conversions. + +```python +import qbittorrentapi + +qbt_client = qbittorrentapi.Client( + host='localhost:8080', + username='admin', + password='adminadmin', + SIMPLE_RESPONSES=True, +) +``` + +-------------------------------- + +### qbittorrentapi.app.AppAPIMixIn Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Provides methods for interacting with the qBittorrent application's API. This class serves as a mixin for application-related functionalities. + +```APIDOC +qbittorrentapi.app.AppAPIMixIn: + Methods related to application settings and information. +``` + +-------------------------------- + +### qbittorrentapi.sync.Sync Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/sync.rst + +Documentation for the Sync class, which handles synchronization operations. It includes all members, undocumented members, and the special '__call__' member. + +```APIDOC +qbittorrentapi.sync.Sync + :members: + :undoc-members: + :special-members: __call__ +``` + +-------------------------------- + +### qbittorrentapi.app.ApplicationPreferencesDictionary + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a dictionary for application preferences in qBittorrent. + +```APIDOC +qbittorrentapi.app.ApplicationPreferencesDictionary: + Dictionary structure for qBittorrent application preferences. +``` + +-------------------------------- + +### qbittorrentapi.sync.SyncAPIMixIn Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/sync.rst + +Documentation for the SyncAPIMixIn class, which provides synchronization-related methods. It excludes 'sync' and 'sync_torrentPeers' members and shows inheritance. + +```APIDOC +qbittorrentapi.sync.SyncAPIMixIn + :members: + :undoc-members: + :exclude-members: sync, sync_torrentPeers + :show-inheritance: +``` + +-------------------------------- + +### SearchAPIMixIn Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Provides search-related functionalities for interacting with qBittorrent's search engine. It includes methods for initiating searches, managing plugins, and retrieving search results. Excludes specific methods that might be handled by a base class or are internal. + +```APIDOC +qbittorrentapi.search.SearchAPIMixIn: + __init__(...) + Initializes the SearchAPIMixIn class. + + search(pattern: str, **kwargs) -> SearchResultsDictionary + Searches for torrents matching the given pattern. + Parameters: + pattern (str): The search query string. + **kwargs: Additional keyword arguments for search options (e.g., category, limit). + Returns: + SearchResultsDictionary: A dictionary containing the search results. + + search_installPlugin(url: str) + Installs a search plugin from the given URL. + Parameters: + url (str): The URL of the search plugin to install. + + search_uninstallPlugin(name: str) + Uninstalls a search plugin by its name. + Parameters: + name (str): The name of the search plugin to uninstall. + + search_enablePlugin(name: str) + Enables a search plugin by its name. + Parameters: + name (str): The name of the search plugin to enable. + + search_updatePlugins() + Updates all installed search plugins. + + search_downloadTorrent(file_url: str, save_path: str, **kwargs) + Downloads a torrent file from the given URL. + Parameters: + file_url (str): The URL of the torrent file. + save_path (str): The path where the torrent should be saved. + **kwargs: Additional keyword arguments for download options. +``` + +-------------------------------- + +### Configuring Request Timeouts + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Demonstrates setting request timeouts for all HTTP requests made by the client using the REQUESTS_ARGS parameter during instantiation. + +```python +from qbittorrentapi import Client + +qbt_client = Client(host="localhost:8080", username='...', password='...', REQUESTS_ARGS={'timeout': (3.1, 30)}) +``` + +-------------------------------- + +### Context Manager for Session Management + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Illustrates using a context manager with qbittorrentapi.Client for proper session handling, ensuring the client is logged in and managing session expiration. + +```python +import qbittorrentapi + +conn_info = { + "host": "localhost:8080", + "username": "...", + "password": "..." +} + +with qbittorrentapi.Client(**conn_info) as qbt_client: + if qbt_client.torrents_add(urls="...") != "Ok.": + raise Exception("Failed to add torrent.") +``` + +-------------------------------- + +### Handle Unsupported qBittorrent Versions + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Configure the client to raise an UnsupportedQbittorrentVersion exception for qBittorrent hosts with versions not fully supported by the client. This ensures compatibility with the client's features. + +```python +from qbittorrentapi import Client + +qbt_client = Client(..., RAISE_ERROR_FOR_UNSUPPORTED_QBITTORRENT_VERSIONS=True) +``` + +-------------------------------- + +### qbittorrentapi.auth.AuthAPIMixIn + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/auth.rst + +Provides authentication methods for interacting with the qBittorrent API. It includes methods for logging in and out, and managing authentication state. Inherits from object. + +```APIDOC +qbittorrentapi.auth.AuthAPIMixIn + __init__(self, auth_client) + Initializes the AuthAPIMixIn with an authentication client. + Parameters: + auth_client: The client responsible for authentication. + + login(self, username, password, **kwargs) + Logs into the qBittorrent Web API. + Parameters: + username (str): The username for authentication. + password (str): The password for authentication. + **kwargs: Additional keyword arguments for login. + Returns: True if login is successful, False otherwise. + + logout(self) + Logs out of the qBittorrent Web API. + Returns: True if logout is successful, False otherwise. + + is_logged_in(self) + Checks if the client is currently logged in. + Returns: True if logged in, False otherwise. + + is_logged_out(self) + Checks if the client is currently logged out. + Returns: True if logged out, False otherwise. +``` + +-------------------------------- + +### qbittorrent-api Documentation Structure + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/api.rst + +This section outlines the structure of the API documentation, indicating that detailed API documentation can be found within the 'apidoc/' directory. It uses a Sphinx toctree directive to organize the documentation. + +```APIDOC +.. toctree:: + :maxdepth: 2 + :glob: + + apidoc/* +``` + +-------------------------------- + +### Set Simple Responses for Individual Method Call + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/performance.rst + +Shows how to override the default behavior and request a simple JSON response for a specific method call by passing SIMPLE_RESPONSES=True as an argument. + +```python +qbt_client.torrents.files(torrent_hash='...', SIMPLE_RESPONSES=True) +``` + +-------------------------------- + +### qbittorrentapi.sync.SyncTorrentPeersDictionary Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/sync.rst + +Documentation for the SyncTorrentPeersDictionary class, used for representing synchronized torrent peers data. It includes all members, undocumented members, and shows inheritance. + +```APIDOC +qbittorrentapi.sync.SyncTorrentPeersDictionary + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### Instantiating qBittorrent API Client with Sub-Path (Python) + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/CHANGELOG.md + +This snippet demonstrates how to instantiate the qBittorrent API client when the qBittorrent Web API is accessible via a sub-path (e.g., behind a reverse proxy). It shows passing the combined host and sub-path to the `host` parameter of the `Client` constructor. This ensures that all API requests are correctly prefixed with the specified path. + +```Python +Client(host='localhost/qbt') +``` + +-------------------------------- + +### qbittorrentapi.app.NetworkInterfaceList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a list of network interfaces available on the qBittorrent client. + +```APIDOC +qbittorrentapi.app.NetworkInterfaceList: + List structure for network interfaces in qBittorrent. +``` + +-------------------------------- + +### qbittorrentapi.sync.SyncMainDataDictionary Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/sync.rst + +Documentation for the SyncMainDataDictionary class, used for representing synchronized main data. It lists all members, including undocumented ones, and shows inheritance. + +```APIDOC +qbittorrentapi.sync.SyncMainDataDictionary + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### Handling Untrusted Certificates + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Explains how to instantiate the Client with VERIFY_WEBUI_CERTIFICATE=False to handle untrusted or self-signed certificates, disabling certificate verification. + +```python +from qbittorrentapi import Client + +qbt_client = Client(host="localhost:8080", username='...', password='...', VERIFY_WEBUI_CERTIFICATE=False) +``` + +-------------------------------- + +### qbittorrentapi.app.DirectoryContentList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a list of directory content items returned by the qBittorrent API. + +```APIDOC +qbittorrentapi.app.DirectoryContentList: + List structure for directory content in qBittorrent. +``` + +-------------------------------- + +### Adding Custom HTTP Headers during Instantiation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Demonstrates how to include custom HTTP headers in all requests made by an instantiated client by using the EXTRA_HEADERS parameter. + +```python +from qbittorrentapi import Client + +qbt_client = Client(host="localhost:8080", username='...', password='...', EXTRA_HEADERS={'X-My-Fav-Header': 'header value'}) +``` + +-------------------------------- + +### qbittorrentapi.app.NetworkInterface + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a single network interface on the qBittorrent client. + +```APIDOC +qbittorrentapi.app.NetworkInterface: + Represents a single network interface. +``` + +-------------------------------- + +### qbittorrentapi.request Module Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/request.rst + +This section details the members, private members, undocumented members, and inheritance of the qbittorrentapi.request module. It serves as a comprehensive reference for interacting with the request functionalities within the library. + +```python +.. automodule:: qbittorrentapi.request + :members: + :private-members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### LogEntry Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Represents a single entry within the qBittorrent log. This class inherits from a base class, exposing all its members and undocumented members. + +```APIDOC +qbittorrentapi.log.LogEntry + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### AttrDict Class Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/attrdict.rst + +Provides documentation for the AttrDict class, detailing its members, undocumented members, and inheritance. AttrDict is an internal class for the qbittorrent-api library. + +```python +.. autoclass:: qbittorrentapi._attrdict.AttrDict + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### Handle Unimplemented API Endpoints + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Configure the client to raise a NotImplementedError for API endpoints that are not supported by the host's qBittorrent version. This is useful for early detection of compatibility issues. + +```python +from qbittorrentapi import Client + +qbt_client = Client(..., RAISE_NOTIMPLEMENTEDERROR_FOR_UNIMPLEMENTED_API_ENDPOINTS=True) +``` + +-------------------------------- + +### qBittorrent API - Torrent Operations + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/introduction.rst + +Provides an overview of the methods available for managing torrents through the qBittorrent API client. This includes setting download/upload limits, reannouncing, and changing the save location. + +```APIDOC +qbt_client.torrents.info.active() + - Retrieves a list of currently active torrents. + - Returns: A list of TorrentInfo objects. + +TorrentInfo.set_location(location: str) + - Sets the save location for a specific torrent. + - Parameters: + - location: The new directory path to save the torrent content. + - Returns: None. + +TorrentInfo.reannounce() + - Forces a reannounce for the torrent. + - Returns: None. + +TorrentInfo.upload_limit + - Gets or sets the upload speed limit for the torrent. + - Type: int + - Description: Set to -1 for unlimited upload speed. +``` + +-------------------------------- + +### qbittorrentapi._version_support.Version Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/version.rst + +Provides details on the Version class, its members, and undocumented members related to version support in the qbittorrent-api library. This class is crucial for ensuring compatibility between the client and the qBittorrent client. + +```APIDOC +qbittorrentapi._version_support.Version: + Represents and validates qBittorrent versions. + + Methods: + __init__(self, version_string: str) + Initializes the Version object with a version string. + Parameters: + version_string (str): The qBittorrent version string (e.g., "4.4.0"). + + __str__(self) -> str + Returns the string representation of the version. + + __repr__(self) -> str + Returns the detailed representation of the Version object. + + __eq__(self, other) + Checks if this version is equal to another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if versions are equal, False otherwise. + + __ne__(self, other) + Checks if this version is not equal to another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if versions are not equal, False otherwise. + + __lt__(self, other) + Checks if this version is less than another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if this version is less than the other, False otherwise. + + __le__(self, other) + Checks if this version is less than or equal to another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if this version is less than or equal to the other, False otherwise. + + __gt__(self, other) + Checks if this version is greater than another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if this version is greater than the other, False otherwise. + + __ge__(self, other) + Checks if this version is greater than or equal to another Version object or string. + Parameters: + other (Version | str): The version to compare against. + Returns: + bool: True if this version is greater than or equal to the other, False otherwise. + + is_at_least(self, required_version: str) -> bool + Checks if the current version is at least the required version. + Parameters: + required_version (str): The minimum required version string. + Returns: + bool: True if the version meets the requirement, False otherwise. + + Undocumented Members: + _version_tuple (tuple): Internal representation of the version as a tuple of integers. +``` + +-------------------------------- + +### qbittorrentapi.transfer.TransferInfoDictionary + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/transfer.rst + +Documentation for the TransferInfoDictionary class, which represents information related to transfers. This class inherits from other classes and includes various members that provide detailed transfer status and data. + +```APIDOC +TransferInfoDictionary: + Represents transfer information. + Includes members for detailed transfer status. +``` + +-------------------------------- + +### qbittorrentapi.transfer.TransferAPIMixIn Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/transfer.rst + +This section documents the methods available in the TransferAPIMixIn class, which provides core transfer management functionalities. It excludes specific methods related to speed limits and peer banning, which are detailed elsewhere. + +```APIDOC +TransferAPIMixIn: + Methods related to general transfer management. +``` + +-------------------------------- + +### Attr Class Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/attrdict.rst + +Provides documentation for the Attr class, detailing its members, undocumented members, and inheritance. Attr is an internal class for the qbittorrent-api library. + +```python +.. autoclass:: qbittorrentapi._attrdict.Attr + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### LogMainList Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Represents the main list structure for log entries. This class inherits from a base class, exposing all its members and undocumented members. + +```APIDOC +qbittorrentapi.log.LogMainList + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### Adding Custom HTTP Headers for Individual Requests + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Shows how to send custom HTTP headers for specific API calls using the headers parameter. + +```python +qbt_client.torrents.add(headers={'X-My-Fav-Header': 'header value'}) +``` + +-------------------------------- + +### Search Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents the core search functionality in qBittorrent. This class likely encapsulates the logic for executing searches and managing search-related operations. It excludes methods related to plugin management, which are handled by SearchAPIMixIn. + +```APIDOC +qbittorrentapi.search.Search: + __init__(...) + Initializes the Search class. + + installPlugin(url: str) + Installs a search plugin from the given URL. + Parameters: + url (str): The URL of the search plugin to install. + + uninstallPlugin(name: str) + Uninstalls a search plugin by its name. + Parameters: + name (str): The name of the search plugin to uninstall. + + enablePlugin(name: str) + Enables a search plugin by its name. + Parameters: + name (str): The name of the search plugin to enable. + + updatePlugins() + Updates all installed search plugins. + + downloadTorrent(file_url: str, save_path: str, **kwargs) + Downloads a torrent file from the given URL. + Parameters: + file_url (str): The URL of the torrent file. + save_path (str): The path where the torrent should be saved. + **kwargs: Additional keyword arguments for download options. +``` + +-------------------------------- + +### TorrentCreator Class Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +This section outlines the methods of the TorrentCreator class, responsible for the core logic of creating torrents. It excludes methods like addTask, torrentFile, and deleteTask, which are likely internal or handled by the mixin. + +```APIDOC +qbittorrentapi.torrentcreator.TorrentCreator + __init__(...) + Initializes the TorrentCreator. + + createTorrent(..., **kwargs) + Creates a torrent file with specified parameters. + Parameters: + ...: Parameters for torrent creation (e.g., files, trackers, name). + Returns: + The created torrent file content or path. + + getTaskStatus(..., **kwargs) + Retrieves the status of a torrent creation task. + Parameters: + task_id: The ID of the task. + Returns: + The status of the specified task. +``` + +-------------------------------- + +### TorrentCreatorAPIMixIn Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +This section details the methods available in the TorrentCreatorAPIMixIn class, which serves as a mixin for torrent creation functionalities. It excludes specific internal methods like torrentcreator, torrentcreator_addTask, torrentcreator_torrentFile, and torrentcreator_deleteTask. + +```APIDOC +qbittorrentapi.torrentcreator.TorrentCreatorAPIMixIn + __init__(...) + Initializes the TorrentCreatorAPIMixIn. + + addTorrent(..., **kwargs) + Adds a torrent using the creator. + Parameters: + ...: Various parameters for torrent creation. + Returns: + The result of adding the torrent. + + createTorrent(..., **kwargs) + Creates a torrent file. + Parameters: + ...: Various parameters for torrent creation. + Returns: + The result of creating the torrent file. + + deleteTorrent(..., **kwargs) + Deletes a torrent task. + Parameters: + ...: Various parameters for deleting a torrent task. + Returns: + The result of deleting the torrent task. + + getTorrentCreatorTask(..., **kwargs) + Retrieves a specific torrent creator task. + Parameters: + ...: Parameters to identify the task. + Returns: + The torrent creator task details. + + getTorrentCreatorTasks(..., **kwargs) + Retrieves a list of all torrent creator tasks. + Parameters: + ...: Optional parameters for filtering or pagination. + Returns: + A list of torrent creator tasks. + + updateTorrent(..., **kwargs) + Updates an existing torrent task. + Parameters: + ...: Parameters for updating the torrent task. + Returns: + The result of updating the torrent task. +``` + +-------------------------------- + +### RSSAPIMixIn Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/rss.rst + +Provides methods for interacting with the RSS feed functionalities. This class includes methods for managing RSS feeds, items, and rules. Specific methods like rss_addFolder, rss_addFeed, rss_removeItem, etc., are excluded from this documentation block. + +```APIDOC +qbittorrentapi.rss.RSSAPIMixIn: + Methods for RSS feed management. + Excludes: rss, rss_addFolder, rss_addFeed, rss_removeItem, rss_moveItem, rss_refreshItem, rss_markAsRead, rss_setRule, rss_renameRule, rss_removeRule, rss_matchingArticles, rss_setFeedURL +``` + +-------------------------------- + +### Torrents API Reference + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrents.rst + +This section provides a detailed reference for the Torrents API, outlining the available classes and their methods for managing torrents, files, categories, tags, and web seeds. + +```APIDOC +qbittorrentapi.torrents.TorrentsAPIMixIn: + Manages core torrent operations. + Methods: + (See excluded members for specific functionalities) + +qbittorrentapi.torrents.Torrents: + Provides access to torrent-related functionalities. + Methods: + (See excluded members for specific functionalities) + +qbittorrentapi.torrents.TorrentDictionary: + Represents a torrent with its properties. + Methods: + (See excluded members for specific functionalities) + +qbittorrentapi.torrents.TorrentCategories: + Manages torrent categories. + Methods: + removeCategories(categories: list[str]) -> None + Removes specified categories. + editCategory(old_name: str, new_name: str) -> None + Renames a category. + createCategory(name: str, save_path: str = None) -> None + Creates a new category. + +qbittorrentapi.torrents.TorrentTags: + Manages torrent tags. + Methods: + addTags(torrent_hashes: str, tags: str) -> None + Adds tags to torrents. + removeTags(torrent_hashes: str, tags: str) -> None + Removes tags from torrents. + createTags(tags: str) -> None + Creates new tags. + deleteTags(tags: str) -> None + Deletes specified tags. + setTags(torrent_hashes: str, tags: str) -> None + Sets tags for torrents, overwriting existing ones. + +qbittorrentapi.torrents.TorrentPropertiesDictionary: + Dictionary for torrent properties. + +qbittorrentapi.torrents.TorrentLimitsDictionary: + Dictionary for torrent speed limits. + +qbittorrentapi.torrents.TorrentCategoriesDictionary: + Dictionary for torrent categories. + +qbittorrentapi.torrents.TorrentsAddPeersDictionary: + Dictionary for adding peers to torrents. + +qbittorrentapi.torrents.TorrentFilesList: + List of files within a torrent. + +qbittorrentapi.torrents.TorrentFile: + Represents a single file in a torrent. + +qbittorrentapi.torrents.WebSeedsList: + List of web seeds for a torrent. + +qbittorrentapi.torrents.WebSeed: + Represents a single web seed. + +qbittorrentapi.torrents.TrackersList: + List of trackers for a torrent. + +qbittorrentapi.torrents.Tracker: + Represents a single tracker. + +qbittorrentapi.torrents.TorrentInfoList: + List of torrent information. + +qbittorrentapi.torrents.TorrentPieceInfoList: + List of piece information for a torrent. + +qbittorrentapi.torrents.TorrentPieceData: + Data for a specific piece of a torrent. + +qbittorrentapi.torrents.TagList: + List of tags. + +qbittorrentapi.torrents.Tag: + Represents a single tag. +``` + +-------------------------------- + +### LogAPIMixIn Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Provides methods for interacting with the qBittorrent log API. It serves as a mixin class, likely intended to be inherited by other classes that require log functionality. Specific methods are exposed through its members, excluding the 'log' attribute. + +```APIDOC +qbittorrentapi.log.LogAPIMixIn + :members: + :undoc-members: + :exclude-members: log + :show-inheritance: +``` + +-------------------------------- + +### Setting Timeouts for Individual Requests + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Shows how to specify request timeouts for individual API calls using the requests_args parameter. + +```python +qbt_client.torrents_info(requests_args={'timeout': (3.1, 30)}) +``` + +-------------------------------- + +### qbittorrentapi.transfer.Transfer Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/transfer.rst + +This section documents the methods of the Transfer class, focusing on transfer operations. It excludes methods for toggling speed limits, setting limits, and banning peers, which are handled separately. + +```APIDOC +Transfer: + Methods for managing transfer operations. +``` + +-------------------------------- + +### SearchJobDictionary + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a dictionary structure for search jobs, likely containing information about ongoing or completed search operations. It inherits from a base dictionary type and includes specific members relevant to search jobs. + +```APIDOC +qbittorrentapi.search.SearchJobDictionary: + __init__(...) + Initializes the SearchJobDictionary. + + # Members typically include information about: + # - Job ID + # - Search query + # - Status (e.g., running, completed, failed) + # - Progress + # - Number of results found +``` + +-------------------------------- + +### qbittorrentapi Exception Hierarchy + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/exceptions.rst + +This section outlines the exception classes provided by the qbittorrentapi library, detailing their inheritance structure and available members. It helps developers understand and handle potential errors during interaction with the qBittorrent API. + +```python +import qbittorrentapi + +try: + # Attempt to interact with qBittorrent API + pass +except qbittorrentapi.LoginFailed as e: + print(f"Login failed: {e}") +except qbittorrentapi.APIConnectionError as e: + print(f"Connection error: {e}") +except qbittorrentapi.NotFoundHTTPError as e: + print(f"Resource not found: {e}") +except qbittorrentapi.ForbiddenHTTPError as e: + print(f"Forbidden access: {e}") +except qbittorrentapi.BadRequestHTTPError as e: + print(f"Bad request: {e}") +except qbittorrentapi.ServerError as e: + print(f"Server error: {e}") +except qbittorrentapi.QBittorrentError as e: + print(f"An unexpected qBittorrent API error occurred: {e}") +except Exception as e: + print(f"An unexpected error occurred: {e}") +``` + +-------------------------------- + +### Log Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Represents the main log functionality within the qbittorrent-api. This class exposes various members for accessing and managing log data. It also supports being called directly, indicated by the special member '__call__'. + +```APIDOC +qbittorrentapi.log.Log + :members: + :undoc-members: + :special-members: __call__ +``` + +-------------------------------- + +### LogPeer Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Represents a single log peer entry. This class inherits from a base class, exposing all its members and undocumented members. + +```APIDOC +qbittorrentapi.log.LogPeer + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### MutableAttr Class Documentation + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/attrdict.rst + +Provides documentation for the MutableAttr class, detailing its members, undocumented members, and inheritance. MutableAttr is an internal class for the qbittorrent-api library. + +```python +.. autoclass:: qbittorrentapi._attrdict.MutableAttr + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### qbittorrentapi.app.CookieList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a list of cookies used with the qBittorrent API. + +```APIDOC +qbittorrentapi.app.CookieList: + List structure for cookies in qBittorrent. +``` + +-------------------------------- + +### TorrentCreatorTaskStatus Members + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +This section describes the members of the TorrentCreatorTaskStatus class, which likely enumerates the possible states for a torrent creation task. + +```APIDOC +qbittorrentapi.torrentcreator.TorrentCreatorTaskStatus + PENDING = 'pending' + The task is waiting to be processed. + + PROCESSING = 'processing' + The task is currently being processed. + + COMPLETED = 'completed' + The task has finished successfully. + + FAILED = 'failed' + The task failed to complete. +``` + +-------------------------------- + +### Manual qBittorrent Version Introspection + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Manually check if a qBittorrent application version is supported by the client using the Version.is_app_version_supported method. This allows for custom handling of version compatibility. + +```python +from qbittorrentapi import Client, Version + +qbt_client = Client(...) + +if Version.is_app_version_supported(qbt_client.app.version): + print("qBittorrent version is supported.") +else: + print("qBittorrent version is not supported.") +``` + +-------------------------------- + +### RSS Class Methods + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/rss.rst + +Represents the core RSS functionality. It includes methods for managing RSS feeds and items. The special member __call__ is documented. Methods like rss, addFolder, addFeed, removeItem, etc., are excluded. + +```APIDOC +qbittorrentapi.rss.RSS: + Core RSS functionality. + Special Members: __call__ + Excludes: rss, addFolder, addFeed, removeItem, moveItem, refreshItem, markAsRead, setRule, renameRule, removeRule, matchingArticles, setFeedURL +``` + +-------------------------------- + +### qbittorrentapi.auth.Authorization + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/auth.rst + +Represents authorization details for API requests. This class is typically used internally by the authentication mixin. + +```APIDOC +qbittorrentapi.auth.Authorization + __init__(self, username, password) + Initializes the Authorization object with username and password. + Parameters: + username (str): The username for authorization. + password (str): The password for authorization. + + username + The username associated with this authorization. + + password + The password associated with this authorization. +``` + +-------------------------------- + +### qbittorrentapi.definitions Module Members + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/definitions.rst + +This section details the members of the qbittorrentapi.definitions module. It includes all documented members but excludes the TorrentStates enum and undocumented members. The show-inheritance flag indicates that inheritance relationships are displayed. + +```python +import qbittorrentapi + +# Accessing members of the definitions module +# Example: Accessing a specific definition class or function +# print(dir(qbittorrentapi.definitions)) + +# The following is a representation of what might be documented within the module. +# Specific members are not listed here as they are dynamically generated by the automodule directive. + +# Example of a potential class within definitions: +# class SomeDefinition: +# """A sample definition class.""" +# def __init__(self, value): +# self.value = value + +# Example of a potential function within definitions: +# def some_function(param1: str) -> int: +# """A sample function.""" +# return len(param1) + +# The automodule directive with :members:, :undoc-members:, and :show-inheritance: +# implies that the following would be generated and displayed in the documentation: +# - All public members (functions, classes, variables) +# - Undocumented members (if any) +# - Inheritance hierarchy for classes +# - Excludes 'TorrentStates' as specified. +``` + +-------------------------------- + +### TorrentCreatorTaskStatusList Members + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +This section describes the members of the TorrentCreatorTaskStatusList class, which is likely a container for multiple torrent creation task statuses. + +```APIDOC +qbittorrentapi.torrentcreator.TorrentCreatorTaskStatusList + __init__(...) + Initializes a TorrentCreatorTaskStatusList. + + tasks: list[TorrentCreatorTaskDictionary] + A list containing TorrentCreatorTaskDictionary objects. +``` + +-------------------------------- + +### LogPeersList Class + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/log.rst + +Defines the structure for a list of log peers. This class inherits from a base class, indicated by 'show-inheritance', and includes all its members and undocumented members. + +```APIDOC +qbittorrentapi.log.LogPeersList + :members: + :undoc-members: + :show-inheritance: +``` + +-------------------------------- + +### Fetch Torrents Asynchronously + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/async.rst + +Demonstrates how to fetch torrent information asynchronously by running the blocking `qbt_client.torrents_info` method in a separate thread using `asyncio.to_thread`. This prevents blocking the asyncio event loop. + +```python +async def fetch_torrents() -> TorrentInfoList: + return await asyncio.to_thread(qbt_client.torrents_info, category="uploaded") +``` + +-------------------------------- + +### SearchCategoriesList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a list of available search categories. This structure is used to manage and retrieve the categories that can be used when performing searches. + +```APIDOC +qbittorrentapi.search.SearchCategoriesList: + __init__(...) + Initializes the SearchCategoriesList. + + # Members are typically SearchCategory objects, each representing a searchable category. +``` + +-------------------------------- + +### SearchStatusesList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a list of search statuses, likely used to track the state of multiple search operations. It inherits from a base list type and contains individual SearchStatus objects. + +```APIDOC +qbittorrentapi.search.SearchStatusesList: + __init__(...) + Initializes the SearchStatusesList. + + # Members are typically SearchStatus objects, each representing the status of a single search job. +``` + +-------------------------------- + +### SearchCategory + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a single search category, providing its name and potentially other relevant information. This is used to define the types of content that can be searched for. + +```APIDOC +qbittorrentapi.search.SearchCategory: + __init__(...) + Initializes the SearchCategory. + + # Attributes typically include: + # - name (str): The name of the search category (e.g., 'all', 'movies', 'music'). + # - supported_by (list): A list of plugins that support this category. +``` + +-------------------------------- + +### SearchStatus + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents the status of a single search job, providing details about its progress and completion. It inherits from a base object type and includes specific attributes for status information. + +```APIDOC +qbittorrentapi.search.SearchStatus: + __init__(...) + Initializes the SearchStatus. + + # Attributes typically include: + # - status (str): The current status of the search (e.g., 'Running', 'Completed', 'Error'). + # - progress (int): The progress of the search in percentage. + # - total (int): The total number of items found. +``` + +-------------------------------- + +### TorrentCreatorTaskDictionary Members + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +Details the members of the TorrentCreatorTaskDictionary class, used for representing torrent creation tasks. It excludes torrentFile and deleteTask, suggesting these might be handled at a different level. + +```APIDOC +qbittorrentapi.torrentcreator.TorrentCreatorTaskDictionary + __init__(...) + Initializes a TorrentCreatorTaskDictionary. + + task_id: int + The unique identifier for the torrent creation task. + + status: TorrentCreatorTaskStatus + The current status of the task. + + progress: float + The progress of the torrent creation task (0.0 to 1.0). + + created_torrent_path: str + The file path where the torrent was created. + + error_message: str + An error message if the task failed. +``` + +-------------------------------- + +### SearchResultsDictionary + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/search.rst + +Represents a dictionary structure for search results, containing a list of torrents that match a search query. It inherits from a base dictionary type and provides access to individual search results. + +```APIDOC +qbittorrentapi.search.SearchResultsDictionary: + __init__(...) + Initializes the SearchResultsDictionary. + + # Members typically include: + # - A list of SearchResults (or similar objects) + # - Total number of results + # - Pagination information (if applicable) +``` + +-------------------------------- + +### qbittorrentapi.app.NetworkInterfaceAddressList + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a list of IP addresses associated with a network interface. + +```APIDOC +qbittorrentapi.app.NetworkInterfaceAddressList: + List structure for network interface addresses in qBittorrent. +``` + +-------------------------------- + +### qbittorrentapi.app.Cookie + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/app.rst + +Represents a cookie used for authentication or other purposes with the qBittorrent API. + +```APIDOC +qbittorrentapi.app.Cookie: + Represents a single cookie. +``` + +-------------------------------- + +### TaskStatus Members + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/torrentcreator.rst + +Details the members of the TaskStatus class, which appears to be an alias or a more general status indicator, possibly for tasks within the torrent creator system. + +```APIDOC +qbittorrentapi.torrentcreator.TaskStatus + PENDING = 'pending' + Task is pending. + + PROCESSING = 'processing' + Task is currently processing. + + COMPLETED = 'completed' + Task has been completed. + + FAILED = 'failed' + Task has failed. +``` + +-------------------------------- + +### RSSitemsDictionary Structure + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/rss.rst + +Defines the structure for dictionaries containing RSS items. This class is used to represent collections of RSS feed items. + +```APIDOC +qbittorrentapi.rss.RSSitemsDictionary: + Dictionary structure for RSS items. + Inheritance: show-inheritance +``` + +-------------------------------- + +### RSSRulesDictionary Structure + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/apidoc/rss.rst + +Defines the structure for dictionaries containing RSS rules. This class is used to represent collections of RSS feed rules. + +```APIDOC +qbittorrentapi.rss.RSSRulesDictionary: + Dictionary structure for RSS rules. + Inheritance: show-inheritance +``` + +-------------------------------- + +### Disable Logging Debug Output + +Source: https://github.com/rmartin16/qbittorrent-api/blob/main/docs/source/behavior&configuration.rst + +Disable debug logging for the qbittorrentapi and related packages by setting the logger level to INFO. This can be done during client instantiation or by directly configuring loggers. + +```python +import logging +from qbittorrentapi import Client + +# Option 1: During client instantiation +# qbt_client = Client(..., DISABLE_LOGGING_DEBUG_OUTPUT=True) + +# Option 2: Manually configure loggers +logging.getLogger('qbittorrentapi').setLevel(logging.INFO) +logging.getLogger('requests').setLevel(logging.INFO) +logging.getLogger('urllib3').setLevel(logging.INFO) +``` + +=== COMPLETE CONTENT === This response contains all available snippets from this library. No additional content exists. Do not make further requests. \ No newline at end of file diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..e111a53 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,43 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - dev + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine image tag + id: meta + run: | + if [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "tag=latest" >> $GITHUB_OUTPUT + elif [ "${{ github.ref }}" == "refs/heads/dev" ]; then + echo "tag=dev" >> $GITHUB_OUTPUT + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: timothe/ace-pace:${{ steps.meta.outputs.tag }} diff --git a/.gitignore b/.gitignore index d6bbd50..9795fe7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,17 @@ pip-log.txt *.log Ace-Pace_DB.csv Ace-Pace_Missing.csv +crc32_files.db +check.sh +sonar-project.properties + +# Coverage reports +.coverage +.coverage.* +htmlcov/ +.tox/ +*.cover +.hypothesis/ +.pytest_cache/ +# Keep coverage.xml for SonarQube (but can be regenerated) +# coverage.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a58353 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.13-slim + +LABEL description="Ace-Pace - One Pace Library Manager" + +COPY . /app +WORKDIR /app + +ENV RUN_DOCKER="true" \ + PYTHONUNBUFFERED=1 \ + TZ=Europe/Berlin \ + TORRENT_HOST="127.0.0.1" \ + TORRENT_CLIENT="transmission" \ + TORRENT_PORT="9091" \ + TORRENT_USER="" \ + TORRENT_PASSWORD="" +# Note: NYAA_URL, DB, and EPISODES_UPDATE should be set in docker-compose.yml, not here +# This allows users to override them without rebuilding the image + +RUN apt-get update \ + && apt-get install -y tzdata \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir -r /app/requirements.txt \ + && chmod +x /app/entrypoint.sh + +CMD ["/app/entrypoint.sh"] diff --git a/README.md b/README.md index 1ae9372..32e6f67 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# Ace-Pace +# 🏴‍☠️ Ace-Pace Welcome to **Ace-Pace**, your ultimate companion for organizing and managing your One-Pace library with precision and ease! Whether you're a casual viewer who wants a neat collection or a hardcore fan aiming for the perfect sync between episodes and the official One-Pace releases, Ace-Pace is designed to make your life simpler, your library cleaner, and your watching experience smoother. One-Pace is a fantastic fan project that trims the One Piece anime down to its essential story arcs, removing filler and pacing issues to deliver a tighter, more engaging narrative. However, managing your One-Pace episodes, ensuring you have all the latest releases can be a daunting task. That's where Ace-Pace comes in — it automates the heavy lifting, letting you focus on enjoying the adventure. -## How to Install +## 🚀 How to Install To get started with Ace-Pace, you'll need to have Python installed on your system. We recommend using Python 3.6 or higher. You can download Python from the [official website](https://www.python.org/downloads/). @@ -18,35 +18,251 @@ pip install -r requirements.txt This will install all necessary packages to ensure Ace-Pace runs smoothly. -## How to Use +## 🐳 Docker Usage + +Ace-Pace can also be run using Docker, which simplifies deployment and ensures consistent execution across different environments. + +### Using Docker Run + +You can run Ace-Pace using `docker run` with environment variables and volume mounts: + +```bash +docker run --rm \ + -v /path/to/OnePaceLibrary:/media:rw \ + -v /path/to/config:/config:rw \ + -e TZ=Europe/London \ + -e DB=true \ + -e EPISODES_UPDATE=true \ + -e DOWNLOAD=false \ + -e DRY_RUN=false \ + -e TORRENT_CLIENT=transmission \ + -e TORRENT_HOST=127.0.0.1 \ + -e TORRENT_PORT=9091 \ + -e NYAA_URL=https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc \ + -e DEBUG=true \ + timothe/ace-pace:latest +``` + +### Using Docker Compose + +For easier management, you can use the provided `docker-compose.yml` file. First, edit the compose file to match your setup: + +1. Update the volume paths: + ```yaml + volumes: + - /path/to/OnePaceLibrary:/media:rw + - /path/to/config:/config:rw + ``` + +2. Configure environment variables as needed (Torrent client settings, Nyaa URL, etc.) + +3. Run with: + ```bash + docker-compose up + ``` + +Or run in detached mode: +```bash +docker-compose up -d +``` + +### Docker Environment Variables + +The following environment variables can be used to configure Ace-Pace in Docker: + +- `NYAA_URL` - Nyaa.si search URL (optional, default: `https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc`) + - When not set, uses default URL without quality filter. Quality filtering (1080p only) is always applied in code. +- `DB` - Set to `true` to generate CSV database export on container start (default: `false`) +- `EPISODES_UPDATE` - Set to `true` to update episodes metadata from Nyaa on container start (default: `false`) +- `DOWNLOAD` - Set to `true` to automatically download missing episodes after generating report (default: `false`) +- `RENAME` - Set to `true` to rename local files in the media folder to match One-Pace episode titles from the episodes index (default: `false`) + - Non-interactive: no confirmation prompt; use `DRY_RUN=true` to simulate renaming without changing files + - Before renaming, ensures CRC32 cache is complete for the media folder (calculates missing CRC32s if needed) +- `ACEPACE_MEDIA_DIR_DOCKER` - Media/library folder in Docker (default: `"/media"`). Entrypoint passes this as `--folder`. +- `ACEPACE_CONFIG_DIR_DOCKER` - Config/data directory in Docker (default: `"/config"`). Not set in entrypoint; override if you mount config elsewhere. +- `DRY_RUN` - When `DOWNLOAD=true`: test BitTorrent client without adding torrents. When `RENAME=true`: show rename plan without renaming (default: `false`) + - With download: validates magnet links and checks existing torrents but does not add any downloads + - With rename: prints which files would be renamed without modifying the filesystem +- `TORRENT_CLIENT` - BitTorrent client type: `transmission` or `qbittorrent` (default: `transmission`) +- `TORRENT_HOST` - BitTorrent client host address (default: `localhost`) +- `TORRENT_PORT` - BitTorrent client port (default: `9091` for Transmission, `8080` for qBittorrent) +- `TORRENT_USER` - BitTorrent client username (optional) +- `TORRENT_PASSWORD` - BitTorrent client password (optional) +- `DEBUG` - Enable debug output for troubleshooting (default: `false`) + - Set to `true`, `1`, `yes`, or `on` to enable detailed debug information + - When enabled, shows troubleshooting info, sample CRC32s, comparison details, and processing statistics + - Useful for diagnosing issues with missing episode detection or data processing +- `TZ` - Timezone (default: `Europe/London`) + +### Docker Volume Mounts + +The following volumes should be mounted for persistent data: + +- **Media folder** (default `/media`) - Mount your One-Pace library here (read-write). Override with `ACEPACE_MEDIA_DIR_DOCKER`. +- **Config folder** (default `/config`) - Mount a directory for persistent configuration and data files (read-write). Override with `ACEPACE_CONFIG_DIR_DOCKER`. + - Contains: `crc32_files.db`, `episodes_index.db`, `Ace-Pace_Missing.csv`, `Ace-Pace_DB.csv` + - `episodes_index.db` now stores magnet links for all episodes, reducing the need to fetch them repeatedly + +### Docker Execution Flow + +When the container starts, it executes the following steps in order: + +1. **Episodes Update** (if `EPISODES_UPDATE=true`): Updates the episodes metadata database from Nyaa, including magnet links for all episodes +2. **Database Export** (if `DB=true`): Exports the CRC32 database to CSV +3. **Missing Episodes Report**: Always runs to generate/update `Ace-Pace_Missing.csv` (unless only DB export was requested) +4. **Rename** (if `RENAME=true`): Ensures CRC32 cache is complete for the media folder, then renames local files to match One-Pace episode titles (no confirmation). Use `DRY_RUN=true` to simulate only. +5. **Download** (if `DOWNLOAD=true`): Automatically downloads missing episodes via the configured BitTorrent client + - If `DRY_RUN=true`, tests connection and validates magnet links without adding torrents + +### Docker Notes + +- In Docker mode, the default media folder is `/media` (set `ACEPACE_MEDIA_DIR_DOCKER` to override); config/data default is `/config` (set `ACEPACE_CONFIG_DIR_DOCKER` to override) +- The container runs non-interactively, so all configuration must be provided via environment variables +- All data files (databases, CSV exports) are stored in the config directory +- Quality filtering (1080p only) is applied in code regardless of the URL used +- When `NYAA_URL` is not set, the default URL searches for all "one pace" episodes without quality filter, then filters for 1080p in code +- Make sure your BitTorrent client is accessible from within the Docker network (use host network mode or configure networking appropriately) + +### VPN Considerations + +If you're running Ace-Pace through a VPN container (such as Gluetun), you may encounter 429 (Too Many Requests) errors when querying Nyaa.si. This is because multiple requests from the same VPN exit node can trigger rate limiting. + +**Recommendation:** It's perfectly fine to run Ace-Pace without a VPN. Instead, keep your BitTorrent client behind the VPN to protect your downloads while allowing Ace-Pace to query Nyaa.si directly without rate limiting issues. + +## 🧪 Running Tests + +To run the test suite with coverage: + +```bash +# Install test dependencies +pip install -r requirements.txt + +# Run tests with coverage +pytest + +# Or explicitly generate coverage report +pytest --cov=. --cov-report=xml --cov-report=html --cov-report=term-missing +``` + +This will generate: +- `coverage.xml` - Used by SonarQube for test coverage analysis +- `htmlcov/` - HTML coverage report (open `htmlcov/index.html` in a browser) +- Terminal output showing coverage summary + +## 🐛 Debug Mode + +Ace-Pace includes a debug mode that provides detailed troubleshooting information. This is useful when diagnosing issues with missing episode detection or data processing. + +### Enabling Debug Mode + +**In Python (local execution):** +```bash +DEBUG=true python acepace.py --folder /path/to/videos +``` + +**In Docker:** +```bash +docker run --rm \ + -v /path/to/OnePaceLibrary:/media:rw \ + -v /path/to/config:/config:rw \ + timothe/ace-pace:latest +``` + +**In Docker Compose:** +Add to your `docker-compose.yml`: +```yaml +environment: + - DEBUG=true +``` + +### Debug Output + +When debug mode is enabled, you'll see additional information including: +- Episode fetching progress and page counts +- CRC32 normalization and comparison details +- Sample CRC32s from both Nyaa and local sources +- File processing statistics (cached vs calculated) +- Mapping issues and comparison mismatches +- Intersection and difference analysis +- Processing statistics for each operation + +All debug output is prefixed with "DEBUG:" for easy filtering. + +**Note:** Debug mode defaults to `false` (disabled). Set `DEBUG` to `true`, `1`, `yes`, or `on` to enable. The value is case-insensitive. + +## 🛠️ How to Use Run the script using Python with the following command: ``` -python acepace.py [-h] [--url URL] [--folder FOLDER] [--db] [--download CLIENT] +python acepace.py [-h] [--url URL] [--folder FOLDER] [--db] [--client {transmission,qbittorrent}] [--download] [--host HOST] [--port PORT] [--username USERNAME] [--password PASSWORD] [--download-folder DOWNLOAD_FOLDER] [--tag TAG]... [--category CATEGORY] ``` +### 🔭 Main commands + - `--folder ` (required for most cases) Specify the path to your local One-Pace video library. Ace-Pace will scan this directory recursively to identify and analyze your existing episodes. -- `--url ` - Define the Nyaa URL used for the query to get episodes metadata and download links. Defaults to `https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc`. +- `--url ` + Define the Nyaa URL used for the query to get episodes metadata and download links. Defaults to `https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc`. + Note: Quality filtering (1080p only) is always applied in code. - `--db` (standalone flag) Create a CSV file with the existing local file paths and CRC32 checksums. Useful to check what's detected and debugging. -- `--download ` (standalone flag) - Enable downloading of missing episodes using a BitTorrent client (only Transmission is supported currently). +- `--rename` (standalone flag) + Rename local files based on matching titles from One-Pace episodes index. Requires `--folder` to be specified. Optionally use `--url` to specify a custom Nyaa.si search URL. + +- `--episodes_update` (standalone flag) + Update the episodes metadata database from Nyaa.si. Optionally use `--url` to specify a custom Nyaa.si search URL. This command forces an update even if episodes were recently updated (within the last 10 minutes). + +### 📥 Download commands + +- `--client ` + Specify the BitTorrent client to use for downloading missing episodes. + Supported clients: `transmission`, `qbittorrent`. + +- `--download` (standalone flag) + Enable downloading of missing episodes using the specified BitTorrent client. + +- `--host ` + The BitTorrent client host (default: `localhost`). + +- `--port ` + The BitTorrent client port. -### Some examples +- `--username ` + The BitTorrent client username. + +- `--password ` + The BitTorrent client password. + +- `--download-folder ` + The folder to download the torrents to. + +- `--tag ...` + Tag to add to the torrent in qBittorrent (can be used multiple times). + +- `--category ` + Category to add to the torrent in qBittorrent. + +- `--dry-run` (standalone flag) + With `--download`: test BitTorrent client without adding torrents. With `--rename`: show rename plan without renaming files. + +### 📚 Some examples ``` -python acepace.py --folder "/volume42/media/One Piece/" --url https://nyaa.si/?f=0&c=0_0&q=one+pace+720p&o=asc +python acepace.py --folder "/volume42/media/One Piece/" --url https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc python acepace.py --folder "/volume42/media/One Piece/" -python acepace.py --download transmission +python acepace.py --client transmission --download +python acepace.py --client qbittorrent --download --host 192.168.1.100 --port 8080 --username myuser --password mypassword --download-folder /downloads/onepace --tag onepace --tag 'one pace' --category 'anime' +python acepace.py --client transmission --download --dry-run +python acepace.py --client qbittorrent --download --dry-run --host 192.168.1.100 --port 8080 python acepace.py --db +python acepace.py --folder "/volume42/media/One Piece/" --rename +python acepace.py --episodes_update --url https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc ``` -## Workflow Overview +## 📜 Workflow Overview 1. **Scanning:** Ace-Pace begins by scanning your specified folder, computing CRC32 checksums for each video file to build an accurate inventory of your current collection and store it locally. @@ -54,8 +270,10 @@ python acepace.py --db 3. **Reporting:** A detailed report is generated, highlighting which episodes you already have, which are missing, and any discrepancies. -4. **Optional Downloading:** After that, Ace-Pace will propose to download any missing episodes directly on your BitTorrent client (Transmission only for now). +4. **Optional Downloading:** After that, Ace-Pace will propose to download any missing episodes directly on your BitTorrent client. -## Credits +## 🙏 Credits Ace-Pace is proudly inspired by and built to support the incredible work of the [One-Pace](http://onepace.net/) team. Their dedication to crafting a seamless and engaging One Piece viewing experience has allowed me to discover and share this legendary series. I salute their passion, creativity, and commitment. + +Since the start of this project, not unlinke Luffy, a few people joined me to build or support it, namely [@Staubgeborener](https://github.com/Staubgeborener) & [@thekoma](https://github.com/thekoma) who implemented the multi-clients functionality. Check them out! diff --git a/acepace.py b/acepace.py index f56aa0e..966e34c 100644 --- a/acepace.py +++ b/acepace.py @@ -1,28 +1,146 @@ -import requests -from bs4 import BeautifulSoup -import os -import zlib -import argparse -import re -import sqlite3 -from datetime import datetime -import csv import time -import getpass +import csv +from datetime import datetime +import sqlite3 +import re +import argparse +import zlib +import os +import signal +import sys +from bs4 import BeautifulSoup # type: ignore +import requests # type: ignore + +from clients import get_client + + +# Check if running in Docker (non-interactive mode) +IS_DOCKER = "RUN_DOCKER" in os.environ + +# Check if debug mode is enabled (via DEBUG environment variable) +# Defaults to False if not set or empty +DEBUG_MODE = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + +# Global flag for graceful shutdown +_shutdown_requested = False + +# Shutdown message constant +_SHUTDOWN_MESSAGE = "Shutdown requested, stopping fetch operation..." + + +def debug_print(*args, **kwargs): + """Print debug messages only if DEBUG mode is enabled. + Works exactly like print() but only outputs when DEBUG environment variable is set.""" + if DEBUG_MODE: + print(*args, **kwargs) + + +def _signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + global _shutdown_requested + _shutdown_requested = True + print("\nShutdown signal received, finishing current operation...") # Define regex to extract CRC32 from filename text (commonly in [xxxxx]) CRC32_REGEX = re.compile(r"\[([A-Fa-f0-9]{8})\]") +# Quality regex patterns - matches [1080p], etc. (case insensitive) +QUALITY_REGEX = re.compile(r"\[(\d+p)\]", re.IGNORECASE) + # Video file extensions we care about VIDEO_EXTENSIONS = {".mkv", ".mp4", ".avi"} +# Constants for repeated string literals +HTML_PARSER = "html.parser" +NYAA_BASE_URL = "https://nyaa.si" +ONE_PACE_MARKER = "[One Pace]" + +# HTTP and network constants +HTTP_OK = 200 +REQUEST_DELAY_SECONDS = 0.2 +CRC32_CHUNK_SIZE = 8192 +MAGNET_LINK_PREFIX = "magnet:" + +# Config and media directory defaults (override via env: ACEPACE_CONFIG_DIR_*, ACEPACE_MEDIA_DIR_*) +CONFIG_DIR_DOCKER_DEFAULT = "/config" +CONFIG_DIR_LOCAL_DEFAULT = "." +MEDIA_DIR_DOCKER_DEFAULT = "/media" +MEDIA_DIR_LOCAL_DEFAULT = "" DB_NAME = "crc32_files.db" EPISODES_DB_NAME = "episodes_index.db" +MISSING_CSV_FILENAME = "Ace-Pace_Missing.csv" +DB_CSV_FILENAME = "Ace-Pace_DB.csv" +CSV_COLUMN_MAGNET_LINK = "Magnet Link" + + +def _get_release_date(): + """Release date from modification time of this file (no repo commits, no extra file).""" + try: + mtime = os.path.getmtime(os.path.abspath(__file__)) + return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d") + except (OSError, ValueError): + return "" + + +def get_config_dir(): + """Get the config directory path based on Docker mode. + Returns the config directory path, creating it if necessary. + Override via ACEPACE_CONFIG_DIR_DOCKER (Docker) or ACEPACE_CONFIG_DIR_LOCAL (local). + """ + if IS_DOCKER: + config_dir = os.getenv("ACEPACE_CONFIG_DIR_DOCKER", CONFIG_DIR_DOCKER_DEFAULT) + else: + config_dir = os.getenv("ACEPACE_CONFIG_DIR_LOCAL", CONFIG_DIR_LOCAL_DEFAULT) + if not os.path.exists(config_dir): + os.makedirs(config_dir, exist_ok=True) + return config_dir + + +def _get_default_media_dir(): + """Default media/library folder for the current mode (Docker vs local). + Override via ACEPACE_MEDIA_DIR_DOCKER (Docker) or ACEPACE_MEDIA_DIR_LOCAL (local). + """ + if IS_DOCKER: + return os.getenv("ACEPACE_MEDIA_DIR_DOCKER", MEDIA_DIR_DOCKER_DEFAULT) + return os.getenv("ACEPACE_MEDIA_DIR_LOCAL", MEDIA_DIR_LOCAL_DEFAULT) + + +def get_config_path(filename): + """Get the full path to a config file. + Args: + filename: The name of the config file + Returns: + Full path to the config file in the appropriate config directory + """ + config_dir = get_config_dir() + return os.path.join(config_dir, filename) + + +def normalize_file_path(file_path): + """Normalize a file path for consistent storage and lookup. + Resolves symlinks and converts to absolute path to ensure the same file + always maps to the same path string, regardless of OS or environment. + Args: + file_path: The file path to normalize + Returns: + Normalized absolute path + """ + try: + # Use realpath to resolve symlinks and get canonical path + return os.path.realpath(os.path.abspath(file_path)) + except (OSError, ValueError): + # Fallback to abspath if realpath fails (e.g., file doesn't exist yet) + return os.path.normpath(os.path.abspath(file_path)) -def init_db(): - exists = os.path.exists(DB_NAME) - conn = sqlite3.connect(DB_NAME) +def init_db(suppress_messages=False): + """Initialize the database. + Args: + suppress_messages: If True, suppress informational messages (useful for automated runs) + """ + db_path = get_config_path(DB_NAME) + exists = os.path.exists(db_path) + conn = sqlite3.connect(db_path) c = conn.cursor() c.execute( """ @@ -41,25 +159,35 @@ def init_db(): """ ) conn.commit() - if exists: + if exists and not suppress_messages: print("Database already exists. You can export it using the --db option.") return conn # --- New: Episodes metadata DB --- def init_episodes_db(): - exists = os.path.exists(EPISODES_DB_NAME) - conn = sqlite3.connect(EPISODES_DB_NAME) + """Initialize the episodes index database. + Creates the episodes_index and metadata tables if they don't exist. + Returns: Database connection object.""" + episodes_db_path = get_config_path(EPISODES_DB_NAME) + conn = sqlite3.connect(episodes_db_path) c = conn.cursor() c.execute( """ CREATE TABLE IF NOT EXISTS episodes_index ( crc32 TEXT PRIMARY KEY, title TEXT, - page_link TEXT + page_link TEXT, + magnet_link TEXT ) """ ) + # Add magnet_link column if it doesn't exist (for existing databases) + try: + c.execute("ALTER TABLE episodes_index ADD COLUMN magnet_link TEXT") + except sqlite3.OperationalError: + # Column already exists, ignore + pass c.execute( """ CREATE TABLE IF NOT EXISTS metadata ( @@ -73,6 +201,11 @@ def init_episodes_db(): def get_episodes_metadata(conn, key): + """Get metadata value from episodes database. + Args: + conn: Database connection + key: Metadata key + Returns: Metadata value or None if not found.""" c = conn.cursor() c.execute("SELECT value FROM metadata WHERE key = ?", (key,)) row = c.fetchone() @@ -80,6 +213,11 @@ def get_episodes_metadata(conn, key): def set_episodes_metadata(conn, key, value): + """Set metadata value in episodes database. + Args: + conn: Database connection + key: Metadata key + value: Metadata value""" c = conn.cursor() c.execute( "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", (key, value) @@ -88,39 +226,37 @@ def set_episodes_metadata(conn, key, value): # --- New: Fetch and update episodes_index table --- -def fetch_episodes_metadata(): - """ - Fetch all One Pace episodes from Nyaa, collecting CRC32, title, and page link. - If CRC32 not in title, fetch the torrent page and try to extract CRC32s from file list. - Returns: List of (crc32, title, page_link) - """ - - def _process_fname_entry(fname_text, seen_crc32, episodes, page_link): - """Helper to extract CRC32 from fname_text and store if valid and unique.""" - m = CRC32_REGEX.findall(fname_text) - found = False - if m and "[One Pace]" in fname_text: - crc32 = m[-1].upper() - if crc32 not in seen_crc32: - # print(f"New CRC32 detected: {crc32} -> Title: {fname_text}") - episodes.append((crc32, fname_text, page_link)) - seen_crc32.add(crc32) - found = True - return found - - base_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace" - episodes = [] - seen_crc32 = set() - page = 1 - print(f"Browsing {base_url}...") - - # --- Get total number of pages by parsing first page's pagination controls --- - resp = requests.get(f"{base_url}&p=1") - if resp.status_code != 200: - print(f"Failed to fetch page 1, status code: {resp.status_code}") - return episodes - soup = BeautifulSoup(resp.text, "html.parser") - # Find pagination links and determine max page number +def _is_valid_quality(fname_text): + """Check if filename has valid quality (1080p only). + Returns True if quality is 1080p, False otherwise.""" + quality_matches = QUALITY_REGEX.findall(fname_text) + if not quality_matches: + return False # No quality marker found, exclude + # Check if quality is exactly 1080p (not higher, not lower) + for quality in quality_matches: + quality_num = int(quality.lower().replace('p', '')) + if quality_num == 1080: + return True + return False # Quality not 1080p + + +def _process_fname_entry(fname_text, seen_crc32, episodes, page_link, magnet_link=""): + """Helper to extract CRC32 from fname_text and store if valid and unique. + Only accepts episodes with 1080p quality.""" + m = CRC32_REGEX.findall(fname_text) + found = False + if m and ONE_PACE_MARKER in fname_text and _is_valid_quality(fname_text): + crc32 = m[-1].upper() + if crc32 not in seen_crc32: + # print(f"New CRC32 detected: {crc32} -> Title: {fname_text}") + episodes.append((crc32, fname_text, page_link, magnet_link)) + seen_crc32.add(crc32) + found = True + return found + + +def _get_total_pages(soup): + """Extract total number of pages from pagination controls.""" total_pages = 1 pagination = soup.find("ul", class_="pagination") if pagination: @@ -131,126 +267,250 @@ def _process_fname_entry(fname_text, seen_crc32, episodes, page_link): if text.isdigit(): try: page_numbers.append(int(text)) - except Exception: + except (ValueError, TypeError): pass if page_numbers: total_pages = max(page_numbers) + return total_pages + + +def _extract_title_link_from_row(row): + """Extract title link from a table row.""" + links = row.find_all("a", href=True) + for a in links: + href = a.get("href", "") + if href.startswith("/view/") and a.has_attr("title"): + return a + return None + + +def _extract_filenames_from_folder_structure(filelist_div): + """Extract filenames from folder structure in file list.""" + all_uls = filelist_div.find_all("ul") + filenames = [] + for ul in all_uls: + for file_li in ul.find_all("li"): + if not file_li.find("ul"): + direct_texts = [ + t for t in file_li.contents if isinstance(t, str) + ] + fname_text = "".join(direct_texts).strip() + if fname_text: + filenames.append(fname_text) + return filenames + + +def _extract_filenames_from_torrent_page(torrent_soup): + """Extract filenames from a torrent page's file list.""" + filelist_div = torrent_soup.find("div", class_="torrent-file-list") + if not filelist_div: + return [] + + has_folder = bool(filelist_div.find("a", class_="folder")) + + if has_folder: + return _extract_filenames_from_folder_structure(filelist_div) + else: + li = filelist_div.find("li") + if li: + direct_texts = [t for t in li.contents if isinstance(t, str)] + fname_text = "".join(direct_texts).strip() + if fname_text: + return [fname_text] + return [] + + +def _process_torrent_page(page_link, seen_crc32, episodes, magnet_link=""): + """Process a torrent page to extract CRC32 information from file list. + For grouped episodes, all episodes in the group share the same magnet_link.""" + try: + torrent_resp = requests.get(page_link) + if torrent_resp.status_code != HTTP_OK: + print(f"Failed to fetch torrent page {page_link}") + return False + t_soup = BeautifulSoup(torrent_resp.text, HTML_PARSER) + filenames = _extract_filenames_from_torrent_page(t_soup) + found = False + for fname in filenames: + if _process_fname_entry(str(fname), seen_crc32, episodes, page_link, magnet_link): + found = True + return found + except (requests.RequestException, AttributeError, TypeError): + return False + + +def _process_episode_row(row, seen_crc32, episodes): + """Process a single table row to extract episode information.""" + title_link, magnet_link = _extract_links_from_row(row) + if not title_link: + return False + + title = title_link.text.strip() + page_link = NYAA_BASE_URL + title_link["href"] + matches = CRC32_REGEX.findall(title) + + if matches: + return _process_fname_entry(title, seen_crc32, episodes, page_link, magnet_link or "") + else: + # CRC32 not in title, need to visit torrent page + # The magnet_link from the row applies to all episodes in the group + return _process_torrent_page(page_link, seen_crc32, episodes, magnet_link or "") + + +def _fetch_episodes_page(base_url, page, soup=None): + """Fetch a single page of episodes. + Returns tuple: (page_soup, success) where success indicates if page was fetched.""" + if page == 1 and soup is not None: + return soup, True + + resp = requests.get(f"{base_url}&p={page}") + if resp.status_code != HTTP_OK: + print(f"Failed to fetch page {page}, status code: {resp.status_code}") + return None, False + return BeautifulSoup(resp.text, HTML_PARSER), True + + +def _process_episodes_page_rows(page_soup, seen_crc32, episodes): + """Process all rows from an episodes page.""" + table = page_soup.find("table", class_="torrent-list") + if not table: + return + rows = table.find_all("tr") # type: ignore + for row in rows: + if _shutdown_requested: + break + _process_episode_row(row, seen_crc32, episodes) + + +def fetch_episodes_metadata(base_url=None): + """ + Fetch all One Pace episodes from Nyaa, collecting CRC32, title, page link, and magnet link. + If CRC32 not in title, fetch the torrent page and try to extract CRC32s from file list. + For grouped episodes (multiple episodes in one torrent), all episodes share the same magnet link. + Args: + base_url: Base URL for Nyaa search. If None, uses default without quality filter. + Note: Quality filtering (1080p only) is always applied regardless of URL. + Returns: List of (crc32, title, page_link, magnet_link) + """ + if base_url is None: + base_url = f"{NYAA_BASE_URL}/?f=0&c=0_0&q=one+pace" + + episodes = [] + seen_crc32 = set() + print(f"Browsing {base_url}...") + + # Get total number of pages by parsing first page's pagination controls + soup, success = _fetch_episodes_page(base_url, 1) + if not success: + debug_print("DEBUG: Failed to fetch first page for episodes metadata") + return episodes + total_pages = _get_total_pages(soup) + debug_print(f"DEBUG: Found {total_pages} total pages to process for episodes metadata") - # Now loop from page 1 to total_pages + # Loop from page 1 to total_pages + page = 1 while page <= total_pages: + if _shutdown_requested: + print(_SHUTDOWN_MESSAGE) + break + print(f"Fetching page {page}/{total_pages}...") - if page == 1: - # We've already fetched page 1 above - page_soup = soup - else: - resp = requests.get(f"{base_url}&p={page}") - if resp.status_code != 200: - print(f"Failed to fetch page {page}, status code: {resp.status_code}") - break - page_soup = BeautifulSoup(resp.text, "html.parser") - table = page_soup.find("table", class_="torrent-list") - if not table: + page_soup, success = _fetch_episodes_page(base_url, page, soup if page == 1 else None) + if not success: + break + + _process_episodes_page_rows(page_soup, seen_crc32, episodes) + + if _shutdown_requested: break - rows = table.find_all("tr") - page_has_matches = False - for row in rows: - links = row.find_all("a", href=True) - title_link = None - for a in links: - href = a.get("href", "") - if href.startswith("/view/") and a.has_attr("title"): - title_link = a - break - if not title_link: - continue - title = title_link.text.strip() - page_link = "https://nyaa.si" + title_link["href"] - matches = CRC32_REGEX.findall(title) - found_in_this_row = False - if matches: - found_in_this_row = _process_fname_entry( - title, seen_crc32, episodes, page_link - ) - else: - try: - # print(f"Fetching page {page_link}...") - torrent_resp = requests.get(page_link) - if torrent_resp.status_code != 200: - print(f"Failed to fetch torrent page {page_link}") - continue - t_soup = BeautifulSoup(torrent_resp.text, "html.parser") - filelist_div = t_soup.find("div", class_="torrent-file-list") - if not filelist_div: - continue - has_folder = bool(filelist_div.find("a", class_="folder")) - filenames = [] - if has_folder: - # print("Has folder") - all_uls = filelist_div.find_all("ul") - leaf_filenames = [] - for ul in all_uls: - for file_li in ul.find_all("li"): - if not file_li.find("ul"): - direct_texts = [ - t - for t in file_li.contents - if isinstance(t, str) - ] - fname_text = "".join(direct_texts).strip() - if fname_text: - leaf_filenames.append(fname_text) - for fname in leaf_filenames: - fname = str(fname) - if _process_fname_entry( - fname, seen_crc32, episodes, page_link - ): - found_in_this_row = True - else: - # print("No folder") - li = filelist_div.find("li") - direct_texts = [t for t in li.contents if isinstance(t, str)] - fname_text = "".join(direct_texts).strip() - # print(f"Direct text: {fname_text}") - if fname_text: - if _process_fname_entry( - fname_text, seen_crc32, episodes, page_link - ): - found_in_this_row = True - except Exception: - print( - f"Error occurred while processing file list for {title} ({page_link})" - ) - if found_in_this_row: - page_has_matches = True - # If no matches on this page, break (may be redundant now that we know total_pages) - # if not page_has_matches: - # break page += 1 - time.sleep(0.2) + time.sleep(REQUEST_DELAY_SECONDS) + print(f"Fetched {len(episodes)} unique episodes with CRC32s.") return episodes -def update_episodes_index_db(): +def _should_skip_episodes_update(force_update, last_update_str): + """Check if episodes update should be skipped due to recent update. + Args: + force_update: If True, never skip + last_update_str: String timestamp of last update, or None + Returns: True if should skip, False if should proceed""" + if force_update: + return False + + if not last_update_str: + return False + + try: + last_update = datetime.strptime(last_update_str, "%Y-%m-%d %H:%M:%S") + time_diff = datetime.now() - last_update + # Skip update if updated within last 10 minutes to avoid unnecessary double updates + if time_diff.total_seconds() < 600: # 10 minutes = 600 seconds + print(f"Episodes were recently updated ({last_update_str}), skipping update to avoid duplicate fetch.") + print("Set EPISODES_UPDATE=true or use --episodes_update to force update.") + return True + except (ValueError, TypeError): + # If parsing fails, proceed with update + pass + + return False + + +def update_episodes_index_db(base_url=None, force_update=False): + """Update episodes index database from Nyaa. + Args: + base_url: Base URL for Nyaa search. If None, uses default. + force_update: If True, force update even if recently updated. If False, skip if updated within last 10 minutes. + """ + debug_print(f"DEBUG: Starting update_episodes_index_db with URL: {base_url}, force_update: {force_update}") + + # Check if episodes were recently updated (within last 10 minutes) conn = init_episodes_db() - episodes = fetch_episodes_metadata() + if not force_update: + last_update_str = get_episodes_metadata(conn, "episodes_db_last_update") + + if _should_skip_episodes_update(force_update, last_update_str): + conn.close() + return + episodes = fetch_episodes_metadata(base_url) + debug_print(f"DEBUG: Fetched {len(episodes)} episodes from Nyaa") c = conn.cursor() - count = 0 - for crc32, title, page_link in episodes: - c.execute( - "INSERT OR REPLACE INTO episodes_index (crc32, title, page_link) VALUES (?, ?, ?)", - (crc32, title, page_link), + + # Prepare data for batch insert (allowing for shutdown during processing) + episode_rows = [] + for episode_data in episodes: + # Check for shutdown request during processing + if _shutdown_requested: + print("Shutdown requested, committing partial update...") + break + # Handle both old format (3 items) and new format (4 items) for backward compatibility + if len(episode_data) == 3: + crc32, title, page_link = episode_data + magnet_link = "" + else: + crc32, title, page_link, magnet_link = episode_data + episode_rows.append((crc32, title, page_link, magnet_link or "")) + + # Batch insert for better performance + if episode_rows: + c.executemany( + "INSERT OR REPLACE INTO episodes_index (crc32, title, page_link, magnet_link) VALUES (?, ?, ?, ?)", + episode_rows ) - count += 1 conn.commit() + count = len(episode_rows) now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") set_episodes_metadata(conn, "episodes_db_last_update", now_str) print(f"Episodes index updated with {count} entries.") print(f"Last update: {now_str}") + debug_print(f"DEBUG: Updated {count} entries in episodes_index database") conn.close() def load_crc32_to_title_from_index(): + """Load CRC32 to title mapping from episodes index database. + Returns: Dictionary mapping CRC32 to episode title.""" conn = init_episodes_db() c = conn.cursor() c.execute("SELECT crc32, title FROM episodes_index") @@ -259,7 +519,207 @@ def load_crc32_to_title_from_index(): return d +def load_1080p_episodes_from_index(): + """Load only 1080p episodes from episodes_index database. + Returns: Tuple of (crc32_to_link, crc32_to_text, crc32_to_magnet) dictionaries with only 1080p episodes.""" + conn = init_episodes_db() + c = conn.cursor() + # Handle both old schema (without magnet_link) and new schema (with magnet_link) + try: + c.execute("SELECT crc32, title, page_link, magnet_link FROM episodes_index") + has_magnet_column = True + except sqlite3.OperationalError: + # Old schema, magnet_link column doesn't exist yet + c.execute("SELECT crc32, title, page_link FROM episodes_index") + has_magnet_column = False + crc32_to_link = {} + crc32_to_text = {} + crc32_to_magnet = {} + for row in c.fetchall(): + if has_magnet_column: + crc32, title, page_link, magnet_link = row + else: + crc32, title, page_link = row + magnet_link = "" + # Only include 1080p episodes (same filter as fetch_crc32_links) + if _is_valid_quality(title): + crc32_to_link[crc32] = page_link + crc32_to_text[crc32] = title + crc32_to_magnet[crc32] = magnet_link or "" + conn.close() + return crc32_to_link, crc32_to_text, crc32_to_magnet + + +def _validate_row_links(title_link, magnet_link): + """Validate that row links are valid and properly formatted.""" + return (title_link and magnet_link and + isinstance(magnet_link, str) and + magnet_link.startswith(MAGNET_LINK_PREFIX) and + hasattr(title_link, 'text')) + + +def _is_valid_one_pace_episode(filename_text): + """Check if filename is a valid One Pace episode with 1080p quality.""" + if ONE_PACE_MARKER not in filename_text: + return False + return _is_valid_quality(filename_text) + + +def _extract_crc32_from_text(text): + """Extract CRC32 from text if present.""" + matches = CRC32_REGEX.findall(text) + if matches: + return matches[-1].upper() + return None + + +def _check_crc32_in_title(filename_text, crc32_set, magnet_link): + """Check if CRC32 is in the title and matches the set.""" + crc32 = _extract_crc32_from_text(filename_text) + if crc32 and crc32 in crc32_set: + return crc32, magnet_link + return None, None + + +def _fetch_crc32_from_torrent_page(link, crc32_set, magnet_link): + """Fetch torrent page and extract CRC32 from file list.""" + try: + torrent_resp = requests.get(link) + if torrent_resp.status_code != HTTP_OK: + return None, None + + t_soup = BeautifulSoup(torrent_resp.text, HTML_PARSER) + filenames = _extract_filenames_from_torrent_page(t_soup) + for fname in filenames: + fname_str = str(fname) + if ONE_PACE_MARKER in fname_str and _is_valid_quality(fname_str): + crc32 = _extract_crc32_from_text(fname_str) + if crc32 and crc32 in crc32_set: + return crc32, magnet_link + except (requests.RequestException, AttributeError, TypeError): + pass + + return None, None + + +def _extract_magnet_link_from_row(row, crc32_set): + """Extract magnet link from a table row if it matches a CRC32 in the set. + First checks title, then visits torrent page if CRC32 not in title. + Args: + row: BeautifulSoup table row element + crc32_set: Set of CRC32 values to match against + Returns: Tuple of (crc32, magnet_link) if found, (None, None) otherwise""" + title_link, magnet_link = _extract_links_from_row(row) + if not _validate_row_links(title_link, magnet_link): + return None, None + + # Type guard: after validation, title_link is guaranteed to be non-None + assert title_link is not None and magnet_link is not None + + filename_text = title_link.text + if not _is_valid_one_pace_episode(filename_text): + return None, None + + # Check if CRC32 is in title + crc32, found_magnet = _check_crc32_in_title(filename_text, crc32_set, magnet_link) + if crc32: + return crc32, found_magnet + + # CRC32 not in title, try fetching torrent page to extract from file list + link = NYAA_BASE_URL + title_link["href"] + return _fetch_crc32_from_torrent_page(link, crc32_set, magnet_link) + + +def _process_magnet_links_page(page_soup, crc32_set, crc32_to_magnet): + """Process a single page to extract magnet links matching CRC32s in the set. + Args: + page_soup: BeautifulSoup object for the page + crc32_set: Set of CRC32 values to match against + crc32_to_magnet: Dictionary to update with found magnet links + Returns: Number of new magnet links found on this page""" + found_count = 0 + table = page_soup.find("table", class_="torrent-list") + if not table: + return found_count + + rows = table.find_all("tr") + for row in rows: + if _shutdown_requested: + break + crc32, magnet_link = _extract_magnet_link_from_row(row, crc32_set) + if crc32 and magnet_link: + crc32_to_magnet[crc32] = magnet_link + found_count += 1 + + return found_count + + +def _get_page_soup_for_magnet_links(base_url, page, first_page_soup): + """Get BeautifulSoup object for a specific page when fetching magnet links. + Args: + base_url: Nyaa search URL + page: Page number (1-indexed) + first_page_soup: BeautifulSoup object for page 1 (already fetched) + Returns: Tuple of (soup, success)""" + if page == 1: + return first_page_soup, True + return _fetch_crc32_page(base_url, page) + + +def fetch_magnet_links_for_episodes_from_search(base_url, crc32_to_link): + """Fetch magnet links from Nyaa search results for episodes already in crc32_to_link. + This is more efficient than fetching all episodes again. + Args: + base_url: Nyaa search URL + crc32_to_link: Dictionary mapping CRC32 to page_link (episodes we need magnet links for) + Returns: Dictionary mapping CRC32 to magnet_link""" + crc32_to_magnet = {} + crc32_set = set(crc32_to_link.keys()) + + if not crc32_set: + return crc32_to_magnet + + # Get total number of pages (fetch page 1 silently first to get total pages) + resp = requests.get(f"{base_url}&p=1") + if resp.status_code != HTTP_OK: + return crc32_to_magnet + soup = BeautifulSoup(resp.text, HTML_PARSER) + total_pages = _get_total_pages(soup) + print(f"Fetching magnet links from {total_pages} pages...") + + # Process pages to extract magnet links for episodes we need + # Continue searching until we've found all requested episodes or searched all pages + page = 1 + while page <= total_pages and len(crc32_to_magnet) < len(crc32_set): + if _shutdown_requested: + break + + if page == 1: + page_soup = soup + success = True + else: + page_soup, success = _get_page_soup_for_magnet_links(base_url, page, soup) + + if not success or page_soup is None: + break + + _process_magnet_links_page(page_soup, crc32_set, crc32_to_magnet) + + page += 1 + if page <= total_pages: + time.sleep(REQUEST_DELAY_SECONDS) + + return crc32_to_magnet + + + + def get_metadata(conn, key): + """Get metadata value from database. + Args: + conn: Database connection + key: Metadata key + Returns: Metadata value or None if not found.""" c = conn.cursor() c.execute("SELECT value FROM metadata WHERE key = ?", (key,)) row = c.fetchone() @@ -267,6 +727,11 @@ def get_metadata(conn, key): def set_metadata(conn, key, value): + """Set metadata value in database. + Args: + conn: Database connection + key: Metadata key + value: Metadata value""" c = conn.cursor() c.execute( "INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)", (key, value) @@ -274,76 +739,184 @@ def set_metadata(conn, key, value): conn.commit() +def _extract_links_from_row(row): + """Extract title link and magnet link from a table row. + Returns tuple: (title_link, magnet_link) or (None, "") if not found.""" + links = row.find_all("a", href=True) + title_link = None + magnet_link = "" + for a in links: + if a.has_attr("title"): + title_link = a + href = a.get("href", "") + if href.startswith(MAGNET_LINK_PREFIX): + magnet_link = href + return title_link, magnet_link + + +def _process_title_with_crc32(filename_text, link, magnet_link, crc32_to_link, crc32_to_text, crc32_to_magnet): + """Process a title that has CRC32 in it. + Returns True if successfully processed, False otherwise.""" + matches = CRC32_REGEX.findall(filename_text) + if matches: + crc32 = matches[-1].upper() + crc32_to_link[crc32] = link + crc32_to_text[crc32] = filename_text + crc32_to_magnet[crc32] = magnet_link + return True + return False + + +def _process_torrent_page_for_crc32(link, magnet_link, crc32_to_link, crc32_to_text, crc32_to_magnet): + """Fetch torrent page and extract CRC32 from file list. + Returns True if CRC32 found, False otherwise.""" + try: + torrent_resp = requests.get(link) + if torrent_resp.status_code == HTTP_OK: + t_soup = BeautifulSoup(torrent_resp.text, HTML_PARSER) + filenames = _extract_filenames_from_torrent_page(t_soup) + for fname in filenames: + fname_str = str(fname) + if ONE_PACE_MARKER in fname_str and _is_valid_quality(fname_str): + fname_matches = CRC32_REGEX.findall(fname_str) + if fname_matches: + crc32 = fname_matches[-1].upper() + crc32_to_link[crc32] = link + crc32_to_text[crc32] = fname_str + crc32_to_magnet[crc32] = magnet_link + return True + except (requests.RequestException, AttributeError, TypeError): + pass + return False + + +def _process_crc32_row(row, crc32_to_link, crc32_to_text, crc32_to_magnet): + """Process a single table row to extract CRC32 information. + Only accepts episodes with 1080p quality. + If CRC32 not in title, fetches torrent page to extract from file list. + Returns tuple: (success: bool, filename_text: str or None, should_warn: bool) + where should_warn indicates if a warning should be shown (only when CRC32 is missing, not when quality is wrong).""" + title_link, magnet_link = _extract_links_from_row(row) + if not title_link: + return False, None, False + + filename_text = title_link.text + link = NYAA_BASE_URL + title_link["href"] + + # Check if it's a One Pace episode first + if ONE_PACE_MARKER not in filename_text: + return False, filename_text, False + + # Check quality first - if not 1080p, silently skip (don't warn) + if not _is_valid_quality(filename_text): + return False, filename_text, False + + # Quality is valid (1080p), now check for CRC32 + if _process_title_with_crc32(filename_text, link, magnet_link, crc32_to_link, crc32_to_text, crc32_to_magnet): + return True, filename_text, False + + # CRC32 not in title, try fetching torrent page + if _process_torrent_page_for_crc32(link, magnet_link, crc32_to_link, crc32_to_text, crc32_to_magnet): + return True, filename_text, False + + # CRC32 not found in title or torrent page, but quality is valid - should warn + return False, filename_text, True + + +def _fetch_crc32_page(base_url, page): + """Fetch a single page for CRC32 links. + Returns tuple: (soup, success) where success indicates if page was fetched.""" + print(f"Fetching page {page}...") + resp = requests.get(f"{base_url}&p={page}") + if resp.status_code != HTTP_OK: + print(f"Failed to fetch page {page}, status code: {resp.status_code}") + return None, False + return BeautifulSoup(resp.text, HTML_PARSER), True + + +def _process_crc32_page_rows(soup, crc32_to_link, crc32_to_text, crc32_to_magnet): + """Process all rows from a CRC32 links page. + Returns the number of episodes found on this page.""" + table = soup.find("table", class_="torrent-list") + if not table: + return 0 + + rows = table.find_all("tr") # type: ignore + if not rows: + return 0 + + found_count = 0 + for row in rows: + if _shutdown_requested: + print(_SHUTDOWN_MESSAGE) + break + success, filename_text, should_warn = _process_crc32_row(row, crc32_to_link, crc32_to_text, crc32_to_magnet) + if success: + found_count += 1 + elif should_warn and filename_text: + debug_print(f"Warning: No CRC32 found in title '{filename_text}'") + + return found_count + + def fetch_crc32_links(base_url): + """Fetch CRC32 links from Nyaa.si search URL. + Only accepts episodes with 1080p quality. + Uses pagination to fetch all pages, similar to fetch_episodes_metadata. + Args: + base_url: Nyaa.si search URL + Returns: Tuple of (crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page)""" crc32_to_link = {} crc32_to_text = {} crc32_to_magnet = {} - page = 1 + + debug_print(f"DEBUG: Starting fetch_crc32_links with URL: {base_url}") + + # Get total number of pages by parsing first page's pagination controls + soup, success = _fetch_crc32_page(base_url, 1) + if not success: + debug_print("DEBUG: Failed to fetch first page for CRC32 links") + return crc32_to_link, crc32_to_text, crc32_to_magnet, 0 + + total_pages = _get_total_pages(soup) + debug_print(f"DEBUG: Found {total_pages} total pages to process for CRC32 links") last_checked_page = 0 - while True: - print(f"Fetching page {page}...") - resp = requests.get(f"{base_url}&p={page}") - if resp.status_code != 200: - print(f"Failed to fetch page {page}, status code: {resp.status_code}") + + # Loop from page 1 to total_pages (similar to fetch_episodes_metadata) + page = 1 + while page <= total_pages: + if _shutdown_requested: + print(_SHUTDOWN_MESSAGE) break - - soup = BeautifulSoup(resp.text, "html.parser") - table = soup.find("table", class_="torrent-list") - if not table: - print("No table found, stopping.") + + # Use cached soup for page 1, fetch for others + if page == 1: + page_soup = soup + success = True + else: + page_soup, success = _fetch_crc32_page(base_url, page) + + if not success: break - - rows = table.find_all("tr") - if not rows: - print("No rows found, stopping.") + + episodes_found = _process_crc32_page_rows(page_soup, crc32_to_link, crc32_to_text, crc32_to_magnet) + debug_print(f"DEBUG: Page {page}/{total_pages}: Found {episodes_found} valid episodes (total so far: {len(crc32_to_link)})") + + if _shutdown_requested: break - - found_in_page = False - for row in rows: - links = row.find_all("a", href=True) - title_link = None - magnet_link = "" - for a in links: - if a.has_attr("title"): - title_link = a - href = a.get("href", "") - if href.startswith("magnet:"): - magnet_link = href - if not title_link: - continue # Skip rows without a valid title link - filename_text = title_link.text - link = "https://nyaa.si" + title_link["href"] - matches = CRC32_REGEX.findall(filename_text) - if matches: - crc32 = matches[-1].upper() - crc32_to_link[crc32] = link - crc32_to_text[crc32] = filename_text - crc32_to_magnet[crc32] = magnet_link - found_in_page = True - else: - print(f"Warning: No CRC32 found in title '{filename_text}'") - - if not found_in_page: - break # No more entries found - + last_checked_page = page page += 1 + if page <= total_pages: # Don't sleep after last page + time.sleep(REQUEST_DELAY_SECONDS) + + debug_print(f"DEBUG: Completed fetch_crc32_links: {len(crc32_to_link)} total episodes found across {last_checked_page} pages") return crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page -def fetch_title_by_crc32(crc32): - # Search on Nyaa for the given CRC32 - search_url = f"https://nyaa.si/?f=0&c=0_0&q={crc32}&o=asc" - resp = requests.get(search_url) - if resp.status_code != 200: - print(f"Failed to fetch search results for CRC32 {crc32}") - return None - soup = BeautifulSoup(resp.text, "html.parser") - table = soup.find("table", class_="torrent-list") - if not table: - return None - rows = table.find_all("tr") +def _extract_matching_titles_from_rows(rows, crc32): + """Extract titles matching the given CRC32 from table rows.""" matched_titles = [] for row in rows: links = row.find_all("a", href=True) @@ -354,52 +927,198 @@ def fetch_title_by_crc32(crc32): matches = CRC32_REGEX.findall(filename_text) if matches and matches[-1].upper() == crc32: matched_titles.append(filename_text) + return matched_titles + + +def fetch_title_by_crc32(crc32): + """Search on Nyaa for the given CRC32 and return the episode title. + Args: + crc32: CRC32 checksum to search for + Returns: Episode title if exactly one match found, None otherwise.""" + # Search on Nyaa for the given CRC32 + search_url = f"{NYAA_BASE_URL}/?f=0&c=0_0&q={crc32}&o=asc" + resp = requests.get(search_url) + if resp.status_code != HTTP_OK: + print(f"Failed to fetch search results for CRC32 {crc32}") + return None + soup = BeautifulSoup(resp.text, HTML_PARSER) + table = soup.find("table", class_="torrent-list") + if not table: + return None + rows = table.find_all("tr") # type: ignore + matched_titles = _extract_matching_titles_from_rows(rows, crc32) + if len(matched_titles) == 1: print(f"Found {crc32} on Nyaa!") return matched_titles[0] elif len(matched_titles) == 0: - print(f"Warning: No title found for {crc32}") + debug_print(f"Warning: No title found for {crc32}") return None else: - print(f"Warning: Multiple titles found for CRC32 {crc32}: {matched_titles}") + debug_print(f"Warning: Multiple titles found for CRC32 {crc32}: {matched_titles}") return None +def _calculate_file_crc32(file_path): + """Calculate CRC32 for a single file. + Returns the CRC32 as a string, or None if calculation was interrupted.""" + with open(file_path, "rb") as f: + crc = 0 + while chunk := f.read(CRC32_CHUNK_SIZE): + if _shutdown_requested: + return None + crc = zlib.crc32(chunk, crc) + return f"{crc & 0xFFFFFFFF:08X}" + + +def _process_video_file(file_path, c, conn, local_crc32s): + """Process a single video file: check cache or calculate CRC32. + Returns True if file was processed successfully.""" + normalized_path = normalize_file_path(file_path) + + # Check if already in DB + c.execute("SELECT crc32 FROM crc32_cache WHERE file_path = ?", (normalized_path,)) + row = c.fetchone() + if row: + local_crc32s.add(row[0]) + debug_print(f"DEBUG: Using cached CRC32 for {os.path.basename(file_path)}: {row[0]}") + return True + + # Calculate CRC32 + parent_folder = os.path.basename(os.path.dirname(file_path)) + file_name = os.path.basename(file_path) + print(f"Calculating CRC32 for {parent_folder}/{file_name}...") + + crc32 = _calculate_file_crc32(file_path) + if crc32 is None: + debug_print(f"DEBUG: CRC32 calculation interrupted for {file_path}") + return False # Calculation interrupted + + debug_print(f"DEBUG: Calculated CRC32 for {file_name}: {crc32}") + local_crc32s.add(crc32) + c.execute( + "INSERT OR REPLACE INTO crc32_cache (file_path, crc32) VALUES (?, ?)", + (normalized_path, crc32), + ) + conn.commit() + return True + + +def _process_single_file_for_crc32(file_path, c, conn, local_crc32s): + """Process a single file for CRC32 calculation. + Returns tuple: (success: bool, was_cached: bool)""" + normalized_path = normalize_file_path(file_path) + # Check if already in DB + c.execute("SELECT crc32 FROM crc32_cache WHERE file_path = ?", (normalized_path,)) + row = c.fetchone() + was_cached = bool(row) + + if _process_video_file(file_path, c, conn, local_crc32s): + return True, was_cached + return False, was_cached + + +def _process_files_in_directory(root, files, c, conn, local_crc32s, stats): + """Process files in a directory, updating stats. + Returns True if processing should continue, False if shutdown requested.""" + for file in files: + if _shutdown_requested: + return False + + ext = os.path.splitext(file)[1].lower() + if ext in VIDEO_EXTENSIONS: + file_path = os.path.join(root, file) + success, was_cached = _process_single_file_for_crc32(file_path, c, conn, local_crc32s) + if success: + stats['processed'] += 1 + if was_cached: + stats['cached'] += 1 + else: + stats['calculated'] += 1 + return True + + def calculate_local_crc32(folder, conn): + """Calculate CRC32 checksums for all video files in the given folder. + Uses cached values from database when available. + Args: + folder: Folder path to scan for video files + conn: Database connection + Returns: Set of CRC32 checksums found in the folder.""" local_crc32s = set() c = conn.cursor() + stats = {'processed': 0, 'cached': 0, 'calculated': 0} + + debug_print(f"DEBUG: Starting calculate_local_crc32 for folder: {folder}") + for root, dirs, files in os.walk(folder): - for file in files: - ext = os.path.splitext(file)[1].lower() - if ext in VIDEO_EXTENSIONS: - file_path = os.path.join(root, file) - # Check if file_path already in DB - c.execute( - "SELECT crc32 FROM crc32_cache WHERE file_path = ?", (file_path,) - ) - row = c.fetchone() - if row: - crc32 = row[0] - local_crc32s.add(crc32) - continue - - parent_folder = os.path.basename(root) - print(f"Calculating CRC32 for {parent_folder}/{file}...") - with open(file_path, "rb") as f: - crc = 0 - while chunk := f.read(8192): - crc = zlib.crc32(chunk, crc) - crc32 = f"{crc & 0xFFFFFFFF:08X}" - local_crc32s.add(crc32) - c.execute( - "INSERT OR REPLACE INTO crc32_cache (file_path, crc32) VALUES (?, ?)", - (file_path, crc32), - ) - conn.commit() + if _shutdown_requested: + print("Shutdown requested, stopping file processing...") + break + + if not _process_files_in_directory(root, files, c, conn, local_crc32s, stats): + break + + debug_print(f"DEBUG: Processed {stats['processed']} video files ({stats['cached']} from cache, {stats['calculated']} calculated)") + debug_print(f"DEBUG: Found {len(local_crc32s)} unique CRC32s") + return local_crc32s -def rename_local_files(conn, folder): +def _build_rename_plan(entries, crc32_to_title): + """Build a plan of files to rename based on CRC32 matches.""" + rename_plan = [] + for file_path, crc32 in entries: + title = crc32_to_title.get(crc32) + if not title: + continue # No match found in index, skip + dir_name = os.path.dirname(file_path) + # Sanitize title for filename (remove problematic characters) + sanitized_title = re.sub(r'[\\/*?:"<>|]', "", title).strip() + new_filename = f"{sanitized_title}" + new_path = os.path.join(dir_name, new_filename) + if os.path.abspath(file_path) != os.path.abspath(new_path): + rename_plan.append((file_path, new_path)) + return rename_plan + + +def _get_rename_confirmation(): + """Get user confirmation for renaming files.""" + if IS_DOCKER: + return "y" + return input("Proceed with renaming? (y/n): ").strip().lower() + + +def _execute_rename(rename_plan, conn): + """Execute the rename plan and update the database.""" + c = conn.cursor() + for old, new in rename_plan: + try: + if os.path.exists(new): + print(f"Cannot rename {old} to {new}: target file already exists.") + continue + os.rename(old, new) + print(f"Renamed {old} to {new}") + # Normalize paths for consistent database updates + normalized_old = normalize_file_path(old) + normalized_new = normalize_file_path(new) + # Update DB with new file path + c.execute( + "UPDATE crc32_cache SET file_path = ? WHERE file_path = ?", (normalized_new, normalized_old) + ) + conn.commit() + except (sqlite3.Error, OSError) as e: + print(f"Failed to rename {old} to {new}: {e}") + + +def rename_local_files(conn, dry_run=False): + """Rename local files based on CRC32 matching titles from episodes index. + Matches local video files with episodes in the database and renames them + to match the official episode titles. + Args: + conn: Database connection + dry_run: If True, only print the rename plan and do not rename or ask for confirmation. + """ c = conn.cursor() c.execute("SELECT file_path, crc32 FROM crc32_cache") entries = c.fetchall() @@ -413,22 +1132,8 @@ def rename_local_files(conn, folder): for _, crc32 in entries: local_crc32s.add(crc32) - matched = 0 total = len(local_crc32s) - rename_plan = [] - for file_path, crc32 in entries: - title = crc32_to_title.get(crc32) - if not title: - continue # No match found in index, skip - dir_name = os.path.dirname(file_path) - ext = os.path.splitext(file_path)[1] - # Sanitize title for filename (remove problematic characters) - sanitized_title = re.sub(r'[\\/*?:"<>|]', "", title).strip() - new_filename = f"{sanitized_title}" - new_path = os.path.join(dir_name, new_filename) - if os.path.abspath(file_path) != os.path.abspath(new_path): - rename_plan.append((file_path, new_path)) - matched += 1 + rename_plan = _build_rename_plan(entries, crc32_to_title) if not rename_plan: print("No files to rename.") @@ -440,288 +1145,313 @@ def rename_local_files(conn, folder): print(f"{os.path.basename(old)} -> {os.path.basename(new)}") print(f"{len(rename_plan)}/{total} files will be renamed.") - confirm = input("Proceed with renaming? (y/n): ").strip().lower() + if dry_run: + print("DRY RUN: would rename the above files (no changes made).") + return + + confirm = _get_rename_confirmation() if confirm != "y": print("Renaming aborted.") return - for old, new in rename_plan: - try: - if os.path.exists(new): - print(f"Cannot rename {old} to {new}: target file already exists.") - continue - os.rename(old, new) - print(f"Renamed {old} to {new}") - # Update DB with new file path - c.execute( - "UPDATE crc32_cache SET file_path = ? WHERE file_path = ?", (new, old) - ) - conn.commit() - except Exception as e: - print(f"Failed to rename {old} to {new}: {e}") + _execute_rename(rename_plan, conn) def export_db_to_csv(conn): + """Export local CRC32 database to CSV file. + Args: + conn: Database connection""" c = conn.cursor() c.execute("SELECT file_path, crc32 FROM crc32_cache") rows = c.fetchall() - with open("Ace-Pace_DB.csv", "w", encoding="utf-8", newline="") as f: + export_csv_path = get_config_path(DB_CSV_FILENAME) + with open(export_csv_path, "w", encoding="utf-8", newline="") as f: writer = csv.writer(f, quoting=csv.QUOTE_ALL) writer.writerow(["File Path", "CRC32"]) for row in rows: writer.writerow(row) - print("Database exported to Ace-Pace_DB.csv") + print(f"Database exported to {export_csv_path}") now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") set_metadata(conn, "last_db_export", now_str) -def download_with_transmission(): - if not os.path.exists("Ace-Pace_Missing.csv"): - print("Missing file 'Ace-Pace_Missing.csv' not found. Run the script first!") - return +def _prompt_folder_interactive(conn): + """Prompt user for folder using last_folder metadata or raw input. Returns folder or None.""" + last_folder = get_metadata(conn, "last_folder") + if last_folder: + print(f"Last used folder: {last_folder}") + user_input = input( + "Press Enter to use this folder, or enter a new path: " + ).strip() + folder = user_input if user_input else last_folder + else: + folder = input("Enter the folder containing local video files: ").strip() + if not folder: + print("Error: No folder specified.") + return None + return folder - magnets = [] - with open("Ace-Pace_Missing.csv", "r", encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: - magnet_link = row.get("Magnet Link", "").strip() - if magnet_link.startswith("magnet:"): - magnets.append(magnet_link) - if not magnets: - print("No magnet links found in 'Ace-Pace_Missing.csv'.") - return +def _get_folder_from_args(args, conn, needs_folder): + """Get folder path from arguments or prompt user. + In Docker uses ACEPACE_MEDIA_DIR_DOCKER (default /media); locally uses ACEPACE_MEDIA_DIR_LOCAL if set. + """ + folder = args.folder + if IS_DOCKER and needs_folder: + folder = _get_default_media_dir() + set_metadata(conn, "last_folder", folder) + return folder + if needs_folder and not folder: + default_media = _get_default_media_dir() + if default_media: + folder = default_media + set_metadata(conn, "last_folder", folder) + return folder + folder = _prompt_folder_interactive(conn) + if folder is None: + return None + set_metadata(conn, "last_folder", folder) + elif folder: + set_metadata(conn, "last_folder", folder) + return folder - print("The details below are not stored.") - host = input("Enter Transmission host (default: localhost): ").strip() - if not host: - host = "localhost" - port_input = input("Enter Transmission port (default: 9091): ").strip() - if port_input: - try: - port = int(port_input) - except ValueError: - print("Invalid port number. Using default 9091.") - port = 9091 - else: - port = 9091 - rpc_username = input("Enter Transmission username (leave blank if none): ").strip() - rpc_password = getpass.getpass( - "Enter Transmission password (leave blank if none): " - ).strip() - - base_url = f"http://{host}:{port}/transmission/rpc" - session_id = None - session = requests.Session() - auth = (rpc_username, rpc_password) if rpc_username else None - - # Test connection and get session ID - try: - headers = {} - if session_id: - headers["X-Transmission-Session-Id"] = session_id - resp = session.post( - base_url, auth=auth, headers=headers, json={"method": "session-get"} - ) - if resp.status_code == 409: - session_id = resp.headers.get("X-Transmission-Session-Id") - headers["X-Transmission-Session-Id"] = session_id - resp = session.post( - base_url, auth=auth, headers=headers, json={"method": "session-get"} - ) - resp.raise_for_status() - except Exception as e: - print(f"Failed to connect to Transmission RPC: {e}") - return - print("Connection to Transmission successful!") +def _get_client_from_args_or_env(args): + """Get client type from args or environment variables. + In Docker mode, defaults to 'transmission' if not specified. + """ + if IS_DOCKER and not args.client: + return os.getenv("TORRENT_CLIENT", "transmission") + return args.client - # Suggest default download directory to user - try: - session_info = resp.json() - default_download_dir = "" - if "arguments" in session_info and "download-dir" in session_info["arguments"]: - default_download_dir = session_info["arguments"]["download-dir"] - except Exception: - default_download_dir = "" - - if default_download_dir: - prompt_text = f"Enter target folder for downloads (current default: {default_download_dir}): " - else: - prompt_text = "Enter target folder for downloads (leave blank for default): " - target_folder = input(prompt_text).strip() - confirm = ( - input(f"Do you want to add {len(magnets)} torrents to Transmission? (y/n): ") - .strip() - .lower() - ) - if confirm != "y": - print("Abort! Abort!") - return +def _get_default_port(client): + """Get default port for a given client.""" + return 9091 if client == "transmission" else 8080 - added_count = 0 - total = len(magnets) - for idx, magnet in enumerate(magnets, 1): - truncated = magnet[:50] + ("..." if len(magnet) > 50 else "") - print(f"Adding {idx}/{total}: {truncated}") - payload = {"method": "torrent-add", "arguments": {"filename": magnet}} - if target_folder: - payload["arguments"]["download-dir"] = target_folder - try: - headers = {"X-Transmission-Session-Id": session_id} if session_id else {} - resp = session.post(base_url, auth=auth, headers=headers, json=payload) - if resp.status_code == 409: - session_id = resp.headers.get("X-Transmission-Session-Id") - headers["X-Transmission-Session-Id"] = session_id - resp = session.post(base_url, auth=auth, headers=headers, json=payload) - resp.raise_for_status() - result = resp.json() - if result.get("result") == "success": - added_count += 1 - else: - print( - f"Failed to add torrent: {truncated} Error: {result.get('result')}" - ) - time.sleep(0.1) - except Exception as e: - print(f"Failed to add torrent: {truncated} Error: {e}") - - print(f"Added {added_count} torrents to Transmission.") - - -def download_missing_to_client(client_type): - client_type = client_type.lower() - if client_type == "transmission": - download_with_transmission() - else: - print(f"Download client '{client_type}' not supported.") +def _get_docker_connection_params(args): + """Get connection parameters from Docker environment variables. + Uses default values: localhost, 9091, transmission if not specified. + """ + # Get client (defaults to transmission in Docker) + client = _get_client_from_args_or_env(args) + + # Get host (defaults to localhost) + host = os.getenv("TORRENT_HOST", args.host or "localhost") + + # Get port (defaults to 9091 for transmission, 8080 for qbittorrent) + port_env = os.getenv("TORRENT_PORT") + port = int(port_env) if port_env else None + if not port: + default_port = _get_default_port(client) + port = args.port if args.port else default_port + + username = os.getenv("TORRENT_USER", args.username or "") + password = os.getenv("TORRENT_PASSWORD", args.password or "") + download_folder = args.download_folder or _get_default_media_dir() + return host, port, username, password, download_folder, client + + +def _get_non_docker_connection_params(args): + """Get connection parameters from command-line arguments.""" + host = args.host or "localhost" + port = args.port + if not port: + port = _get_default_port(args.client) + username = args.username or "" + password = args.password or "" + download_folder = args.download_folder + return host, port, username, password, download_folder + + +def _load_magnet_links(): + """Load magnet links from the missing CSV file. + Deduplicates magnet links so grouped episodes (sharing same magnet) are only added once.""" + missing_csv_path = get_config_path(MISSING_CSV_FILENAME) + if not os.path.exists(missing_csv_path): + print(f"Missing file '{missing_csv_path}' not found. Run the script first!") + return None -def main(): - parser = argparse.ArgumentParser( - description="Find missing episodes from your personal One Pace library." - ) - parser.add_argument( - "--url", - default="https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc", - help="Base URL without the page param. Example: 'https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc' ", - ) - parser.add_argument("--folder", help="Folder containing local video files.") - parser.add_argument( - "--db", action="store_true", help="Export database to CSV and exit." - ) - parser.add_argument( - "--download", - metavar="CLIENT", - help="Import magnet links from missing CSV and add to specified BitTorrent client (e.g. transmission).", - ) - parser.add_argument( - "--rename", - action="store_true", - help="Rename local files based on CRC32 matching titles from Nyaa.", + magnets_set = set() + total_magnets = 0 + with open(missing_csv_path, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + magnet_link = row.get(CSV_COLUMN_MAGNET_LINK, "").strip() + if magnet_link.startswith(MAGNET_LINK_PREFIX): + total_magnets += 1 + magnets_set.add(magnet_link) + + if not magnets_set: + print(f"No magnet links found in '{missing_csv_path}'.") + return None + + # Convert to list and return (sorted for consistent ordering) + magnets = sorted(list(magnets_set)) + duplicates = total_magnets - len(magnets_set) + if duplicates > 0: + print(f"Deduplicated {duplicates} duplicate magnet links (grouped episodes share same magnet).") + + return magnets + + +def _setup_docker_connection(args): + """Setup connection parameters for Docker mode.""" + host, port, username, password, download_folder, client = _get_docker_connection_params(args) + # Log connection parameters in Docker mode + print("Download configuration:") + print(f" Client: {client}") + print(f" Host: {host}") + print(f" Port: {port}") + if username: + print(f" Username: {username}") + if download_folder: + print(f" Download folder: {download_folder}") + if args.dry_run: + print(" Mode: DRY RUN (no torrents will be added)") + return host, port, username, password, download_folder, client + + +def _setup_non_docker_connection(args): + """Setup connection parameters for non-Docker mode.""" + client = _get_client_from_args_or_env(args) + if not client: + print("Error: --client is required when using --download.") + return None, None, None, None, None, None + host, port, username, password, download_folder = _get_non_docker_connection_params(args) + if args.dry_run: + print("DRY RUN MODE: Testing connection without adding torrents...") + return host, port, username, password, download_folder, client + + +def _execute_download_dry_run(client_obj, magnets, client, download_folder, tags, category): + """Execute download in dry-run mode.""" + print(f"DRY RUN: Would add {len(magnets)} missing episode(s) to {client}...") + print("DRY RUN: Testing connection and validating magnet links...") + client_obj.add_torrents( + magnets, + download_folder=download_folder, + tags=tags, + category=category, + dry_run=True, ) - parser.add_argument( - "--episodes_update", - action="store_true", - help="Update episodes metadata database from Nyaa.", + print(f"DRY RUN: Successfully validated connection to {client}.") + print(f"DRY RUN: {len(magnets)} magnet link(s) would be added (no torrents were actually added).") + + +def _execute_download(client_obj, magnets, client, download_folder, tags, category): + """Execute actual download.""" + print(f"Adding {len(magnets)} missing episode(s) to {client}...") + client_obj.add_torrents( + magnets, + download_folder=download_folder, + tags=tags, + category=category, ) - args = parser.parse_args() + print(f"Successfully added {len(magnets)} episode(s) to {client}.") - # Check if the URL points to a valid Nyaa domain - if not args.url.startswith(("https://nyaa.si", "https://nyaa.land")): - print( - "Error: The --url argument must point to a valid Nyaa website (https://nyaa.si or https://nyaa.land)." - ) - return - # --- Show last episodes metadata update --- - episodes_db_conn = init_episodes_db() - last_ep_update = get_episodes_metadata(episodes_db_conn, "episodes_db_last_update") - if last_ep_update: - print(f"Episodes metadata last updated: {last_ep_update}") +def _handle_download_command(args): + """Handle the download command.""" + # Get connection parameters based on Docker mode + if IS_DOCKER: + result = _setup_docker_connection(args) else: - print("Episodes metadata database not yet updated.") - episodes_db_conn.close() + result = _setup_non_docker_connection(args) + + if result[0] is None: # Check if setup failed + return False + + host, port, username, password, download_folder, client = result - if args.episodes_update: - update_episodes_index_db() - return + magnets = _load_magnet_links() + if magnets is None: + return False - conn = init_db() - - # Folder selection logic: Always prompt if folder is required but not given - folder = args.folder - needs_folder = not args.download # All commands except --download need folder - if needs_folder and not folder: - # Try to load last_folder from metadata - last_folder = get_metadata(conn, "last_folder") - if last_folder: - print(f"Previously used folder: {last_folder}") - user_input = input( - "Press Enter to use this folder, or enter a new path: " - ).strip() - if user_input: - folder = user_input - else: - folder = last_folder + try: + client_obj = get_client(client, host, port, username, password) + if args.dry_run: + _execute_download_dry_run(client_obj, magnets, client, download_folder, args.tag, args.category) else: - folder = input("Enter the folder containing local video files: ").strip() - if not folder: - print("Error: No folder specified.") - return - set_metadata(conn, "last_folder", folder) - elif folder: - set_metadata(conn, "last_folder", folder) + _execute_download(client_obj, magnets, client, download_folder, args.tag, args.category) + except ConnectionError as e: + print(f"Connection Error: {e}") + print(f"Please verify that {client} is running and accessible at {host}:{port}") + return False + except ValueError as e: + print(f"Configuration Error: {e}") + return False + except Exception as e: + print(f"Unexpected Error: {e}") + return False - if args.download: - download_missing_to_client(args.download) - return + return True - if args.rename: - # Prompt to update episodes_index DB if it's old - episodes_db_conn = init_episodes_db() - last_ep_update = get_episodes_metadata( - episodes_db_conn, "episodes_db_last_update" - ) - episodes_db_conn.close() - if not last_ep_update: - print("WARNING: Episodes metadata database has not been updated yet.") - elif last_ep_update: - prompt = ( - input( - f"Update episodes metadata database before renaming? (last update: {last_ep_update}) (y/n): " - ) - .strip() - .lower() - ) - else: - prompt = ( - input("Update episodes metadata database before renaming? (y/n): ") - .strip() - .lower() - ) - if prompt == "y": - update_episodes_index_db() + +def _get_rename_prompt(last_ep_update): + """Get user prompt for updating episodes database before renaming.""" + if IS_DOCKER: + # In Docker mode, always update if database hasn't been updated + return "y" if not last_ep_update else "n" + + if not last_ep_update: + print("WARNING: Episodes metadata database has not been updated yet.") + return input("Update episodes metadata database before renaming? (y/n): ").strip().lower() + else: + return input( + f"Update episodes metadata database before renaming? (last update: {last_ep_update}) (y/n): " + ).strip().lower() + + +def _ensure_crc32_cache_complete(folder, conn): + """Ensure CRC32 cache includes all local video files for the folder. + If any video files in folder are not in the cache, runs calculate_local_crc32. + Respects config/data paths from get_config_dir (Docker vs local via env). + """ + total_files, recorded_files = _count_video_files(folder, conn) + if total_files == 0: + print("No video files found in folder; skipping CRC32 cache check.") + return + if recorded_files < total_files: + missing_count = total_files - recorded_files print( - "Renaming local files based on matching titles from One Pace episodes index..." + f"CRC32 cache missing {missing_count} of {total_files} files. " + "Calculating CRC32s for local files..." ) - rename_local_files(conn, folder) - return + calculate_local_crc32(folder, conn) + else: + print("CRC32 cache is up to date for local files.") - if not folder: - print("Error: --folder argument is required.") - return - last_missing_export = get_metadata(conn, "last_missing_export") - if last_missing_export: - print(f"Last missing files list generated on: {last_missing_export}") +def _handle_rename_command(conn, base_url=None, dry_run=False, folder=None): + """Handle the rename command. + Args: + conn: Database connection + base_url: Base URL for Nyaa search (optional) + dry_run: If True, only show rename plan and do not rename or ask for confirmation. + folder: Local media folder for CRC32 cache check (uses version-specific default if not set). + """ + episodes_db_conn = init_episodes_db() + last_ep_update = get_episodes_metadata( + episodes_db_conn, "episodes_db_last_update" + ) + episodes_db_conn.close() + + prompt = _get_rename_prompt(last_ep_update) + + if prompt == "y": + update_episodes_index_db(base_url) + if folder: + _ensure_crc32_cache_complete(folder, conn) + print( + "Renaming local files based on matching titles from One Pace episodes index..." + ) + rename_local_files(conn, dry_run=dry_run) - if args.db: - export_db_to_csv(conn) - return - # Count total video files and files already recorded in DB +def _count_video_files(folder, conn): + """Count total video files and files already recorded in DB.""" total_files = 0 recorded_files = 0 c = conn.cursor() @@ -731,9 +1461,68 @@ def main(): if ext in VIDEO_EXTENSIONS: total_files += 1 file_path = os.path.join(root, file) - c.execute("SELECT 1 FROM crc32_cache WHERE file_path = ?", (file_path,)) + # Normalize path for consistent lookup + normalized_path = normalize_file_path(file_path) + c.execute("SELECT 1 FROM crc32_cache WHERE file_path = ?", (normalized_path,)) if c.fetchone(): recorded_files += 1 + return total_files, recorded_files + + +def _load_old_missing_crc32s(): + """Load CRC32s from previous missing CSV file.""" + old_missing_crc32s = set() + missing_csv_path = get_config_path(MISSING_CSV_FILENAME) + if os.path.exists(missing_csv_path): + with open(missing_csv_path, "r", encoding="utf-8") as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) >= 1: + title = row[0] + # Extract CRC32 from title if possible + matches = CRC32_REGEX.findall(title) + if matches: + old_missing_crc32s.add(matches[-1].upper()) + return old_missing_crc32s + + +def _save_missing_episodes_csv(missing, crc32_to_text, crc32_to_link, crc32_to_magnet): + """Save missing episodes to CSV file.""" + missing_csv_path = get_config_path(MISSING_CSV_FILENAME) + saved_count = 0 + error_count = 0 + with open(missing_csv_path, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f, quoting=csv.QUOTE_ALL) + writer.writerow(["Title", "Page Link", CSV_COLUMN_MAGNET_LINK]) + for crc32 in missing: + try: + title = crc32_to_text.get(crc32, f"[CRC32: {crc32}]") + page_link = crc32_to_link.get(crc32, "") + magnet = crc32_to_magnet.get(crc32, "") + writer.writerow([title, page_link, magnet]) + saved_count += 1 + except (IOError, OSError, csv.Error) as e: + error_count += 1 + print(f"ERROR: Failed to save missing episode with CRC32 '{crc32}': {e}") + # Still write a row with available information + writer.writerow([f"[ERROR: CRC32 {crc32}]", "", ""]) + + print(f"Missing files list saved to {missing_csv_path}") + if error_count > 0: + print(f"WARNING: {error_count} episodes had errors while saving to CSV") + if saved_count == 0 and len(missing) > 0: + print(f"ERROR: No episodes were successfully saved to CSV despite {len(missing)} missing episodes!") + print("This indicates a critical issue with the CRC32 mapping.") + + +def _print_report_header(conn, folder, args): + """Print header information for the report.""" + last_missing_export = get_metadata(conn, "last_missing_export") + if last_missing_export: + print(f"Last missing files list generated on: {last_missing_export}") + + total_files, recorded_files = _count_video_files(folder, conn) last_run = get_metadata(conn, "last_run") if last_run: @@ -742,16 +1531,327 @@ def main(): now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") set_metadata(conn, "last_run", now_str) - print(f"Using URL: {args.url}") + # Show URL, but note that quality filtering (1080p only) is applied regardless + url_display = args.url + if "1080p" not in url_display: + url_display += " (quality filtering: 1080p only)" + print(f"Using URL: {url_display}") print(f"Total video files detected: {total_files}") print(f"Episodes already recorded in DB: {recorded_files}") - - crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page = ( - fetch_crc32_links(args.url) + + return last_run + + +def _print_troubleshooting_header(crc32_to_link, local_crc32s): + """Print initial troubleshooting information header.""" + debug_print("\n=== DEBUG: TROUBLESHOOTING INFO ===") + debug_print(f"Episodes from Nyaa (crc32_to_link keys): {len(crc32_to_link)}") + debug_print(f"Local CRC32s: {len(local_crc32s)}") + + # Check for empty sets + if len(crc32_to_link) == 0: + debug_print("WARNING: No episodes fetched from Nyaa! Check URL and quality filtering.") + if len(local_crc32s) == 0: + debug_print("WARNING: No local CRC32s found! Check folder path and file extensions.") + + # Show sample CRC32s from both sources (first 5) + if crc32_to_link: + sample_nyaa = list(crc32_to_link.keys())[:5] + debug_print(f"Sample Nyaa CRC32s (first 5): {sample_nyaa}") + debug_print(f"Sample Nyaa CRC32 types: {[type(c).__name__ for c in sample_nyaa]}") + if local_crc32s: + sample_local = list(local_crc32s)[:5] + debug_print(f"Sample local CRC32s (first 5): {sample_local}") + debug_print(f"Sample local CRC32 types: {[type(c).__name__ for c in sample_local]}") + + +def _normalize_crc32_sets(crc32_to_link, local_crc32s): + """Normalize CRC32 sets to uppercase strings for comparison. + Returns tuple: (nyaa_crc32s_normalized, local_crc32s_normalized)""" + nyaa_crc32s_normalized = {str(c).strip().upper() for c in crc32_to_link.keys()} + local_crc32s_normalized = {str(c).strip().upper() for c in local_crc32s} + + debug_print("\nAfter normalization:") + debug_print(f"Nyaa CRC32s: {len(nyaa_crc32s_normalized)}") + debug_print(f"Local CRC32s: {len(local_crc32s_normalized)}") + + # Check for matches using normalized sets + matches_normalized = nyaa_crc32s_normalized & local_crc32s_normalized + debug_print(f"Matches after normalization: {len(matches_normalized)}") + if matches_normalized: + debug_print(f"Sample matches (first 3): {list(matches_normalized)[:3]}") + + return nyaa_crc32s_normalized, local_crc32s_normalized + + +def _build_normalized_to_original_mapping(crc32_to_link, nyaa_crc32s_normalized): + """Build mapping from normalized CRC32 back to original key. + Returns tuple: (normalized_to_original dict, mapping_issues list)""" + normalized_to_original = {} + for orig_key in crc32_to_link.keys(): + norm_key = str(orig_key).strip().upper() + # If we already have this normalized key, keep the first one (shouldn't happen with CRC32s) + if norm_key not in normalized_to_original: + normalized_to_original[norm_key] = orig_key + + mapping_issues = [] + # Verify the mapping is correct + if len(normalized_to_original) != len(nyaa_crc32s_normalized): + debug_print(f"WARNING: Mapping size mismatch! normalized_to_original: {len(normalized_to_original)}, nyaa_crc32s_normalized: {len(nyaa_crc32s_normalized)}") + debug_print("This could indicate duplicate normalized CRC32s or mapping issues.") + # Show which normalized CRC32s are missing from the mapping + missing_from_mapping = nyaa_crc32s_normalized - set(normalized_to_original.keys()) + if missing_from_mapping: + mapping_issues = list(missing_from_mapping) + debug_print(f"Normalized CRC32s missing from mapping (first 5): {mapping_issues[:5]}") + + return normalized_to_original, mapping_issues + + +def _build_missing_list(missing_normalized_set, normalized_to_original, crc32_to_link): + """Build missing episodes list from normalized set. + Returns tuple: (missing list, mapping_errors list)""" + missing = [] + missing_normalized = list(missing_normalized_set) + mapping_errors = [] + + for norm_crc in missing_normalized: + if norm_crc in normalized_to_original: + missing.append(normalized_to_original[norm_crc]) + else: + # Try to find the original key by searching (fallback) + found = False + for orig_key in crc32_to_link.keys(): + if str(orig_key).strip().upper() == norm_crc: + missing.append(orig_key) + found = True + break + if not found: + mapping_errors.append(norm_crc) + debug_print(f"ERROR: Could not find original key for normalized CRC32 '{norm_crc}'") + + if mapping_errors: + debug_print(f"WARNING: {len(mapping_errors)} missing episodes could not be mapped to original keys!") + debug_print("This is a critical error - these episodes will not be included in the missing list.") + debug_print(f"Affected normalized CRC32s (first 10): {mapping_errors[:10]}") + + return missing, mapping_errors + + +def _print_comparison_results(nyaa_crc32s_normalized, local_crc32s_normalized, + crc32_to_link, local_crc32s, missing, missing_normalized): + """Print comparison results and troubleshooting information.""" + # Also check the original comparison for debugging + original_missing_count = len([c for c in crc32_to_link.keys() if c not in local_crc32s]) + debug_print(f"Missing episodes (original comparison): {original_missing_count}") + debug_print(f"Missing episodes (normalized comparison): {len(missing)}") + debug_print(f"Missing normalized CRC32s: {len(missing_normalized)}") + + if original_missing_count != len(missing): + debug_print(f"WARNING: Comparison mismatch detected! Original: {original_missing_count}, Normalized: {len(missing)}") + debug_print("This suggests a data type or format issue. Using normalized comparison.") + + # Show intersection details + intersection = nyaa_crc32s_normalized & local_crc32s_normalized + debug_print(f"Intersection (episodes found locally): {len(intersection)}") + if intersection: + debug_print(f"Sample found episodes (first 3): {list(intersection)[:3]}") + + # Show difference details + difference = nyaa_crc32s_normalized - local_crc32s_normalized + debug_print(f"Difference (episodes NOT found locally): {len(difference)}") + if difference: + debug_print(f"Sample missing episodes (first 3): {list(difference)[:3]}") + + # Check if sets are suspiciously similar (potential bug indicator) + if len(nyaa_crc32s_normalized) > 0 and len(local_crc32s_normalized) > 0: + similarity_ratio = len(intersection) / len(nyaa_crc32s_normalized) + debug_print(f"Similarity ratio (intersection/nyaa): {similarity_ratio:.2%}") + if similarity_ratio > 0.95 and len(difference) == 0: + debug_print("WARNING: Almost all Nyaa episodes appear to be found locally!") + debug_print("This might indicate a comparison bug or data issue.") + debug_print("Please verify that your local files actually contain all these episodes.") + + # Check for sets being identical (definite bug) + if nyaa_crc32s_normalized == local_crc32s_normalized: + debug_print("ERROR: Nyaa and local CRC32 sets are IDENTICAL!") + debug_print("This indicates a critical bug - the sets should not be the same.") + debug_print("Possible causes:") + debug_print(" - Local CRC32s are being populated from Nyaa data (wrong source)") + debug_print(" - Comparison is using the same set for both sides") + debug_print(" - Database corruption or incorrect data") + + debug_print("=== END DEBUG: TROUBLESHOOTING INFO ===\n") + + +def _should_force_episodes_update(last_update_str): + """Determine if episodes should be force updated based on last update time. + Args: + last_update_str: String timestamp of last update, or None + Returns: True if should force update, False if recently updated (within 10 minutes)""" + if not last_update_str: + return True + + try: + last_update = datetime.strptime(last_update_str, "%Y-%m-%d %H:%M:%S") + time_diff = datetime.now() - last_update + # If updated within last 10 minutes, skip to avoid double update + if time_diff.total_seconds() < 600: # 10 minutes = 600 seconds + print("EPISODES_UPDATE=true: Episodes were recently updated, using existing database...") + return False + except (ValueError, TypeError): + # If parsing fails, proceed with update + pass + return True + + +def _handle_episodes_update_decision(episodes_update_env, last_update_str, base_url): + """Handle the decision to update episodes based on EPISODES_UPDATE environment variable. + Args: + episodes_update_env: True if EPISODES_UPDATE environment variable is set + last_update_str: String timestamp of last update, or None + base_url: Base URL for Nyaa search + Returns: True if database should be used, False if should fetch from Nyaa""" + if episodes_update_env: + # EPISODES_UPDATE=true: Force update episodes even if recently updated + if _should_force_episodes_update(last_update_str): + print("EPISODES_UPDATE=true: Forcing episodes metadata update...") + update_episodes_index_db(base_url, force_update=True) + # After update (forced or skipped), always use database + return True + + # EPISODES_UPDATE=false or not set: Use database only, never fetch from Nyaa + if last_update_str: + return True + + # Database doesn't exist, need to fetch (but this shouldn't happen in normal operation) + print("Episodes database not found. Fetching from Nyaa...") + return False + + +def _load_episodes_from_database(episodes_update_env, base_url, fetch_magnets=True): + """Load episodes from database, including magnet links stored in database. + Args: + episodes_update_env: True if EPISODES_UPDATE environment variable is set + base_url: Base URL for Nyaa search (unused now, kept for compatibility) + fetch_magnets: If True, fetch missing magnet links from Nyaa. If False, use only database. + Returns: Tuple of (crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page)""" + if episodes_update_env: + print("Using episodes index database (EPISODES_UPDATE=true, using updated database)...") + else: + print("Using episodes index database (EPISODES_UPDATE=false, checking database only)...") + + crc32_to_link, crc32_to_text, crc32_to_magnet = load_1080p_episodes_from_index() + print(f"Loaded {len(crc32_to_link)} 1080p episodes from database.") + + # Count how many episodes have magnet links in database + episodes_with_magnets_count = sum(1 for m in crc32_to_magnet.values() if m) + print(f"Found {episodes_with_magnets_count} episodes with magnet links in database.") + + if fetch_magnets: + # Find episodes missing magnet links + missing_magnets = {crc32: crc32_to_link[crc32] for crc32 in crc32_to_link + if not crc32_to_magnet.get(crc32)} + if missing_magnets: + print(f"Fetching {len(missing_magnets)} missing magnet links from search results...") + fetched_magnets = fetch_magnet_links_for_episodes_from_search(base_url, missing_magnets) + # Update database magnet links with newly fetched ones + crc32_to_magnet.update(fetched_magnets) + print(f"Fetched {len(fetched_magnets)} new magnet links.") + + # Update database with newly fetched magnet links (batch update for efficiency) + conn = init_episodes_db() + c = conn.cursor() + c.executemany( + "UPDATE episodes_index SET magnet_link = ? WHERE crc32 = ?", + [(magnet_link, crc32) for crc32, magnet_link in fetched_magnets.items()] + ) + conn.commit() + conn.close() + + # Restrict to episodes we have magnet links for (matches previous behavior) + # This ensures we only count episodes that can actually be downloaded + # Filter to only episodes with non-empty magnet links that exist in crc32_to_link + episodes_with_magnets = {c: m for c, m in crc32_to_magnet.items() if m and c in crc32_to_link} + # Update all dictionaries to only include episodes with magnet links + # Note: All keys in episodes_with_magnets are guaranteed to exist in crc32_to_link and crc32_to_text + # since they're loaded together from the same database query + crc32_to_link = {c: crc32_to_link[c] for c in episodes_with_magnets} + crc32_to_text = {c: crc32_to_text[c] for c in episodes_with_magnets} + crc32_to_magnet = episodes_with_magnets + + return crc32_to_link, crc32_to_text, crc32_to_magnet, 0 + + +def _calculate_missing_episodes(crc32_to_link, local_crc32s): + """Calculate missing episodes by comparing Nyaa episodes with local CRC32s. + Args: + crc32_to_link: Dictionary mapping CRC32 to page_link + local_crc32s: Set of local CRC32 checksums + Returns: List of missing CRC32s""" + debug_print("DEBUG: Starting missing episode detection") + debug_print(f"DEBUG: Episodes from Nyaa: {len(crc32_to_link)}") + debug_print(f"DEBUG: Local CRC32s: {len(local_crc32s)}") + + # Print troubleshooting header + _print_troubleshooting_header(crc32_to_link, local_crc32s) + + # Normalize CRC32 sets + nyaa_crc32s_normalized, local_crc32s_normalized = _normalize_crc32_sets( + crc32_to_link, local_crc32s + ) + + # Find missing using normalized comparison + missing_normalized_set = nyaa_crc32s_normalized - local_crc32s_normalized + + # Build normalized to original mapping + normalized_to_original, _ = _build_normalized_to_original_mapping( + crc32_to_link, nyaa_crc32s_normalized + ) + + # Build missing list + missing, _ = _build_missing_list( + missing_normalized_set, normalized_to_original, crc32_to_link + ) + + # Print comparison results + _print_comparison_results( + nyaa_crc32s_normalized, local_crc32s_normalized, + crc32_to_link, local_crc32s, missing, list(missing_normalized_set) ) + + return missing + + +def _calculate_and_find_missing(folder, conn, args, last_run): + """Calculate local CRC32s and find missing episodes.""" + # Check EPISODES_UPDATE environment variable + episodes_update_env = os.getenv("EPISODES_UPDATE", "").lower() in ("true", "1", "yes", "on") + + # Check if episodes_index exists and has data + conn_episodes = init_episodes_db() + last_update_str = get_episodes_metadata(conn_episodes, "episodes_db_last_update") + + # Determine whether to use database or fetch from Nyaa + use_database = _handle_episodes_update_decision(episodes_update_env, last_update_str, args.url) + conn_episodes.close() + + # Load episodes (from database or fetch from Nyaa) + # Magnet links are now stored in the database, so we load them directly + if use_database: + # Load episodes from database, including magnet links + # fetch_magnets=True will fetch any missing magnet links from Nyaa + crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page = _load_episodes_from_database(episodes_update_env, args.url, fetch_magnets=True) + else: + # Normal fetch from Nyaa (only when database doesn't exist and EPISODES_UPDATE=false) + print("Fetching episodes metadata from Nyaa...") + crc32_to_link, crc32_to_text, crc32_to_magnet, last_checked_page = ( + fetch_crc32_links(args.url) + ) print(f"Found {len(crc32_to_link)} episodes from Nyaa.") + # Calculate local CRC32s if last_run: print("Calculating new local CRC32 hashes...") else: @@ -761,67 +1861,330 @@ def main(): local_crc32s = calculate_local_crc32(folder, conn) print(f"Found {len(local_crc32s)} local CRC32 hashes.") + + debug_print(f"DEBUG: Folder scanned: {folder}") - missing = [crc32 for crc32 in crc32_to_link if crc32 not in local_crc32s] + # Calculate missing episodes (only those with magnet links can be downloaded) + missing = _calculate_missing_episodes(crc32_to_link, local_crc32s) + # Filter to only missing episodes that have magnet links (redundant check removed) + missing = [crc32 for crc32 in missing if crc32_to_magnet.get(crc32)] print( f"\nSummary: {len(missing)} missing episodes out of {len(crc32_to_link)} total found on Nyaa.\n" ) - - # Check for new CRC32 in missing compared to old file if exists - old_missing_crc32s = set() - if os.path.exists("Ace-Pace_Missing.csv"): - with open("Ace-Pace_Missing.csv", "r", encoding="utf-8") as f: - reader = csv.reader(f) - next(reader, None) # skip header - for row in reader: - if len(row) >= 1: - title = row[0] - # Extract CRC32 from title if possible - matches = CRC32_REGEX.findall(title) - if matches: - old_missing_crc32s.add(matches[-1].upper()) - new_crc32s = set(missing) - old_missing_crc32s - if new_crc32s: - print(f"New missing episodes detected since last export: {len(new_crc32s)}") + + return missing, crc32_to_text, crc32_to_link, crc32_to_magnet, last_checked_page + + +def _report_new_missing_episodes(missing, crc32_to_text): + """Report newly detected missing episodes.""" + old_missing_crc32s = _load_old_missing_crc32s() + new_crc32s = set(missing) - old_missing_crc32s + if new_crc32s: + print(f"New missing episodes detected since last export: {len(new_crc32s)}") + # Only print individual episodes in DEBUG mode + if DEBUG_MODE: for crc32 in new_crc32s: title = crc32_to_text.get(crc32, "(Unknown Title)") - print(f"Missing: {title}") + debug_print(f"Missing: {title}") - with open("Ace-Pace_Missing.csv", "w", encoding="utf-8", newline="") as f: - writer = csv.writer(f, quoting=csv.QUOTE_ALL) - writer.writerow(["Title", "Page Link", "Magnet Link"]) - for crc32 in missing: - title = crc32_to_text[crc32] - page_link = crc32_to_link[crc32] - magnet = crc32_to_magnet.get(crc32, "") - writer.writerow([title, page_link, magnet]) - print("Missing files list saved to Ace-Pace_Missing.csv") +def _generate_missing_episodes_report(conn, folder, args): + """Generate and save missing episodes report.""" + last_run = _print_report_header(conn, folder, args) + + missing, crc32_to_text, crc32_to_link, crc32_to_magnet, last_checked_page = ( + _calculate_and_find_missing(folder, conn, args, last_run) + ) + + _report_new_missing_episodes(missing, crc32_to_text) + + _save_missing_episodes_csv(missing, crc32_to_text, crc32_to_link, crc32_to_magnet) set_metadata(conn, "last_checked_page", str(last_checked_page)) now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") set_metadata(conn, "last_missing_export", now_str) + + # Print missing count prominently + print(f"Missing episodes: {len(missing)}") - if missing: - prompt = ( - input( - "Do you want to add missing episodes to a BitTorrent client now? (y/n): " - ) - .strip() - .lower() + return missing, crc32_to_text + + +def _print_help(): + """Print detailed help information about all available commands.""" + help_text = """ +Ace-Pace - Find missing episodes from your personal One Pace library + +AVAILABLE COMMANDS: + + Main Operations: + (no flags) Generate missing episodes report + Scans local folder, calculates CRC32 hashes, and compares + with episodes available on Nyaa to find missing episodes. + Outputs results to Ace-Pace_Missing.csv + + --episodes_update Update episodes from Nyaa and generate missing report + First fetches all One Pace episodes from Nyaa and stores + CRC32, title, page link, and magnet link in the episodes index. + Then runs the missing episodes report (same as main command): + scans local folder, compares with Nyaa, outputs Ace-Pace_Missing.csv. + In Docker mode, --folder defaults to /media if not set. + + --rename Rename local files based on CRC32 matching + Matches local video files with episodes in the database + and renames them to match the official episode titles. + Prompts to update episodes database if it's outdated. + + --db Export local CRC32 database to CSV + Exports the database of calculated CRC32 hashes for + local video files to Ace-Pace_DB.csv + + --download Download missing episodes via BitTorrent client + Reads magnet links from Ace-Pace_Missing.csv and adds + them to the specified BitTorrent client (requires --client) + + --dry-run Test connection to BitTorrent client without adding torrents + Validates magnet links and checks existing torrents but + does not add any downloads. Useful for verifying configuration. + Only effective when used with --download. + + BitTorrent Client Options (for --download): + --client {transmission,qbittorrent} + Specify which BitTorrent client to use + Required when using --download + + --host HOST BitTorrent client host (default: localhost) + + --port PORT BitTorrent client port + Defaults: Transmission=9091, qBittorrent=8080 + + --username USERNAME BitTorrent client username (if required) + + --password PASSWORD BitTorrent client password (if required) + + --download-folder PATH Folder where torrents should be downloaded + Default: /media (in Docker) or client default + + --tag TAG Add tag to torrents in qBittorrent + Can be used multiple times to add multiple tags + + --category CATEGORY Add category to torrents in qBittorrent + + General Options: + --url URL Custom Nyaa search URL + Default: https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc + Note: Quality filtering (1080p only) is applied in code regardless of URL + Must point to a valid Nyaa domain + + --folder PATH Folder containing local video files + If not specified, will prompt for input + In Docker mode, defaults to /media + +EXAMPLES: + + # Generate missing episodes report + python acepace.py --folder /path/to/videos + + # Update episodes database + python acepace.py --episodes_update + + # Rename local files to match episode titles + python acepace.py --folder /path/to/videos --rename + + # Download missing episodes to qBittorrent + python acepace.py --download --client qbittorrent --host localhost --port 8080 + + # Test connection without downloading (dry run) + python acepace.py --download --client transmission --dry-run + + # Export database to CSV + python acepace.py --folder /path/to/videos --db + +For more information, visit: https://github.com/your-repo/ace-pace +""" + print(help_text) + + +def _parse_arguments(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Find missing episodes from your personal One Pace library.", + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, # Disable automatic help to use custom one + epilog=""" +Examples: + python acepace.py --folder /path/to/videos + python acepace.py --episodes_update + python acepace.py --rename --folder /path/to/videos + python acepace.py --download --client qbittorrent + +Use --help for detailed command descriptions. + """ + ) + parser.add_argument( + "--help", "-h", + action="store_true", + help="Show detailed help message with all available commands." + ) + parser.add_argument( + "--url", + default=f"{NYAA_BASE_URL}/?f=0&c=0_0&q=one+pace&o=asc", + help=f"Base URL without the page param. Default searches for 'one pace' without quality filter (quality filtering 1080p only is applied in code). Example: '{NYAA_BASE_URL}/?f=0&c=0_0&q=one+pace&o=asc' ", + ) + parser.add_argument("--folder", help="Folder containing local video files.") + parser.add_argument( + "--db", action="store_true", help="Export database to CSV and exit." + ) + parser.add_argument( + "--client", + choices=["transmission", "qbittorrent"], + help="The BitTorrent client to use (required for --download).", + ) + parser.add_argument( + "--download", + action="store_true", + help="Import magnet links from missing CSV and add to the specified BitTorrent client.", + ) + parser.add_argument( + "--rename", + action="store_true", + help="Rename local files based on CRC32 matching titles from Nyaa.", + ) + parser.add_argument( + "--episodes_update", + action="store_true", + help="Update episodes from Nyaa, then run missing episodes report (like main command).", + ) + parser.add_argument("--host", default="localhost", help="The BitTorrent client host.") + parser.add_argument("--port", type=int, help="The BitTorrent client port.") + parser.add_argument("--username", help="The BitTorrent client username.") + parser.add_argument("--password", help="The BitTorrent client password.") + parser.add_argument("--download-folder", help="The folder to download the torrents to.") + parser.add_argument("--tag", action="append", help="Tag to add to the torrent in qBittorrent (can be used multiple times).") + parser.add_argument("--category", help="Category to add to the torrent in qBittorrent.") + parser.add_argument( + "--dry-run", + action="store_true", + help="Download: test client without adding torrents. Rename: show rename plan without renaming.", + ) + return parser.parse_args() + + +def _validate_url(url): + """Validate that URL points to a valid Nyaa domain.""" + if not url.startswith((NYAA_BASE_URL, "https://nyaa.land")): + print( + f"Error: The --url argument must point to a valid Nyaa website ({NYAA_BASE_URL} or https://nyaa.land)." ) - if prompt == "y": - client = ( - input("Enter client name (currently supported: transmission): ") - .strip() - .lower() - ) - if client: - download_missing_to_client(client) - else: - print("No client specified. Skipping download.") + return False + return True + +def _show_episodes_metadata_status(): + """Show last episodes metadata update status.""" + episodes_db_conn = init_episodes_db() + last_ep_update = get_episodes_metadata(episodes_db_conn, "episodes_db_last_update") + if last_ep_update: + print(f"Episodes metadata last updated: {last_ep_update}") + else: + print("Episodes metadata database not yet updated.") + episodes_db_conn.close() + + +def _handle_main_commands(args, conn, folder): + """Handle main command execution.""" + if args.download: + _handle_download_command(args) + return + + if args.rename: + _handle_rename_command(conn, args.url, dry_run=args.dry_run, folder=folder) + return + + if not folder: + print("Error: --folder argument is required.") + return + + if args.db: + export_db_to_csv(conn) + return + + _generate_missing_episodes_report(conn, folder, args) + + # Note: To download missing episodes, use --download flag with --client + + +def _print_header(): + """Print Ace-Pace header banner.""" + print("=" * 60) + print(" " * 20 + "Ace-Pace") + print(" " * 12 + "One Pace Library Manager") + release = _get_release_date() + if release: + print(" " * (26 - len(release) // 2) + f"Release {release}") + print("=" * 60) + if IS_DOCKER: + print("Running in Docker mode (non-interactive)") + print("-" * 60) + print() + + +def main(): + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGTERM, _signal_handler) + signal.signal(signal.SIGINT, _signal_handler) + + try: + args = _parse_arguments() + + # Show detailed help if requested + if args.help: + _print_help() + sys.exit(0) + + # Print header only for main command (not for --db or --episodes_update) + # In Docker, entrypoint.sh already prints the header once; skip here to avoid duplicate + if not IS_DOCKER and not args.db and not args.episodes_update and not args.help: + _print_header() + + if not _validate_url(args.url): + sys.exit(1) + + # Only show episodes metadata status for main command (not for --db or --episodes_update) + if not args.db and not args.episodes_update: + _show_episodes_metadata_status() + + if args.episodes_update: + # When --episodes_update is used: update episodes from Nyaa, then run missing episodes report (like main command) + update_episodes_index_db(args.url, force_update=True) + conn = init_db(suppress_messages=False) + needs_folder = True # Missing report requires folder + folder = _get_folder_from_args(args, conn, needs_folder) + if folder is None: + sys.exit(1) + _generate_missing_episodes_report(conn, folder, args) + sys.exit(0) + + # Suppress messages when exporting DB (since it's automated) + conn = init_db(suppress_messages=args.db) + + # Folder selection logic: Always prompt if folder is required but not given + needs_folder = not args.download # All commands except --download need folder + folder = _get_folder_from_args(args, conn, needs_folder) + if folder is None: + sys.exit(1) + + _handle_main_commands(args, conn, folder) + + # Exit cleanly (code 0) even if shutdown was requested during processing + sys.exit(0) + except KeyboardInterrupt: + print("\nInterrupted by user, exiting gracefully...") + sys.exit(130) # Standard exit code for SIGINT + except Exception as e: + print(f"Error: {e}") + sys.exit(1) if __name__ == "__main__": main() diff --git a/clients.py b/clients.py new file mode 100644 index 0000000..a65c447 --- /dev/null +++ b/clients.py @@ -0,0 +1,295 @@ +import abc +import getpass +import time +import requests # type: ignore +import qbittorrentapi # type: ignore +import re + +# Rate limiting delay between torrent operations (in seconds) +TORRENT_OPERATION_DELAY = 0.1 + +class Client(abc.ABC): + @abc.abstractmethod + def add_torrents(self, magnets, download_folder=None, tags=None, category=None, dry_run=False): + pass + +class QBittorrentClient(Client): + def __init__(self, host, port, username, password): + self.client = qbittorrentapi.Client( + host=host, + port=port, + username=username, + password=password + ) + try: + self.client.auth_log_in() + except qbittorrentapi.LoginFailed as e: + raise ConnectionError(f"Failed to authenticate with qBittorrent at {host}:{port}: {e}") from e + except qbittorrentapi.APIConnectionError as e: + raise ConnectionError(f"Failed to connect to qBittorrent at {host}:{port}. Check if the client is running and accessible: {e}") from e + except qbittorrentapi.APIError as e: + raise ConnectionError(f"qBittorrent API error at {host}:{port}: {e}") from e + except Exception as e: + raise ConnectionError(f"Unexpected error connecting to qBittorrent at {host}:{port}: {e}") from e + print("Connection to qBittorrent successful!") + + def _extract_info_hash(self, magnet): + """Extract info hash from magnet link.""" + match = re.search(r"xt=urn:btih:([a-fA-F0-9]{40})", magnet) + if not match: + return None + return match.group(1).lower() + + def _handle_existing_torrent(self, info_hash, tags_str, truncated): + """Handle case when torrent already exists.""" + print(f"Torrent {truncated} already exists.") + if tags_str: + print(f"Adding tags to existing torrent: {tags_str}") + self.client.torrents_add_tags(tags=tags_str, torrent_hashes=info_hash) + + def _add_new_torrent(self, magnet, download_folder, tags_str, category, truncated): + """Add a new torrent to qBittorrent.""" + print(f"Adding new torrent: {truncated}") + try: + self.client.torrents_add( + urls=magnet, + save_path=download_folder if download_folder else None, + tags=tags_str, + category=category, + ) + return True + except Exception as e: + print(f"Failed to add torrent: {truncated} Error: {e}") + return False + + def _process_torrent_dry_run(self, magnet, idx, total, tags_str, category): + """Process a single torrent in dry-run mode.""" + truncated = magnet[:50] + ("..." if len(magnet) > 50 else "") + print(f"DRY RUN: Processing torrent {idx}/{total}: {truncated}") + + info_hash = self._extract_info_hash(magnet) + if not info_hash: + print(f"DRY RUN: Could not find info hash in magnet link: {truncated}") + return "invalid" + + try: + existing_torrent = self.client.torrents_info(torrent_hashes=info_hash) + if existing_torrent: + print(f"DRY RUN: Torrent already exists: {truncated}") + if tags_str: + print(f"DRY RUN: Would add tags to existing torrent: {tags_str}") + return "existing" + else: + print(f"DRY RUN: Would add new torrent: {truncated}") + if tags_str: + print(f"DRY RUN: Tags: {tags_str}") + if category: + print(f"DRY RUN: Category: {category}") + return "valid" + except Exception as e: + print(f"DRY RUN: Error checking torrent: {truncated} Error: {e}") + return "invalid" + + def _add_torrents_dry_run(self, magnets, tags, category): + """Handle dry-run mode for adding torrents.""" + print("DRY RUN: Validating magnet links and checking existing torrents...") + if tags: + print(f"DRY RUN: Would create tags: {', '.join(tags)}") + + total = len(magnets) + tags_str = ",".join(tags) if tags else None + valid_count = 0 + existing_count = 0 + invalid_count = 0 + + for idx, magnet in enumerate(magnets, 1): + result = self._process_torrent_dry_run(magnet, idx, total, tags_str, category) + if result == "valid": + valid_count += 1 + elif result == "existing": + existing_count += 1 + else: + invalid_count += 1 + time.sleep(TORRENT_OPERATION_DELAY) + + print(f"DRY RUN: Summary - {valid_count} would be added, {existing_count} already exist, {invalid_count} invalid") + + def _add_torrents_execute(self, magnets, download_folder, tags, category): + """Execute adding torrents (non-dry-run mode).""" + if tags: + self.client.torrents_create_tags(tags=",".join(tags)) + + added_count = 0 + total = len(magnets) + tags_str = ",".join(tags) if tags else None + for idx, magnet in enumerate(magnets, 1): + truncated = magnet[:50] + ("..." if len(magnet) > 50 else "") + print(f"Processing torrent {idx}/{total}: {truncated}") + + info_hash = self._extract_info_hash(magnet) + if not info_hash: + print(f"Could not find info hash in magnet link: {truncated}") + continue + + existing_torrent = self.client.torrents_info(torrent_hashes=info_hash) + if existing_torrent: + self._handle_existing_torrent(info_hash, tags_str, truncated) + else: + if self._add_new_torrent(magnet, download_folder, tags_str, category, truncated): + added_count += 1 + time.sleep(TORRENT_OPERATION_DELAY) + print(f"Added {added_count} new torrents to qBittorrent.") + + def add_torrents(self, magnets, download_folder=None, tags=None, category=None, dry_run=False): + if dry_run: + self._add_torrents_dry_run(magnets, tags, category) + else: + self._add_torrents_execute(magnets, download_folder, tags, category) + + +class TransmissionClient(Client): + def __init__(self, host, port, username, password): + self.base_url = f"http://{host}:{port}/transmission/rpc" + self.session_id = None + self.session = requests.Session() + self.auth = (username, password) if username else None + + # Test connection and get session ID + try: + headers = {} + if self.session_id: + headers["X-Transmission-Session-Id"] = self.session_id + resp = self.session.post( + self.base_url, auth=self.auth, headers=headers, json={"method": "session-get"} + ) + if resp.status_code == 409: + new_session_id = resp.headers.get("X-Transmission-Session-Id") + if new_session_id: + self.session_id = new_session_id + headers["X-Transmission-Session-Id"] = self.session_id + resp = self.session.post( + self.base_url, auth=self.auth, headers=headers, json={"method": "session-get"} + ) + resp.raise_for_status() + except requests.ConnectionError as e: + raise ConnectionError(f"Failed to connect to Transmission at {host}:{port}. Check if the client is running and accessible: {e}") from e + except requests.Timeout as e: + raise ConnectionError(f"Connection to Transmission at {host}:{port} timed out: {e}") from e + except requests.RequestException as e: + raise ConnectionError(f"Failed to connect to Transmission RPC at {host}:{port}: {e}") from e + except ValueError as e: + raise ConnectionError(f"Invalid response from Transmission at {host}:{port}: {e}") from e + + print("Connection to Transmission successful!") + self.session_info = resp.json() + + + def _make_rpc_request(self, payload): + """Make an RPC request to Transmission, handling session ID updates.""" + headers = {"X-Transmission-Session-Id": self.session_id} if self.session_id else {} + resp = self.session.post(self.base_url, auth=self.auth, headers=headers, json=payload) + if resp.status_code == 409: + new_session_id = resp.headers.get("X-Transmission-Session-Id") + if new_session_id: + self.session_id = new_session_id + headers["X-Transmission-Session-Id"] = self.session_id + resp = self.session.post(self.base_url, auth=self.auth, headers=headers, json=payload) + return resp + + def _add_single_torrent(self, magnet, download_folder, truncated): + """Add a single torrent to Transmission.""" + payload = {"method": "torrent-add", "arguments": {"filename": magnet}} + if download_folder: + payload["arguments"]["download-dir"] = download_folder + try: + resp = self._make_rpc_request(payload) + resp.raise_for_status() + result = resp.json() + if result.get("result") == "success": + return True + print(f"Failed to add torrent: {truncated} Error: {result.get('result')}") + return False + except Exception as e: + print(f"Failed to add torrent: {truncated} Error: {e}") + return False + + def _validate_magnet_link(self, magnet): + """Validate a magnet link format.""" + if not magnet.startswith("magnet:?"): + return False + if "xt=urn:btih:" not in magnet: + return False + return True + + def _process_torrent_dry_run(self, magnet, idx, total): + """Process a single torrent in dry-run mode.""" + truncated = magnet[:50] + ("..." if len(magnet) > 50 else "") + print(f"DRY RUN: Processing {idx}/{total}: {truncated}") + + if not self._validate_magnet_link(magnet): + if not magnet.startswith("magnet:?"): + print(f"DRY RUN: Invalid magnet link format: {truncated}") + else: + print(f"DRY RUN: Magnet link missing info hash: {truncated}") + return False + + return True + + def _add_torrents_dry_run(self, magnets): + """Handle dry-run mode for adding torrents.""" + if not magnets: + print("DRY RUN: No magnet links to process.") + return + + print("DRY RUN: Validating magnet links...") + + # Filter out empty/None magnets before processing to ensure consistent indexing + valid_magnets = [m for m in magnets if m and m.strip()] + if len(valid_magnets) != len(magnets): + skipped = len(magnets) - len(valid_magnets) + print(f"DRY RUN: Warning - {skipped} empty or invalid magnet link(s) skipped.") + + total = len(valid_magnets) + valid_count = 0 + invalid_count = 0 + + for idx, magnet in enumerate(valid_magnets, 1): + if self._process_torrent_dry_run(magnet, idx, total): + valid_count += 1 + else: + invalid_count += 1 + time.sleep(TORRENT_OPERATION_DELAY) + + print(f"DRY RUN: Summary - {valid_count} valid magnet links would be added, {invalid_count} invalid") + + def _add_torrents_execute(self, magnets, download_folder): + """Execute adding torrents (non-dry-run mode).""" + added_count = 0 + total = len(magnets) + for idx, magnet in enumerate(magnets, 1): + truncated = magnet[:50] + ("..." if len(magnet) > 50 else "") + print(f"Adding {idx}/{total}: {truncated}") + if self._add_single_torrent(magnet, download_folder, truncated): + added_count += 1 + time.sleep(TORRENT_OPERATION_DELAY) + print(f"Added {added_count} torrents to Transmission.") + + def add_torrents(self, magnets, download_folder=None, tags=None, category=None, dry_run=False): + if tags or category: + warning_msg = "DRY RUN: Warning - " if dry_run else "Warning: " + warning_msg += "Transmission does not support tags or categories through this script." + print(warning_msg) + + if dry_run: + self._add_torrents_dry_run(magnets) + else: + self._add_torrents_execute(magnets, download_folder) + + +def get_client(client_name, host, port, username, password): + if client_name == 'qbittorrent': + return QBittorrentClient(host, port, username, password) + elif client_name == 'transmission': + return TransmissionClient(host, port, username, password) + else: + raise ValueError(f'Unknown client: {client_name}') diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..78a0c00 --- /dev/null +++ b/coverage.xml @@ -0,0 +1,1455 @@ + + + + + + + . + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e1e5831 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + ace-pace: + # build: . + image: timothe/ace-pace:latest # or timothe/ace-pace:dev for dev branch + container_name: ace-pace + volumes: + - /path/to/OnePaceLibrary:/media:rw + - /path/to/config:/config:rw + # network_mode: host + environment: + # Timezone + - TZ=Europe/London + + # Export database to CSV on container start (default: false) + # - DB=true + # Update episodes index database on container start (default: false) + - EPISODES_UPDATE=true + # Nyaa.si search URL (optional, default: https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc) + # Quality filtering (1080p only) is always applied in code regardless of URL + #- NYAA_URL=https://nyaa.si/?f=0&c=0_0&q=one+pace&o=asc + + # Rename local files under /media to match One-Pace episode titles (default: false) + # Non-interactive; use DRY_RUN=true to simulate renaming without changing files + #- RENAME=true + + # Download missing episodes after generating report (default: false) + #- DOWNLOAD=true + # With DOWNLOAD: test client without adding torrents. With RENAME: simulate only (default: false) + #- DRY_RUN=true + + # BitTorrent client type (default: transmission) + # Options: transmission, qbittorrent + #- TORRENT_CLIENT=transmission + # BitTorrent client host (default: localhost) + #- TORRENT_HOST=127.0.0.1 + # BitTorrent client port (default: 9091 for transmission, 8080 for qbittorrent) + #- TORRENT_PORT=9091 + # BitTorrent client username (default: empty, not required) + #- TORRENT_USER=admin + # BitTorrent client password (default: empty, not required) + #- TORRENT_PASSWORD=password + + # Enable debug output for troubleshooting (default: false) + #- DEBUG=true diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..968d969 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,100 @@ +#!/bin/sh + +# Signal handler for graceful shutdown +# Signal numbers: 15 = SIGTERM, 2 = SIGINT +# Python processes run in foreground and will receive signals directly +# This handler ensures clean exit if signal arrives between commands +cleanup() { + echo "Received shutdown signal, exiting gracefully..." + exit 0 +} + +# Set up signal handlers using signal numbers (POSIX compatible) +# Note: When Python runs in foreground, it receives signals directly +# This trap handles signals that arrive when no Python process is running +trap 'cleanup' 15 2 + +# Print Ace-Pace header (release date = mtime of acepace.py, no repo commits) +RELEASE_DATE=$(stat -c %y /app/acepace.py 2>/dev/null | cut -d' ' -f1 || true) +echo "============================================================" +echo " Ace-Pace" +echo " One Pace Library Manager" +if [ -n "$RELEASE_DATE" ]; then echo " Release $RELEASE_DATE"; fi +echo "============================================================" +echo "Running in Docker mode (non-interactive)" +echo "------------------------------------------------------------" +echo "" + +# Media folder: ACEPACE_MEDIA_DIR_DOCKER (default /media) +MEDIA_DIR="${ACEPACE_MEDIA_DIR_DOCKER:-/media}" + +# Run episodes update if requested +if [ "$EPISODES_UPDATE" = "true" ]; then + python /app/acepace.py --episodes_update ${NYAA_URL:+--url "$NYAA_URL"} + EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ]; then + echo "Episodes update failed with exit code $EXIT_CODE" + exit $EXIT_CODE + fi +fi + +# Export database if requested +if [ "$DB" = "true" ]; then + python /app/acepace.py --db + EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ]; then + echo "Database export failed with exit code $EXIT_CODE" + exit $EXIT_CODE + fi +fi + +# Run missing episodes report (unless already done by --episodes_update above) +# When EPISODES_UPDATE=true, the report was already run in step 1 (--episodes_update does both) +# Skip when only exporting DB (DB=true and no other operations) +if [ "$EPISODES_UPDATE" != "true" ] && { [ "$DB" != "true" ] || [ "$DOWNLOAD" = "true" ]; }; then + python /app/acepace.py \ + --folder "$MEDIA_DIR" \ + ${NYAA_URL:+--url "$NYAA_URL"} + EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ]; then + echo "Missing episodes report failed with exit code $EXIT_CODE" + exit $EXIT_CODE + fi +fi + +# Run rename if requested (non-interactive: dry-run simulates, otherwise renames without confirmation) +if [ "$RENAME" = "true" ]; then + DRY_RUN_RENAME_ARG="" + [ "$DRY_RUN" = "true" ] || [ "$DRY_RUN" = "1" ] || [ "$DRY_RUN" = "yes" ] || [ "$DRY_RUN" = "on" ] && DRY_RUN_RENAME_ARG="--dry-run" + python /app/acepace.py \ + --folder "$MEDIA_DIR" \ + --rename \ + ${NYAA_URL:+--url "$NYAA_URL"} \ + ${DRY_RUN_RENAME_ARG:+$DRY_RUN_RENAME_ARG} + EXIT_CODE=$? + if [ $EXIT_CODE -ne 0 ]; then + echo "Rename failed with exit code $EXIT_CODE" + exit $EXIT_CODE + fi +fi + +# If DOWNLOAD is set to true, download missing episodes after generating report +if [ "$DOWNLOAD" = "true" ]; then + # Only add --dry-run when DRY_RUN is explicitly true (not when set to "false" or empty) + DRY_RUN_ARG="" + [ "$DRY_RUN" = "true" ] || [ "$DRY_RUN" = "1" ] || [ "$DRY_RUN" = "yes" ] || [ "$DRY_RUN" = "on" ] && DRY_RUN_ARG="--dry-run" + # Use exec to replace shell process so Python becomes PID 1 and receives signals directly + exec python /app/acepace.py \ + --folder "$MEDIA_DIR" \ + ${NYAA_URL:+--url "$NYAA_URL"} \ + --download \ + ${DRY_RUN_ARG:+$DRY_RUN_ARG} \ + ${TORRENT_CLIENT:+--client "$TORRENT_CLIENT"} \ + ${TORRENT_HOST:+--host "$TORRENT_HOST"} \ + ${TORRENT_PORT:+--port "$TORRENT_PORT"} \ + ${TORRENT_USER:+--username "$TORRENT_USER"} \ + ${TORRENT_PASSWORD:+--password "$TORRENT_PASSWORD"} +fi + +# Exit with success code if we reach here +exit 0 \ No newline at end of file diff --git a/episodes_index.db b/episodes_index.db index 47b56e3..b61028c 100644 Binary files a/episodes_index.db and b/episodes_index.db differ diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..9926555 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,12 @@ +{ + "include": [ + "." + ], + "exclude": [ + "**/__pycache__", + "**/node_modules" + ], + "reportMissingImports": "warning", + "reportMissingTypeStubs": false, + "pythonVersion": "3.8" +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3676feb --- /dev/null +++ b/pytest.ini @@ -0,0 +1,18 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short + --cov=. + --cov-report=term-missing + --cov-report=xml + --cov-report=html + --cov-branch +markers = + unit: Unit tests + integration: Integration tests + slow: Slow running tests diff --git a/requirements.txt b/requirements.txt index 1190bd8..ee7cdb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,7 @@ requests beautifulsoup4 +qbittorrent-api +pytest>=7.0.0 +pytest-mock>=3.10.0 +pytest-cov>=4.0.0 +coverage>=7.0.0 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2a4393d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,100 @@ +# Ace-Pace Test Suite + +This directory contains unit tests for the Ace-Pace application. + +## Test Structure + +- `test_database.py` - Tests for database initialization and metadata operations +- `test_crc32.py` - Tests for CRC32 calculation and extraction +- `test_episodes.py` - Tests for episode metadata fetching from Nyaa +- `test_file_operations.py` - Tests for file operations and renaming +- `test_clients.py` - Tests for BitTorrent client integrations +- `test_missing_detection.py` - Tests for missing episode detection logic +- `conftest.py` - Shared fixtures and test utilities + +## Running Tests + +To run all tests: + +```bash +pytest tests/ +``` + +To run a specific test file: + +```bash +pytest tests/test_database.py +``` + +To run a specific test: + +```bash +pytest tests/test_database.py::TestDatabaseInitialization::test_init_db_creates_tables +``` + +To run with verbose output: + +```bash +pytest tests/ -v +``` + +To run with coverage: + +```bash +pytest tests/ --cov=acepace --cov=clients +``` + +## Test Coverage + +The test suite covers: + +1. **Database Operations** + - Database initialization + - Metadata get/set operations + - Episodes index operations + +2. **CRC32 Operations** + - CRC32 extraction from filenames + - CRC32 calculation from file content + - Caching of CRC32 values + +3. **Episode Metadata** + - Fetching episodes from Nyaa + - Handling pagination + - Extracting CRC32 from titles and file lists + - Updating episodes index database + +4. **File Operations** + - File renaming based on CRC32 matching + - Filename sanitization + - CSV export functionality + +5. **BitTorrent Clients** + - qBittorrent client initialization and operations + - Transmission client initialization and operations + - Client factory function + - Error handling + +6. **Missing Episode Detection** + - Comparing local and remote CRC32s + - Generating missing episode lists + - Fetching CRC32 links from Nyaa + +## Mocking + +Tests use mocking for: +- Network requests (requests.get) +- File system operations +- BitTorrent client APIs +- User input prompts +- Time delays + +## Fixtures + +Common fixtures are defined in `conftest.py`: +- `temp_dir` - Temporary directory for test files +- `temp_db_path` - Temporary database path +- `sample_video_content` - Sample video content for testing +- `sample_crc32` - Sample CRC32 value +- `sample_episode_data` - Sample episode data +- `mock_nyaa_html_*` - Mock HTML responses from Nyaa diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ab0593c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for Ace-Pace diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0227402 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,146 @@ +"""Shared fixtures for Ace-Pace tests.""" +import pytest +import sqlite3 +import os +import tempfile +import shutil +from pathlib import Path + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + temp_path = tempfile.mkdtemp() + yield temp_path + shutil.rmtree(temp_path) + + +@pytest.fixture +def temp_db_path(temp_dir): + """Create a temporary database path.""" + return os.path.join(temp_dir, "test_crc32_files.db") + + +@pytest.fixture +def temp_episodes_db_path(temp_dir): + """Create a temporary episodes database path.""" + return os.path.join(temp_dir, "test_episodes_index.db") + + +@pytest.fixture +def sample_video_content(): + """Sample video file content for CRC32 testing.""" + return b"This is sample video content for testing CRC32 calculation" * 100 + + +@pytest.fixture +def sample_crc32(): + """Sample CRC32 value for testing.""" + return "A1B2C3D4" + + +@pytest.fixture +def sample_episode_data(): + """Sample episode data for testing.""" + return [ + ("A1B2C3D4", "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", "https://nyaa.si/view/12345", "magnet:?xt=urn:btih:abc123"), + ("E5F6A7B8", "[One Pace] Episode 2 [1080p][E5F6A7B8].mkv", "https://nyaa.si/view/12346", "magnet:?xt=urn:btih:def456"), + ("A9B0C1D2", "[One Pace] Episode 3 [1080p][A9B0C1D2].mkv", "https://nyaa.si/view/12347", "magnet:?xt=urn:btih:ghi789"), + ] + + +@pytest.fixture +def mock_nyaa_html_single_page(): + """Mock HTML for a single Nyaa.si page.""" + return """ + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+ [One Pace] Episode 2 [1080p][E5F6A7B8].mkv + Magnet +
+
    +
  • 1
  • +
+ + + """ + + +@pytest.fixture +def mock_nyaa_html_multi_page(): + """Mock HTML for multi-page Nyaa.si results.""" + return """ + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+
    +
  • 1
  • +
  • 2
  • +
  • 3
  • +
+ + + """ + + +@pytest.fixture +def mock_nyaa_torrent_page(): + """Mock HTML for a single torrent page with file list.""" + return """ + + +
+
    +
  • [One Pace] Episode 1 [1080p][A1B2C3D4].mkv
  • +
+
+ + + """ + + +@pytest.fixture +def mock_nyaa_torrent_page_folder(): + """Mock HTML for a torrent page with folder structure.""" + return """ + + +
+ One Pace +
    +
  • +
      +
    • [One Pace] Episode 1 [1080p][A1B2C3D4].mkv
    • +
    +
  • +
+
+ + + """ + + +@pytest.fixture +def sample_magnet_links(): + """Sample magnet links for testing.""" + return [ + "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&dn=test", + "magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abcdef12&dn=test2", + ] diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..98c1cf5 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,374 @@ +"""Unit tests for BitTorrent client operations.""" +import pytest +import sys +import os +from unittest.mock import patch, MagicMock, Mock +from requests.structures import CaseInsensitiveDict + +# Add parent directory to path to import clients +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from clients import QBittorrentClient, TransmissionClient, get_client + + +class TestQBittorrentClient: + """Tests for qBittorrent client.""" + + @patch('clients.qbittorrentapi.Client') + def test_qbittorrent_client_init_success(self, mock_client_class): + """Test successful qBittorrent client initialization.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + + assert client.client == mock_client + mock_client.auth_log_in.assert_called_once() + + @patch('clients.qbittorrentapi.Client') + def test_qbittorrent_client_init_login_failed(self, mock_client_class): + """Test qBittorrent client initialization with login failure.""" + import qbittorrentapi + mock_client = MagicMock() + mock_client.auth_log_in.side_effect = qbittorrentapi.LoginFailed("Invalid credentials") + mock_client_class.return_value = mock_client + + with pytest.raises(ConnectionError) as exc_info: + QBittorrentClient("localhost", 8080, "user", "pass") + + assert "Failed to authenticate with qBittorrent" in str(exc_info.value) + assert "localhost:8080" in str(exc_info.value) + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_add_torrents(self, mock_sleep, mock_client_class, sample_magnet_links): + """Test adding torrents to qBittorrent.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + mock_client.torrents_info.return_value = [] # No existing torrents + mock_client.torrents_add.return_value = None + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + client.add_torrents(sample_magnet_links, download_folder="/downloads", tags=["test"], category="anime") + + assert mock_client.torrents_add.call_count == 2 + mock_client.torrents_create_tags.assert_called_once() + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_add_torrents_duplicate(self, mock_sleep, mock_client_class, sample_magnet_links): + """Test adding duplicate torrents to qBittorrent.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + # First torrent exists, second doesn't + mock_client.torrents_info.side_effect = [ + [{"hash": "1234567890abcdef1234567890abcdef12345678"}], # First exists + [] # Second doesn't + ] + mock_client.torrents_add.return_value = None + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + client.add_torrents(sample_magnet_links, tags=["test"]) + + # Should only add the second torrent + assert mock_client.torrents_add.call_count == 1 + # Should add tags to existing torrent + assert mock_client.torrents_add_tags.call_count == 1 + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_add_torrents_invalid_magnet(self, mock_sleep, mock_client_class): + """Test handling invalid magnet links.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + invalid_magnets = ["invalid_magnet_link"] + + client.add_torrents(invalid_magnets) + + # Should not call torrents_add for invalid magnet + mock_client.torrents_add.assert_not_called() + + @patch('clients.qbittorrentapi.Client') + def test_qbittorrent_client_init_api_connection_error(self, mock_client_class): + """Test qBittorrent client initialization with API connection error.""" + import qbittorrentapi + mock_client = MagicMock() + mock_client.auth_log_in.side_effect = qbittorrentapi.APIConnectionError("Connection refused") + mock_client_class.return_value = mock_client + + with pytest.raises(ConnectionError) as exc_info: + QBittorrentClient("localhost", 8080, "user", "pass") + + assert "Failed to connect to qBittorrent" in str(exc_info.value) + assert "localhost:8080" in str(exc_info.value) + assert "Check if the client is running" in str(exc_info.value) + + @patch('clients.qbittorrentapi.Client') + def test_qbittorrent_client_init_api_error(self, mock_client_class): + """Test qBittorrent client initialization with general APIError.""" + import qbittorrentapi + mock_client = MagicMock() + mock_client.auth_log_in.side_effect = qbittorrentapi.APIError("General API error") + mock_client_class.return_value = mock_client + + with pytest.raises(ConnectionError) as exc_info: + QBittorrentClient("localhost", 8080, "user", "pass") + + assert "qBittorrent API error" in str(exc_info.value) + assert "localhost:8080" in str(exc_info.value) + + +class TestTransmissionClient: + """Tests for Transmission client.""" + + @patch('clients.requests.Session') + def test_transmission_client_init_success(self, mock_session_class): + """Test successful Transmission client initialization.""" + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "success"} + mock_session.post.return_value = mock_response + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, "user", "pass") + + assert client.session == mock_session + mock_session.post.assert_called() + + @patch('clients.requests.Session') + def test_transmission_client_init_409_retry(self, mock_session_class): + """Test Transmission client handles 409 status code (session ID).""" + mock_session = MagicMock() + + # First call returns 409 with session ID + mock_response_409 = MagicMock() + mock_response_409.status_code = 409 + # Use CaseInsensitiveDict to match requests.Response.headers behavior + mock_response_409.headers = CaseInsensitiveDict({"X-Transmission-Session-Id": "test_session_id"}) + + # Second call succeeds + mock_response_200 = MagicMock() + mock_response_200.status_code = 200 + mock_response_200.json.return_value = {"result": "success"} + + mock_session.post.side_effect = [mock_response_409, mock_response_200] + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, None, None) + + assert client.session_id == "test_session_id" + + @patch('clients.requests.Session') + @patch('clients.time.sleep') + def test_transmission_add_torrents(self, mock_sleep, mock_session_class, sample_magnet_links): + """Test adding torrents to Transmission.""" + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "success"} + mock_session.post.return_value = mock_response + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, None, None) + client.session_id = "test_session_id" + client.add_torrents(sample_magnet_links, download_folder="/downloads") + + assert mock_session.post.call_count >= len(sample_magnet_links) + + @patch('clients.requests.Session') + @patch('clients.time.sleep') + def test_transmission_add_torrents_handles_409(self, mock_sleep, mock_session_class, sample_magnet_links): + """Test Transmission handles 409 during torrent add.""" + mock_session = MagicMock() + + # Response for __init__ connection test (session-get) + mock_init_response = MagicMock() + mock_init_response.status_code = 200 + mock_init_response.json.return_value = {"result": "success"} + + # First call in add_torrents returns 409, second succeeds + mock_response_409 = MagicMock() + mock_response_409.status_code = 409 + # Use CaseInsensitiveDict to match requests.Response.headers behavior + mock_response_409.headers = CaseInsensitiveDict({"X-Transmission-Session-Id": "new_session_id"}) + + mock_response_200 = MagicMock() + mock_response_200.status_code = 200 + mock_response_200.json.return_value = {"result": "success"} + + # __init__ makes one POST, add_torrents makes two POSTs (409 then retry) + mock_session.post.side_effect = [mock_init_response, mock_response_409, mock_response_200] + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, None, None) + client.session_id = "old_session_id" + client.add_torrents(sample_magnet_links[:1]) # Just one to simplify + + assert client.session_id == "new_session_id" + + @patch('clients.requests.Session') + def test_transmission_client_init_connection_error(self, mock_session_class): + """Test Transmission client initialization with connection error.""" + import requests + mock_session = MagicMock() + mock_session.post.side_effect = requests.ConnectionError("Connection refused") + mock_session_class.return_value = mock_session + + with pytest.raises(ConnectionError) as exc_info: + TransmissionClient("localhost", 9091, "user", "pass") + + assert "Failed to connect to Transmission" in str(exc_info.value) + assert "localhost:9091" in str(exc_info.value) + assert "Check if the client is running" in str(exc_info.value) + + @patch('clients.requests.Session') + def test_transmission_client_init_timeout(self, mock_session_class): + """Test Transmission client initialization with timeout.""" + import requests + mock_session = MagicMock() + mock_session.post.side_effect = requests.Timeout("Request timed out") + mock_session_class.return_value = mock_session + + with pytest.raises(ConnectionError) as exc_info: + TransmissionClient("localhost", 9091, "user", "pass") + + assert "timed out" in str(exc_info.value) + assert "localhost:9091" in str(exc_info.value) + + +class TestClientFactory: + """Tests for client factory function.""" + + def test_get_client_qbittorrent(self): + """Test getting qBittorrent client.""" + with patch('clients.QBittorrentClient') as mock_class: + mock_instance = MagicMock() + mock_class.return_value = mock_instance + client = get_client("qbittorrent", "localhost", 8080, "user", "pass") + assert client == mock_instance + + def test_get_client_transmission(self): + """Test getting Transmission client.""" + with patch('clients.TransmissionClient') as mock_class: + mock_instance = MagicMock() + mock_class.return_value = mock_instance + client = get_client("transmission", "localhost", 9091, "user", "pass") + assert client == mock_instance + + def test_get_client_unknown(self): + """Test getting unknown client raises error.""" + with pytest.raises(ValueError) as exc_info: + get_client("unknown", "localhost", 8080, "user", "pass") + assert "Unknown client" in str(exc_info.value) + + +class TestDryRunMode: + """Tests for dry run mode functionality.""" + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_dry_run(self, mock_sleep, mock_client_class, sample_magnet_links): + """Test qBittorrent dry run mode validates without adding torrents.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + mock_client.torrents_info.return_value = [] # No existing torrents + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + client.add_torrents(sample_magnet_links, download_folder="/downloads", tags=["test"], category="anime", dry_run=True) + + # Should not call torrents_add in dry run mode + mock_client.torrents_add.assert_not_called() + # Should call torrents_info to check existing torrents + assert mock_client.torrents_info.call_count == len(sample_magnet_links) + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_dry_run_with_existing_torrents(self, mock_sleep, mock_client_class, sample_magnet_links): + """Test qBittorrent dry run mode handles existing torrents.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + # First torrent exists, second doesn't + mock_client.torrents_info.side_effect = [ + [{"hash": "1234567890abcdef1234567890abcdef12345678"}], # First exists + [] # Second doesn't + ] + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + client.add_torrents(sample_magnet_links, tags=["test"], dry_run=True) + + # Should not call torrents_add in dry run mode + mock_client.torrents_add.assert_not_called() + # Should not add tags to existing torrents in dry run + mock_client.torrents_add_tags.assert_not_called() + + @patch('clients.qbittorrentapi.Client') + @patch('clients.time.sleep') + def test_qbittorrent_dry_run_invalid_magnet(self, mock_sleep, mock_client_class): + """Test qBittorrent dry run mode handles invalid magnet links.""" + mock_client = MagicMock() + mock_client.auth_log_in.return_value = None + mock_client_class.return_value = mock_client + + client = QBittorrentClient("localhost", 8080, "user", "pass") + invalid_magnets = ["invalid_magnet_link"] + + client.add_torrents(invalid_magnets, dry_run=True) + + # Should not call any API methods for invalid magnets + mock_client.torrents_add.assert_not_called() + mock_client.torrents_info.assert_not_called() + + @patch('clients.requests.Session') + @patch('clients.time.sleep') + def test_transmission_dry_run(self, mock_sleep, mock_session_class, sample_magnet_links): + """Test Transmission dry run mode validates without adding torrents.""" + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "success"} + mock_session.post.return_value = mock_response + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, None, None) + client.session_id = "test_session_id" + client.add_torrents(sample_magnet_links, download_folder="/downloads", dry_run=True) + + # Should not make torrent-add requests in dry run mode + # Only the initial session-get call should be made (in __init__) + # Count calls that are torrent-add (not session-get) + torrent_add_calls = [call for call in mock_session.post.call_args_list + if len(call[1].get('json', {}).get('method', '')) > 0 + and call[1]['json'].get('method') == 'torrent-add'] + assert len(torrent_add_calls) == 0 + + @patch('clients.requests.Session') + @patch('clients.time.sleep') + def test_transmission_dry_run_invalid_magnet(self, mock_sleep, mock_session_class): + """Test Transmission dry run mode handles invalid magnet links.""" + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"result": "success"} + mock_session.post.return_value = mock_response + mock_session_class.return_value = mock_session + + client = TransmissionClient("localhost", 9091, None, None) + client.session_id = "test_session_id" + invalid_magnets = ["invalid_magnet_link"] + + client.add_torrents(invalid_magnets, dry_run=True) + + # Should not make any torrent-add requests for invalid magnets + torrent_add_calls = [call for call in mock_session.post.call_args_list + if len(call[1].get('json', {}).get('method', '')) > 0 + and call[1]['json'].get('method') == 'torrent-add'] + assert len(torrent_add_calls) == 0 diff --git a/tests/test_crc32.py b/tests/test_crc32.py new file mode 100644 index 0000000..f5e105b --- /dev/null +++ b/tests/test_crc32.py @@ -0,0 +1,155 @@ +"""Unit tests for CRC32 operations.""" +import pytest +import zlib +import os +import sys +import tempfile +from unittest.mock import patch, mock_open + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +class TestCRC32Extraction: + """Tests for CRC32 extraction from filenames.""" + + def test_extract_crc32_from_filename(self): + """Test extracting CRC32 from filename with brackets.""" + filename = "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv" + matches = acepace.CRC32_REGEX.findall(filename) + assert len(matches) > 0 + assert matches[-1].upper() == "A1B2C3D4" + + def test_extract_crc32_multiple_matches(self): + """Test extracting CRC32 when multiple matches exist (takes last).""" + filename = "[One Pace] Episode 1 [1080p][A1B2C3D4][E5F6A7B8].mkv" + matches = acepace.CRC32_REGEX.findall(filename) + assert len(matches) == 2 + # Should take the last match + assert matches[-1].upper() == "E5F6A7B8" + + def test_extract_crc32_no_match(self): + """Test extracting CRC32 from filename without CRC32.""" + filename = "[One Pace] Episode 1 [1080p].mkv" + matches = acepace.CRC32_REGEX.findall(filename) + assert len(matches) == 0 + + def test_extract_crc32_lowercase(self): + """Test extracting CRC32 with lowercase hex.""" + filename = "[One Pace] Episode 1 [1080p][a1b2c3d4].mkv" + matches = acepace.CRC32_REGEX.findall(filename) + assert len(matches) > 0 + assert matches[-1].upper() == "A1B2C3D4" + + def test_extract_crc32_invalid_length(self): + """Test that regex doesn't match invalid CRC32 lengths.""" + filename = "[One Pace] Episode 1 [1080p][A1B2C3].mkv" # Too short + matches = acepace.CRC32_REGEX.findall(filename) + assert len(matches) == 0 + + +class TestCRC32Calculation: + """Tests for CRC32 calculation from file content.""" + + def test_calculate_crc32_from_content(self, sample_video_content, temp_dir): + """Test calculating CRC32 from file content.""" + test_file = os.path.join(temp_dir, "test_video.mkv") + with open(test_file, "wb") as f: + f.write(sample_video_content) + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + conn.close() + + # Calculate expected CRC32 + expected_crc = 0 + for chunk in [sample_video_content[i:i+8192] for i in range(0, len(sample_video_content), 8192)]: + expected_crc = zlib.crc32(chunk, expected_crc) + expected_crc32 = f"{expected_crc & 0xFFFFFFFF:08X}" + + assert len(crc32s) == 1 + assert expected_crc32 in crc32s + + def test_calculate_crc32_caches_result(self, sample_video_content, temp_dir): + """Test that CRC32 calculation caches results in database.""" + test_file = os.path.join(temp_dir, "test_video.mkv") + with open(test_file, "wb") as f: + f.write(sample_video_content) + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + + # First calculation + crc32s1 = acepace.calculate_local_crc32(temp_dir, conn) + + # Second calculation should use cache + crc32s2 = acepace.calculate_local_crc32(temp_dir, conn) + + # Verify cache was used (check database with normalized path) + normalized_path = acepace.normalize_file_path(test_file) + cursor = conn.cursor() + cursor.execute("SELECT crc32 FROM crc32_cache WHERE file_path = ?", (normalized_path,)) + row = cursor.fetchone() + assert row is not None + + assert crc32s1 == crc32s2 + conn.close() + + def test_calculate_crc32_only_video_files(self, temp_dir): + """Test that only video files are processed.""" + # Create video file + video_file = os.path.join(temp_dir, "test.mkv") + with open(video_file, "wb") as f: + f.write(b"video content") + + # Create non-video file + text_file = os.path.join(temp_dir, "test.txt") + with open(text_file, "w") as f: + f.write("text content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + conn.close() + + # Should only have one CRC32 (for the video file) + assert len(crc32s) == 1 + + def test_calculate_crc32_multiple_video_formats(self, temp_dir): + """Test that multiple video formats are supported.""" + files = [ + ("test1.mkv", b"mkv content"), + ("test2.mp4", b"mp4 content"), + ("test3.avi", b"avi content"), + ] + + for filename, content in files: + filepath = os.path.join(temp_dir, filename) + with open(filepath, "wb") as f: + f.write(content) + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + conn.close() + + assert len(crc32s) == 3 + + def test_calculate_crc32_subdirectories(self, temp_dir): + """Test that CRC32 calculation works in subdirectories.""" + subdir = os.path.join(temp_dir, "subdir") + os.makedirs(subdir) + + video_file = os.path.join(subdir, "test.mkv") + with open(video_file, "wb") as f: + f.write(b"video content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + conn.close() + + assert len(crc32s) == 1 diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..e8b229d --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,200 @@ +"""Unit tests for database operations.""" +import pytest +import sqlite3 +import os +import sys +from unittest.mock import patch, MagicMock + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +class TestDatabaseInitialization: + """Tests for database initialization functions.""" + + @patch('acepace.DB_NAME', 'test_crc32_files.db') + def test_init_db_creates_tables(self, temp_dir, monkeypatch): + """Test that init_db creates required tables.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test_crc32_files.db')): + conn = acepace.init_db() + cursor = conn.cursor() + + # Check crc32_cache table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='crc32_cache'") + assert cursor.fetchone() is not None + + # Check metadata table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'") + assert cursor.fetchone() is not None + + conn.close() + os.remove(os.path.join(temp_dir, 'test_crc32_files.db')) + + @patch('acepace.EPISODES_DB_NAME', 'test_episodes_index.db') + def test_init_episodes_db_creates_tables(self, temp_dir, monkeypatch): + """Test that init_episodes_db creates required tables.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test_episodes_index.db')): + conn = acepace.init_episodes_db() + cursor = conn.cursor() + + # Check episodes_index table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='episodes_index'") + assert cursor.fetchone() is not None + + # Check metadata table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='metadata'") + assert cursor.fetchone() is not None + + conn.close() + os.remove(os.path.join(temp_dir, 'test_episodes_index.db')) + + def test_init_db_suppresses_messages_when_requested(self, temp_dir): + """Test that init_db suppresses messages when suppress_messages=True.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + # First call creates the database (should show message if suppress_messages=False) + conn1 = acepace.init_db(suppress_messages=False) + conn1.close() + + # Second call with suppress_messages=True should not print message + with patch('builtins.print') as mock_print: + conn2 = acepace.init_db(suppress_messages=True) + conn2.close() + + # Verify "Database already exists" message was NOT printed + print_calls = [str(c) for c in mock_print.call_args_list] + assert not any("Database already exists" in str(call) for call in print_calls) + + def test_init_db_shows_messages_when_not_suppressed(self, temp_dir): + """Test that init_db shows messages when suppress_messages=False (default).""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + # First call creates the database + conn1 = acepace.init_db() + conn1.close() + + # Second call should show message (default behavior) + with patch('builtins.print') as mock_print: + conn2 = acepace.init_db(suppress_messages=False) + conn2.close() + + # Verify "Database already exists" message WAS printed + print_calls = [str(c) for c in mock_print.call_args_list] + assert any("Database already exists" in str(call) for call in print_calls) + + +class TestMetadataOperations: + """Tests for metadata get/set operations.""" + + def test_get_metadata_nonexistent_key(self, temp_dir): + """Test getting metadata for non-existent key returns None.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + result = acepace.get_metadata(conn, "nonexistent_key") + assert result is None + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + def test_set_and_get_metadata(self, temp_dir): + """Test setting and getting metadata.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + acepace.set_metadata(conn, "test_key", "test_value") + result = acepace.get_metadata(conn, "test_key") + assert result == "test_value" + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + def test_set_metadata_overwrites_existing(self, temp_dir): + """Test that set_metadata overwrites existing values.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + acepace.set_metadata(conn, "test_key", "old_value") + acepace.set_metadata(conn, "test_key", "new_value") + result = acepace.get_metadata(conn, "test_key") + assert result == "new_value" + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + def test_get_episodes_metadata_nonexistent_key(self, temp_dir): + """Test getting episodes metadata for non-existent key returns None.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_episodes_db() + result = acepace.get_episodes_metadata(conn, "nonexistent_key") + assert result is None + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + def test_set_and_get_episodes_metadata(self, temp_dir): + """Test setting and getting episodes metadata.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_episodes_db() + acepace.set_episodes_metadata(conn, "test_key", "test_value") + result = acepace.get_episodes_metadata(conn, "test_key") + assert result == "test_value" + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + +class TestEpisodesIndexOperations: + """Tests for episodes index database operations.""" + + def test_load_crc32_to_title_from_index(self, temp_dir, sample_episode_data): + """Test loading CRC32 to title mapping from episodes index.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_episodes_db() + cursor = conn.cursor() + + # Insert sample data (handle both 3-item and 4-item formats for backward compatibility) + for episode_data in sample_episode_data: + if len(episode_data) == 3: + crc32, title, page_link = episode_data + magnet_link = "" + else: + crc32, title, page_link, magnet_link = episode_data + cursor.execute( + "INSERT INTO episodes_index (crc32, title, page_link, magnet_link) VALUES (?, ?, ?, ?)", + (crc32, title, page_link, magnet_link) + ) + conn.commit() + conn.close() + + # Load and verify + mapping = acepace.load_crc32_to_title_from_index() + assert len(mapping) == 3 + assert mapping["A1B2C3D4"] == "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv" + assert mapping["E5F6A7B8"] == "[One Pace] Episode 2 [1080p][E5F6A7B8].mkv" + assert mapping["A9B0C1D2"] == "[One Pace] Episode 3 [1080p][A9B0C1D2].mkv" + + os.remove(os.path.join(temp_dir, 'test.db')) + + def test_load_1080p_episodes_from_index_includes_magnet_links(self, temp_dir, sample_episode_data): + """Test loading 1080p episodes from index includes magnet links.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_episodes_db() + cursor = conn.cursor() + + # Insert sample data with magnet links + for episode_data in sample_episode_data: + if len(episode_data) == 3: + crc32, title, page_link = episode_data + magnet_link = "" + else: + crc32, title, page_link, magnet_link = episode_data + cursor.execute( + "INSERT INTO episodes_index (crc32, title, page_link, magnet_link) VALUES (?, ?, ?, ?)", + (crc32, title, page_link, magnet_link) + ) + conn.commit() + conn.close() + + # Load and verify + crc32_to_link, crc32_to_text, crc32_to_magnet = acepace.load_1080p_episodes_from_index() + assert len(crc32_to_link) == 3 + assert len(crc32_to_text) == 3 + assert len(crc32_to_magnet) == 3 + assert crc32_to_link["A1B2C3D4"] == "https://nyaa.si/view/12345" + assert crc32_to_magnet["A1B2C3D4"] == "magnet:?xt=urn:btih:abc123" + assert crc32_to_magnet["E5F6A7B8"] == "magnet:?xt=urn:btih:def456" + + os.remove(os.path.join(temp_dir, 'test.db')) diff --git a/tests/test_debug.py b/tests/test_debug.py new file mode 100644 index 0000000..3917859 --- /dev/null +++ b/tests/test_debug.py @@ -0,0 +1,79 @@ +"""Unit tests for DEBUG mode functionality.""" +import pytest +import os +import sys +from unittest.mock import patch, MagicMock + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestDebugMode: + """Tests for DEBUG mode functionality.""" + + def test_debug_print_outputs_when_enabled(self, capsys): + """Test that debug_print outputs when DEBUG mode is enabled.""" + with patch('acepace.DEBUG_MODE', True): + import acepace + acepace.debug_print("Test debug message") + captured = capsys.readouterr() + assert "Test debug message" in captured.out + + def test_debug_print_no_output_when_disabled(self, capsys): + """Test that debug_print does not output when DEBUG mode is disabled.""" + with patch('acepace.DEBUG_MODE', False): + import acepace + acepace.debug_print("Test debug message") + captured = capsys.readouterr() + assert "Test debug message" not in captured.out + + def test_debug_mode_parsing_true(self): + """Test that DEBUG mode parsing works with 'true'.""" + with patch.dict(os.environ, {'DEBUG': 'true'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is True + + def test_debug_mode_parsing_1(self): + """Test that DEBUG mode parsing works with '1'.""" + with patch.dict(os.environ, {'DEBUG': '1'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is True + + def test_debug_mode_parsing_yes(self): + """Test that DEBUG mode parsing works with 'yes'.""" + with patch.dict(os.environ, {'DEBUG': 'yes'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is True + + def test_debug_mode_parsing_on(self): + """Test that DEBUG mode parsing works with 'on'.""" + with patch.dict(os.environ, {'DEBUG': 'on'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is True + + def test_debug_mode_parsing_case_insensitive(self): + """Test that DEBUG mode parsing is case-insensitive.""" + with patch.dict(os.environ, {'DEBUG': 'TRUE'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is True + + def test_debug_mode_parsing_false(self): + """Test that DEBUG mode parsing works with 'false'.""" + with patch.dict(os.environ, {'DEBUG': 'false'}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is False + + def test_debug_mode_parsing_empty(self): + """Test that DEBUG mode parsing works with empty string.""" + with patch.dict(os.environ, {'DEBUG': ''}, clear=False): + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is False + + def test_debug_mode_parsing_not_set(self): + """Test that DEBUG mode parsing works when not set.""" + with patch.dict(os.environ, {}, clear=True): + # Remove DEBUG if it exists + if 'DEBUG' in os.environ: + del os.environ['DEBUG'] + debug_value = os.getenv("DEBUG", "").lower() in ("true", "1", "yes", "on") + assert debug_value is False diff --git a/tests/test_episodes.py b/tests/test_episodes.py new file mode 100644 index 0000000..63ca9d3 --- /dev/null +++ b/tests/test_episodes.py @@ -0,0 +1,746 @@ +"""Unit tests for episode metadata fetching.""" +import pytest +import os +import sys +from unittest.mock import patch, MagicMock, Mock +from bs4 import BeautifulSoup + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +def _create_nyaa_html_page(episode_titles): + """Helper function to create Nyaa HTML page with given episode titles. + Args: + episode_titles: List of episode title strings + Returns: HTML string""" + rows = "\n".join([ + f' \n \n {title}\n \n ' + for i, title in enumerate(episode_titles) + ]) + return f""" + + + +{rows} +
+
    +
  • 1
  • +
+ + + """ + + +def _create_mock_response(html_content): + """Helper function to create a mock HTTP response. + Args: + html_content: HTML content string + Returns: MagicMock response object""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html_content + return mock_response + + +class TestEpisodeMetadataFetching: + """Tests for fetching episode metadata from Nyaa.""" + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_single_page(self, mock_get, mock_nyaa_html_single_page): + """Test fetching episodes from a single page.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = mock_nyaa_html_single_page + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + assert len(episodes) == 2 + assert any(ep[0] == "A1B2C3D4" for ep in episodes) + assert any(ep[0] == "E5F6A7B8" for ep in episodes) + + # Verify default URL was used + assert mock_get.call_count > 0 + # Check that the default URL (without 1080p) was used + call_urls = [call[0][0] for call in mock_get.call_args_list] + assert any("q=one+pace" in url and "1080p" not in url for url in call_urls) + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_uses_custom_url(self, mock_get, mock_nyaa_html_single_page): + """Test that fetch_episodes_metadata uses the provided URL parameter.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = mock_nyaa_html_single_page + mock_get.return_value = mock_response + + custom_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + episodes = acepace.fetch_episodes_metadata(custom_url) + + assert len(episodes) == 2 + + # Verify the custom URL was used in requests + assert mock_get.call_count > 0 + call_urls = [call[0][0] for call in mock_get.call_args_list] + # All URLs should start with the custom URL (with page parameter) + for url in call_urls: + assert url.startswith(custom_url + "&p=") or url == custom_url + "&p=1" + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_default_url_when_none_provided(self, mock_get, mock_nyaa_html_single_page): + """Test that fetch_episodes_metadata uses default URL when None is provided.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = mock_nyaa_html_single_page + mock_get.return_value = mock_response + + # Call with None explicitly + episodes = acepace.fetch_episodes_metadata(None) + + assert len(episodes) == 2 + + # Verify default URL was used + assert mock_get.call_count > 0 + call_urls = [call[0][0] for call in mock_get.call_args_list] + # Should use default URL without 1080p + assert any("q=one+pace" in url and "1080p" not in url for url in call_urls) + + @patch('acepace.requests.get') + @patch('acepace.time.sleep') # Mock sleep to speed up tests + def test_fetch_episodes_metadata_multi_page(self, mock_sleep, mock_get, mock_nyaa_html_multi_page): + """Test fetching episodes from multiple pages.""" + # First page response + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = mock_nyaa_html_multi_page + + # Second page response + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = """ + + + + + + +
+ [One Pace] Episode 3 [1080p][A9B0C1D2].mkv +
+ + + """ + + # Third page response (empty) + mock_response3 = MagicMock() + mock_response3.status_code = 200 + mock_response3.text = "
" + + mock_get.side_effect = [mock_response1, mock_response2, mock_response3] + + episodes = acepace.fetch_episodes_metadata() + + # Should have episodes from both pages + assert len(episodes) >= 2 + assert mock_get.call_count >= 2 + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_crc32_in_title(self, mock_get): + """Test extracting CRC32 from title directly.""" + html = """ + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+
    +
  • 1
  • +
+ + + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + assert len(episodes) == 1 + assert episodes[0][0] == "A1B2C3D4" + assert "[One Pace]" in episodes[0][1] + # Verify magnet link is included (4th element) + assert len(episodes[0]) == 4 + assert episodes[0][3] == "" # No magnet link in this test HTML + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_extracts_magnet_links(self, mock_get): + """Test that magnet links are extracted from search rows.""" + html = """ + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+
    +
  • 1
  • +
+ + + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + assert len(episodes) == 1 + crc32, _, _, magnet_link = episodes[0] + assert crc32 == "A1B2C3D4" + assert magnet_link == "magnet:?xt=urn:btih:test123456789" + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_crc32_from_file_list(self, mock_get, mock_nyaa_torrent_page): + """Test extracting CRC32 from torrent page file list.""" + # Listing page without CRC32 in title + listing_html = """ + + + + + + +
+ [One Pace] Episode 1 +
+
    +
  • 1
  • +
+ + + """ + + # Torrent page with file list + mock_listing_response = MagicMock() + mock_listing_response.status_code = 200 + mock_listing_response.text = listing_html + + mock_torrent_response = MagicMock() + mock_torrent_response.status_code = 200 + mock_torrent_response.text = mock_nyaa_torrent_page + + mock_get.side_effect = [mock_listing_response, mock_torrent_response] + + episodes = acepace.fetch_episodes_metadata() + + assert len(episodes) == 1 + assert episodes[0][0] == "A1B2C3D4" + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_handles_http_error(self, mock_get): + """Test that HTTP errors are handled gracefully.""" + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + assert len(episodes) == 0 + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_deduplicates_crc32(self, mock_get): + """Test that duplicate CRC32s are not added multiple times.""" + html = """ + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+ [One Pace] Episode 1 Alt [1080p][A1B2C3D4].mkv +
+
    +
  • 1
  • +
+ + + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + # Should only have one entry despite duplicate CRC32 + crc32s = [ep[0] for ep in episodes] + assert crc32s.count("A1B2C3D4") == 1 + + +class TestUpdateEpisodesIndex: + """Tests for updating episodes index database.""" + + @patch('acepace.fetch_episodes_metadata') + @patch('acepace.EPISODES_DB_NAME', 'test_episodes_index.db') + def test_update_episodes_index_db(self, mock_fetch, temp_dir, sample_episode_data): + """Test updating episodes index database.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + mock_fetch.return_value = sample_episode_data + + acepace.update_episodes_index_db() + + # Verify data was inserted + conn = acepace.init_episodes_db() + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM episodes_index") + count = cursor.fetchone()[0] + assert count == 3 + + # Verify metadata was updated + last_update = acepace.get_episodes_metadata(conn, "episodes_db_last_update") + assert last_update is not None + + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + @patch('acepace.fetch_episodes_metadata') + @patch('acepace.EPISODES_DB_NAME', 'test_episodes_index.db') + def test_update_episodes_index_db_uses_url_parameter(self, mock_fetch, temp_dir, sample_episode_data): + """Test that update_episodes_index_db passes URL parameter to fetch_episodes_metadata.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + mock_fetch.return_value = sample_episode_data + + test_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + acepace.update_episodes_index_db(test_url) + + # Verify fetch_episodes_metadata was called with the URL + mock_fetch.assert_called_once_with(test_url) + + # Clean up + if os.path.exists(os.path.join(temp_dir, 'test.db')): + os.remove(os.path.join(temp_dir, 'test.db')) + + +class TestEpisodeQualityFiltering: + """Tests for ensuring only 1080p episodes are extracted.""" + + @patch('acepace.requests.get') + def test_fetch_episodes_prefers_1080p(self, mock_get): + """Test that 1080p episodes are extracted when available.""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", + "[One Pace] Episode 2 [1080p][E5F6A7B8].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # All episodes should be 1080p + assert len(episodes) == 2 + for crc32, title, _, _ in episodes: + assert "[1080p]" in title.upper() or "1080P" in title.upper() + assert "[One Pace]" in title + + @patch('acepace.requests.get') + def test_fetch_episodes_rejects_720p(self, mock_get): + """Test that 720p episodes are rejected (only 1080p accepted).""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [720p][A1B2C3D4].mkv", + "[One Pace] Episode 2 [720p][E5F6A7B8].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # All 720p episodes should be rejected + assert len(episodes) == 0 + + @patch('acepace.requests.get') + def test_fetch_episodes_excludes_lower_quality(self, mock_get): + """Test that episodes with quality other than 1080p are excluded.""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [480p][A1B2C3D4].mkv", + "[One Pace] Episode 2 [360p][E5F6A7B8].mkv", + "[One Pace] Episode 3 [240p][A9B0C1D2].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # Lower quality episodes should be excluded + assert len(episodes) == 0 + + @patch('acepace.requests.get') + def test_fetch_episodes_only_accepts_1080p_same_episode(self, mock_get): + """Test that when both 1080p and 720p versions exist for same episode, only 1080p is accepted.""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [720p][A1B2C3D4].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # Should only have one entry (1080p version, 720p is rejected) + assert len(episodes) == 1 + crc32, title, _, _ = episodes[0] + assert crc32 == "A1B2C3D4" + # Only 1080p should be kept + assert "[1080p]" in title.upper() or "1080P" in title.upper() + assert "[720p]" not in title.upper() and "720P" not in title.upper() + + @patch('acepace.requests.get') + def test_fetch_episodes_mixed_qualities_only_keeps_1080p(self, mock_get): + """Test that mixed quality episodes only keeps 1080p.""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", + "[One Pace] Episode 2 [720p][E5F6A7B8].mkv", + "[One Pace] Episode 3 [480p][A9B0C1D2].mkv", + "[One Pace] Episode 4 [1080p][A3B4C5D6].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # Should only have 1080p episodes (2 total, excluding 720p and 480p) + assert len(episodes) == 2 + for crc32, title, _, _ in episodes: + title_upper = title.upper() + has_1080p = "[1080P]" in title_upper or "1080P" in title_upper + assert has_1080p, f"Episode {title} should be 1080p" + # Verify no other qualities + assert "[720P]" not in title_upper + assert "[480P]" not in title_upper + assert "[360P]" not in title_upper + assert "[240P]" not in title_upper + + @patch('acepace.requests.get') + def test_fetch_episodes_handles_case_insensitive_quality(self, mock_get): + """Test that quality detection is case-insensitive (1080p only).""" + html = _create_nyaa_html_page([ + "[One Pace] Episode 1 [1080P][A1B2C3D4].mkv", + "[One Pace] Episode 2 [720P][E5F6A7B8].mkv" + ]) + mock_get.return_value = _create_mock_response(html) + + episodes = acepace.fetch_episodes_metadata() + + # Should only accept 1080P (case-insensitive), reject 720P + assert len(episodes) == 1 + crc32, title, _, _ = episodes[0] + assert crc32 == "A1B2C3D4" + # Verify case-insensitive quality detection works for 1080p + title_upper = title.upper() + assert "[1080P]" in title_upper + # Verify 720p is rejected + assert "[720P]" not in title_upper + + @patch('acepace.requests.get') + def test_fetch_episodes_excludes_episodes_without_quality_marker(self, mock_get): + """Test that episodes without quality markers are excluded.""" + html = """ + + + + + + + + + +
+ [One Pace] Episode 1 [A1B2C3D4].mkv +
+ [One Pace] Episode 2 [1080p][E5F6A7B8].mkv +
+
    +
  • 1
  • +
+ + + """ + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + episodes = acepace.fetch_episodes_metadata() + + # Should only include episode with quality marker + assert len(episodes) == 1 + crc32, title, _, _ = episodes[0] + assert crc32 == "E5F6A7B8" + assert "[1080p]" in title.upper() or "1080P" in title.upper() + + @patch('acepace.requests.get') + @patch('acepace.time.sleep') + def test_fetch_episodes_quality_filtering_from_file_list(self, mock_sleep, mock_get): + """Test quality filtering when CRC32 is extracted from torrent file list.""" + # Listing page + listing_html = """ + + + + + + +
+ [One Pace] Episode 1 +
+
    +
  • 1
  • +
+ + + """ + + # Torrent page with 1080p file + torrent_html_1080p = """ + + +
+
    +
  • [One Pace] Episode 1 [1080p][A1B2C3D4].mkv
  • +
+
+ + + """ + + mock_listing_response = MagicMock() + mock_listing_response.status_code = 200 + mock_listing_response.text = listing_html + + mock_torrent_response = MagicMock() + mock_torrent_response.status_code = 200 + mock_torrent_response.text = torrent_html_1080p + + mock_get.side_effect = [mock_listing_response, mock_torrent_response] + + episodes = acepace.fetch_episodes_metadata() + + # Should extract 1080p episode from file list + assert len(episodes) == 1 + crc32, title, _, _ = episodes[0] + assert crc32 == "A1B2C3D4" + assert "[1080p]" in title.upper() or "1080P" in title.upper() + + @patch('acepace.requests.get') + @patch('acepace.time.sleep') + def test_fetch_episodes_quality_filtering_from_file_list_excludes_lower_quality(self, mock_sleep, mock_get): + """Test that lower quality episodes are excluded when extracted from file list.""" + # Listing page + listing_html = """ + + + + + + +
+ [One Pace] Episode 1 +
+
    +
  • 1
  • +
+ + + """ + + # Torrent page with 480p file (should be excluded) + torrent_html_480p = """ + + +
+
    +
  • [One Pace] Episode 1 [480p][A1B2C3D4].mkv
  • +
+
+ + + """ + + mock_listing_response = MagicMock() + mock_listing_response.status_code = 200 + mock_listing_response.text = listing_html + + mock_torrent_response = MagicMock() + mock_torrent_response.status_code = 200 + mock_torrent_response.text = torrent_html_480p + + mock_get.side_effect = [mock_listing_response, mock_torrent_response] + + episodes = acepace.fetch_episodes_metadata() + + # Should exclude 480p episode + assert len(episodes) == 0 + + +class TestQualityFilteringHelper: + """Tests for the quality filtering helper function.""" + + def test_quality_filtering_accepts_1080p(self): + """Test that 1080p quality is accepted.""" + from acepace import _is_valid_quality + + test_cases = [ + "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [1080P][A1B2C3D4].mkv", + "[One Pace] Episode 1 [1080P][A1B2C3D4].mkv", + ] + + for test_case in test_cases: + assert _is_valid_quality(test_case) is True + + def test_quality_filtering_rejects_720p(self): + """Test that 720p quality is rejected (only 1080p accepted).""" + from acepace import _is_valid_quality + + test_cases = [ + "[One Pace] Episode 1 [720p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [720P][A1B2C3D4].mkv", + ] + + for test_case in test_cases: + assert _is_valid_quality(test_case) is False + + def test_quality_filtering_rejects_non_1080p(self): + """Test that qualities other than 1080p are rejected.""" + from acepace import _is_valid_quality + + test_cases = [ + "[One Pace] Episode 1 [720p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [480p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [360p][A1B2C3D4].mkv", + "[One Pace] Episode 1 [240p][A1B2C3D4].mkv", + ] + + for test_case in test_cases: + assert _is_valid_quality(test_case) is False + + def test_quality_filtering_rejects_higher_quality(self): + """Test that qualities higher than 1080p are rejected (4K, etc.).""" + from acepace import _is_valid_quality + + test_cases = [ + "[One Pace] Episode 1 [2160p][A1B2C3D4].mkv", # 4K + "[One Pace] Episode 1 [1440p][A1B2C3D4].mkv", # 1440p + ] + + for test_case in test_cases: + assert _is_valid_quality(test_case) is False + + +class TestURLParameterConsistency: + """Tests to ensure URL parameter is used consistently across functions.""" + + @patch('acepace.requests.get') + def test_fetch_episodes_metadata_and_fetch_crc32_links_use_same_url(self, mock_get): + """Test that both fetch_episodes_metadata and fetch_crc32_links use the same URL when provided.""" + html_with_results = """ + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+
    +
  • 1
  • +
+ + + """ + + html_empty = """ + + + +
+ + + """ + + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = html_with_results + + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = html_empty + + mock_get.side_effect = [mock_response1, mock_response2] + + test_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + + # Test fetch_episodes_metadata + acepace.fetch_episodes_metadata(test_url) + + # Reset mock for second test + mock_get.reset_mock() + mock_get.side_effect = [mock_response1, mock_response2] + + # Test fetch_crc32_links + _, _, _, _ = acepace.fetch_crc32_links(test_url) + + # Both should use the same URL + assert mock_get.call_count > 0 + + # Verify URLs used in both calls + episodes_urls = [call[0][0] for call in mock_get.call_args_list] + + # Both should have used URLs starting with test_url + for url in episodes_urls: + assert url.startswith(test_url + "&p=") or url == test_url + "&p=1" + + @patch('acepace.fetch_episodes_metadata') + def test_update_episodes_index_db_passes_url_to_fetch_episodes_metadata(self, mock_fetch, temp_dir): + """Test that update_episodes_index_db correctly passes URL to fetch_episodes_metadata.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + mock_fetch.return_value = [] + + test_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + acepace.update_episodes_index_db(test_url) + + # Verify fetch_episodes_metadata was called with the URL + mock_fetch.assert_called_once_with(test_url) + + conn = acepace.init_episodes_db() + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) + + @patch('acepace.fetch_episodes_metadata') + def test_update_episodes_index_db_default_url_when_none_provided(self, mock_fetch, temp_dir): + """Test that update_episodes_index_db uses default URL when None is provided.""" + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'test.db')): + mock_fetch.return_value = [] + + acepace.update_episodes_index_db() + + # Verify fetch_episodes_metadata was called with None (which triggers default) + mock_fetch.assert_called_once_with(None) + + conn = acepace.init_episodes_db() + conn.close() + os.remove(os.path.join(temp_dir, 'test.db')) diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py new file mode 100644 index 0000000..dbf33eb --- /dev/null +++ b/tests/test_file_operations.py @@ -0,0 +1,238 @@ +"""Unit tests for file operations and renaming.""" +import pytest +import os +import sys +import shutil +import re +from unittest.mock import patch, MagicMock, mock_open + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +class TestFileRenaming: + """Tests for file renaming functionality.""" + + def test_rename_local_files_matches_by_crc32(self, temp_dir, sample_episode_data): + """Test that files are renamed based on CRC32 matching.""" + # Create test video file + test_file = os.path.join(temp_dir, "old_name.mkv") + with open(test_file, "wb") as f: + f.write(b"test video content") + + # Calculate CRC32 for the file + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + actual_crc32 = list(crc32s)[0] + + # Update episodes index with matching CRC32 + with patch('acepace.EPISODES_DB_NAME', os.path.join(temp_dir, 'episodes.db')): + episodes_conn = acepace.init_episodes_db() + cursor = episodes_conn.cursor() + cursor.execute( + "INSERT INTO episodes_index (crc32, title, page_link, magnet_link) VALUES (?, ?, ?, ?)", + (actual_crc32, "[One Pace] Episode 1 [1080p].mkv", "https://nyaa.si/view/12345", "") + ) + episodes_conn.commit() + episodes_conn.close() + + # Mock load_crc32_to_title_from_index to return our mapping + with patch('acepace.load_crc32_to_title_from_index') as mock_load: + mock_load.return_value = {actual_crc32: "[One Pace] Episode 1 [1080p].mkv"} + + # Note: rename_local_files requires user input, so we'll test the logic separately + # by checking the rename plan generation + cursor = conn.cursor() + cursor.execute("SELECT file_path, crc32 FROM crc32_cache") + entries = cursor.fetchall() + + crc32_to_title = mock_load.return_value + rename_plan = [] + for file_path, crc32 in entries: + title = crc32_to_title.get(crc32) + if title: + dir_name = os.path.dirname(file_path) + sanitized_title = re.sub(r'[\\/*?:"<>|]', "", title).strip() + new_filename = f"{sanitized_title}" + new_path = os.path.join(dir_name, new_filename) + if os.path.abspath(file_path) != os.path.abspath(new_path): + rename_plan.append((file_path, new_path)) + + assert len(rename_plan) > 0 + assert rename_plan[0][1].endswith("[One Pace] Episode 1 [1080p].mkv") + + conn.close() + + def test_rename_sanitizes_filename(self): + """Test that filenames are sanitized to remove problematic characters.""" + title = "[One Pace] Episode 1: Test <1080p> | Special.mkv" + sanitized = re.sub(r'[\\/*?:"<>|]', "", title).strip() + assert ":" not in sanitized + assert "<" not in sanitized + assert ">" not in sanitized + assert "|" not in sanitized + + def test_rename_skips_files_without_match(self, temp_dir): + """Test that files without CRC32 match are skipped.""" + # Create test video file + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + acepace.calculate_local_crc32(temp_dir, conn) + + # Mock load_crc32_to_title_from_index to return empty/no match + with patch('acepace.load_crc32_to_title_from_index') as mock_load: + mock_load.return_value = {} # No matches + + cursor = conn.cursor() + cursor.execute("SELECT file_path, crc32 FROM crc32_cache") + entries = cursor.fetchall() + + crc32_to_title = mock_load.return_value + rename_plan = [] + for file_path, crc32 in entries: + title = crc32_to_title.get(crc32) + if title: + # This should not execute + rename_plan.append((file_path, "new_path")) + + assert len(rename_plan) == 0 + + conn.close() + + def test_rename_uses_normalized_paths(self, temp_dir): + """Test that rename operations use normalized paths in database.""" + test_file = os.path.join(temp_dir, "old_name.mkv") + with open(test_file, "wb") as f: + f.write(b"test video content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + + # Calculate CRC32 (stores normalized path) + acepace.calculate_local_crc32(temp_dir, conn) + + # Get the stored path from database + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM crc32_cache") + stored_path = cursor.fetchone()[0] + + # Verify it's normalized + assert os.path.isabs(stored_path) + assert stored_path == acepace.normalize_file_path(test_file) + + # Mock rename scenario + with patch('acepace.load_crc32_to_title_from_index') as mock_load: + crc32s = acepace.calculate_local_crc32(temp_dir, conn) + actual_crc32 = list(crc32s)[0] + mock_load.return_value = {actual_crc32: "[One Pace] Episode 1 [1080p].mkv"} + + # Simulate rename + new_path = os.path.join(temp_dir, "[One Pace] Episode 1 [1080p].mkv") + normalized_old = acepace.normalize_file_path(test_file) + normalized_new = acepace.normalize_file_path(new_path) + + # Check that paths would be normalized in database update + assert normalized_old == stored_path # Should match what's in DB + assert os.path.isabs(normalized_new) + + conn.close() + + def test_rename_dry_run_does_not_confirm_or_execute(self, temp_dir): + """Test that rename_local_files(conn, dry_run=True) does not ask for confirmation or rename files.""" + test_file = os.path.join(temp_dir, "old_name.mkv") + with open(test_file, "wb") as f: + f.write(b"test video content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + acepace.calculate_local_crc32(temp_dir, conn) + actual_crc32 = list(acepace.calculate_local_crc32(temp_dir, conn))[0] + + with patch('acepace.load_crc32_to_title_from_index') as mock_load: + mock_load.return_value = {actual_crc32: "[One Pace] Episode 1 [1080p].mkv"} + with patch('acepace._get_rename_confirmation') as mock_confirm: + with patch('acepace._execute_rename') as mock_execute: + acepace.rename_local_files(conn, dry_run=True) + mock_confirm.assert_not_called() + mock_execute.assert_not_called() + conn.close() + + def test_ensure_crc32_cache_complete_runs_calculation_when_missing(self, temp_dir): + """When cache is missing CRC32s for some files, calculate_local_crc32 is called.""" + conn = MagicMock() + with patch('acepace._count_video_files') as mock_count: + with patch('acepace.calculate_local_crc32') as mock_calc: + mock_count.return_value = (3, 1) + acepace._ensure_crc32_cache_complete(temp_dir, conn) + mock_calc.assert_called_once_with(temp_dir, conn) + + def test_ensure_crc32_cache_complete_skips_when_up_to_date(self, temp_dir): + """When all files are in cache, calculate_local_crc32 is not called.""" + conn = MagicMock() + with patch('acepace._count_video_files') as mock_count: + with patch('acepace.calculate_local_crc32') as mock_calc: + mock_count.return_value = (2, 2) + acepace._ensure_crc32_cache_complete(temp_dir, conn) + mock_calc.assert_not_called() + + def test_ensure_crc32_cache_complete_skips_when_no_files(self, temp_dir): + """When folder has no video files, calculate_local_crc32 is not called.""" + conn = MagicMock() + with patch('acepace._count_video_files') as mock_count: + with patch('acepace.calculate_local_crc32') as mock_calc: + mock_count.return_value = (0, 0) + acepace._ensure_crc32_cache_complete(temp_dir, conn) + mock_calc.assert_not_called() + + +class TestCSVExport: + """Tests for CSV export functionality.""" + + def test_export_db_to_csv(self, temp_dir): + """Test exporting database to CSV.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + cursor = conn.cursor() + + # Add test data + cursor.execute( + "INSERT INTO crc32_cache (file_path, crc32) VALUES (?, ?)", + ("/path/to/file1.mkv", "A1B2C3D4") + ) + cursor.execute( + "INSERT INTO crc32_cache (file_path, crc32) VALUES (?, ?)", + ("/path/to/file2.mkv", "E5F6A7B8") + ) + conn.commit() + + # Export to CSV + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + acepace.export_db_to_csv(conn) + + # Check if CSV was created (it should be in current directory, but we'll check the logic) + # The actual file would be created in the working directory + conn.close() + + def test_export_db_to_csv_updates_metadata(self, temp_dir): + """Test that export updates last_db_export metadata.""" + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO crc32_cache (file_path, crc32) VALUES (?, ?)", + ("/path/to/file.mkv", "A1B2C3D4") + ) + conn.commit() + + acepace.export_db_to_csv(conn) + + last_export = acepace.get_metadata(conn, "last_db_export") + assert last_export is not None + conn.close() diff --git a/tests/test_main_command.py b/tests/test_main_command.py new file mode 100644 index 0000000..a2a141a --- /dev/null +++ b/tests/test_main_command.py @@ -0,0 +1,557 @@ +"""Unit tests for main command execution and Docker mode behavior.""" +import pytest +import os +import sys +from unittest.mock import patch, MagicMock, call + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + +# Test constants +TEST_HOST_IP = "localhost" # Test host for testing environment variable handling + + +class TestReleaseDateHeader: + """Tests for release date in header (from acepace.py mtime).""" + + @patch('acepace._get_release_date', return_value='2025-02-04') + def test_print_header_includes_release_date(self, mock_release_date): + """Header shows Release line when _get_release_date returns a date.""" + with patch('acepace.IS_DOCKER', False), patch('builtins.print') as mock_print: + acepace._print_header() + printed = " ".join(str(c) for c in mock_print.call_args_list) + assert "Release" in printed + assert "2025-02-04" in printed + + def test_get_release_date_returns_string_from_mtime(self): + """_get_release_date returns YYYY-MM-DD from acepace.py mtime.""" + result = acepace._get_release_date() + assert isinstance(result, str) + if result: + assert len(result) == 10 + assert result[4] == "-" and result[7] == "-" + + +class TestDockerModeBehavior: + """Tests for Docker mode specific behavior.""" + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_main_commands') + def test_docker_mode_message_not_shown_for_db_command(self, mock_handle, mock_folder, mock_init_db, + mock_show_status, mock_validate): + """Test that Docker mode message is not shown for --db command.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + # Create mock args with --db + mock_args = MagicMock() + mock_args.help = False + mock_args.db = True + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = False + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + mock_args.folder = None + + with patch('acepace._parse_arguments', return_value=mock_args): + with patch('builtins.print') as mock_print: + with pytest.raises(SystemExit): + acepace.main() + + # Verify "Running in Docker mode" was NOT printed + print_calls = [str(c) for c in mock_print.call_args_list] + assert not any("Running in Docker mode" in str(call) for call in print_calls) + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.update_episodes_index_db') + def test_docker_mode_message_not_shown_for_episodes_update(self, mock_update, mock_show_status, mock_validate): + """Test that Docker mode message is not shown for --episodes_update command.""" + mock_validate.return_value = True + + # Create mock args with --episodes_update + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = True + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + + with patch('acepace._parse_arguments', return_value=mock_args): + with patch('builtins.print') as mock_print: + with pytest.raises(SystemExit): + acepace.main() + + # Verify "Running in Docker mode" was NOT printed + print_calls = [str(c) for c in mock_print.call_args_list] + assert not any("Running in Docker mode" in str(call) for call in print_calls) + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_main_commands') + def test_docker_mode_message_not_printed_by_python_for_main_command(self, mock_handle, mock_folder, + mock_init_db, mock_show_status, + mock_validate): + """In Docker, entrypoint.sh prints the header once; Python must not print it again.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + # Create mock args for main command (no --db or --episodes_update) + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = False + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + mock_args.folder = None + + with patch('acepace._parse_arguments', return_value=mock_args): + with patch('builtins.print') as mock_print: + with pytest.raises(SystemExit): + acepace.main() + + # Python must NOT print header in Docker (entrypoint.sh already did) + print_calls = [str(c) for c in mock_print.call_args_list] + assert not any("Running in Docker mode" in str(call) for call in print_calls) + + @patch('acepace.IS_DOCKER', False) + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_main_commands') + def test_docker_mode_message_not_shown_when_not_in_docker(self, mock_handle, mock_folder, mock_init_db, + mock_show_status, mock_validate): + """Test that Docker mode message is not shown when not running in Docker.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + # Create mock args for main command + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = False + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + mock_args.folder = None + + with patch('acepace._parse_arguments', return_value=mock_args): + with patch('builtins.print') as mock_print: + with pytest.raises(SystemExit): + acepace.main() + + # Verify "Running in Docker mode" was NOT printed + print_calls = [str(c) for c in mock_print.call_args_list] + assert not any("Running in Docker mode" in str(call) for call in print_calls) + + +class TestEpisodesMetadataStatusSuppression: + """Tests for episodes metadata status message suppression.""" + + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.init_db') + @patch('acepace.export_db_to_csv') + def test_episodes_metadata_status_not_shown_for_db_command(self, mock_export, mock_init_db, + mock_show_status, mock_validate): + """Test that episodes metadata status is not shown for --db command.""" + mock_validate.return_value = True + mock_conn = MagicMock() + mock_init_db.return_value = mock_conn + + # Create mock args with --db + mock_args = MagicMock() + mock_args.help = False + mock_args.db = True + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = False + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + mock_args.folder = "/media" + + with patch('acepace._parse_arguments', return_value=mock_args): + with patch('acepace._get_folder_from_args', return_value="/media"): + with pytest.raises(SystemExit): + acepace.main() + + # Verify _show_episodes_metadata_status was NOT called + mock_show_status.assert_not_called() + + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.update_episodes_index_db') + def test_episodes_metadata_status_not_shown_for_episodes_update(self, mock_update, mock_show_status, mock_validate): + """Test that episodes metadata status is not shown for --episodes_update command.""" + mock_validate.return_value = True + + # Create mock args with --episodes_update + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = True + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + + with patch('acepace._parse_arguments', return_value=mock_args): + with pytest.raises(SystemExit): + acepace.main() + + # Verify _show_episodes_metadata_status was NOT called + mock_show_status.assert_not_called() + + @patch('acepace._validate_url') + @patch('acepace._show_episodes_metadata_status') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_main_commands') + def test_episodes_metadata_status_shown_for_main_command(self, mock_handle, mock_folder, mock_init_db, + mock_show_status, mock_validate): + """Test that episodes metadata status is shown for main command.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + # Create mock args for main command + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = False + mock_args.url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + mock_args.folder = None + + with patch('acepace._parse_arguments', return_value=mock_args): + with pytest.raises(SystemExit): + acepace.main() + + # Verify _show_episodes_metadata_status WAS called + mock_show_status.assert_called_once() + + +class TestURLParameterPropagation: + """Tests for URL parameter propagation through command chain.""" + + @patch('acepace._validate_url') + @patch('acepace.update_episodes_index_db') + def test_episodes_update_receives_url_parameter(self, mock_update, mock_validate): + """Test that --episodes_update receives URL parameter from args.""" + mock_validate.return_value = True + + test_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + + # Create mock args with --episodes_update and URL + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = True + mock_args.url = test_url + + with patch('acepace._parse_arguments', return_value=mock_args): + with pytest.raises(SystemExit): + acepace.main() + + # Verify update_episodes_index_db was called with URL and force_update + mock_update.assert_called_once_with(test_url, force_update=True) + + @patch('acepace._validate_url') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_rename_command') + def test_rename_receives_url_parameter(self, mock_rename, mock_folder, mock_init_db, mock_validate): + """Test that --rename receives URL parameter from args.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + test_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace+1080p&o=asc" + + # Create mock args with --rename and URL + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = True + mock_args.url = test_url + mock_args.folder = None + mock_args.dry_run = False + + with patch('acepace._parse_arguments', return_value=mock_args): + with pytest.raises(SystemExit): + acepace.main() + + # Verify _handle_rename_command was called with URL, dry_run, and folder + mock_rename.assert_called_once() + call_args = mock_rename.call_args + assert call_args[0][1] == test_url # Second positional argument is URL + assert call_args[1]["dry_run"] is False + assert call_args[1]["folder"] == "/media" + + @patch('acepace._validate_url') + @patch('acepace.init_db') + @patch('acepace._get_folder_from_args') + @patch('acepace._handle_rename_command') + def test_rename_with_dry_run_passes_dry_run_true(self, mock_rename, mock_folder, mock_init_db, mock_validate): + """Test that --rename --dry-run passes dry_run=True to _handle_rename_command.""" + mock_validate.return_value = True + mock_init_db.return_value = MagicMock() + mock_folder.return_value = "/media" + + mock_args = MagicMock() + mock_args.help = False + mock_args.db = False + mock_args.episodes_update = False + mock_args.download = False + mock_args.rename = True + mock_args.url = None + mock_args.folder = None + mock_args.dry_run = True + + with patch('acepace._parse_arguments', return_value=mock_args): + with pytest.raises(SystemExit): + acepace.main() + + mock_rename.assert_called_once() + assert mock_rename.call_args[1]["dry_run"] is True + assert mock_rename.call_args[1]["folder"] == "/media" + + +class TestDockerDownloadDefaults: + """Tests for Docker download default values and logging.""" + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_docker_uses_default_connection_values(self, mock_get_client, mock_load_magnets): + """Test that Docker mode uses default connection values when not specified.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + # Create mock args without client/host/port specified + mock_args = MagicMock() + mock_args.client = None + mock_args.host = None + mock_args.port = None + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + + with patch.dict('os.environ', {}, clear=False): + # Remove any TORRENT_* env vars to test defaults + for key in ['TORRENT_CLIENT', 'TORRENT_HOST', 'TORRENT_PORT', 'TORRENT_USER', 'TORRENT_PASSWORD']: + if key in os.environ: + del os.environ[key] + + acepace._handle_download_command(mock_args) + + # Verify default values were used + mock_get_client.assert_called_once() + call_args = mock_get_client.call_args + assert call_args[0][0] == "transmission" # Default client + assert call_args[0][1] == "localhost" # Default host + assert call_args[0][2] == 9091 # Default port for transmission + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_docker_logs_connection_parameters(self, mock_get_client, mock_load_magnets): + """Test that Docker mode logs connection parameters used for download.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + # Create mock args + mock_args = MagicMock() + mock_args.client = None + mock_args.host = None + mock_args.port = None + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + + with patch.dict('os.environ', {}, clear=False): + # Remove any TORRENT_* env vars + for key in ['TORRENT_CLIENT', 'TORRENT_HOST', 'TORRENT_PORT', 'TORRENT_USER', 'TORRENT_PASSWORD']: + if key in os.environ: + del os.environ[key] + + with patch('builtins.print') as mock_print: + acepace._handle_download_command(mock_args) + + # Verify connection parameters were logged + print_calls = [str(c) for c in mock_print.call_args_list] + assert any("Download configuration:" in str(call) for call in print_calls) + assert any("Client:" in str(call) for call in print_calls) + assert any("Host:" in str(call) for call in print_calls) + assert any("Port:" in str(call) for call in print_calls) + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_docker_uses_environment_variable_overrides(self, mock_get_client, mock_load_magnets): + """Test that Docker mode uses environment variables to override defaults.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + # Create mock args + mock_args = MagicMock() + mock_args.client = None + mock_args.host = None + mock_args.port = None + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + + with patch.dict('os.environ', { + 'TORRENT_CLIENT': 'qbittorrent', + 'TORRENT_HOST': TEST_HOST_IP, + 'TORRENT_PORT': '8080', + 'TORRENT_USER': 'admin' + }): + acepace._handle_download_command(mock_args) + + # Verify environment variable values were used + mock_get_client.assert_called_once() + call_args = mock_get_client.call_args + assert call_args[0][0] == "qbittorrent" # From env var + assert call_args[0][1] == TEST_HOST_IP # From env var + assert call_args[0][2] == 8080 # From env var + assert call_args[0][3] == "admin" # From env var + + +class TestDryRunMode: + """Tests for dry run mode in download command.""" + + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_dry_run_mode_calls_add_torrents_with_dry_run_flag(self, mock_get_client, mock_load_magnets): + """Test that dry run mode passes dry_run=True to add_torrents.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + mock_args = MagicMock() + mock_args.client = "transmission" + mock_args.host = "localhost" + mock_args.port = 9091 + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + mock_args.dry_run = True + + acepace._handle_download_command(mock_args) + + # Verify add_torrents was called with dry_run=True + mock_client_obj.add_torrents.assert_called_once() + call_kwargs = mock_client_obj.add_torrents.call_args[1] + assert call_kwargs['dry_run'] is True + + @patch('acepace.IS_DOCKER', True) + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_docker_dry_run_mode_logs_dry_run(self, mock_get_client, mock_load_magnets): + """Test that Docker mode logs dry run status.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + mock_args = MagicMock() + mock_args.client = None + mock_args.host = None + mock_args.port = None + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + mock_args.dry_run = True + + with patch.dict('os.environ', {}, clear=False): + for key in ['TORRENT_CLIENT', 'TORRENT_HOST', 'TORRENT_PORT', 'TORRENT_USER', 'TORRENT_PASSWORD']: + if key in os.environ: + del os.environ[key] + + with patch('builtins.print') as mock_print: + acepace._handle_download_command(mock_args) + + # Verify dry run mode was logged + print_calls = [str(c) for c in mock_print.call_args_list] + assert any("DRY RUN" in str(call) for call in print_calls) + assert any("Mode: DRY RUN" in str(call) for call in print_calls) + + @patch('acepace.IS_DOCKER', False) + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_non_docker_dry_run_mode_logs_dry_run(self, mock_get_client, mock_load_magnets): + """Test that non-Docker mode logs dry run status.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + mock_args = MagicMock() + mock_args.client = "transmission" + mock_args.host = "localhost" + mock_args.port = 9091 + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + mock_args.dry_run = True + + with patch('builtins.print') as mock_print: + acepace._handle_download_command(mock_args) + + # Verify dry run mode was logged + print_calls = [str(c) for c in mock_print.call_args_list] + assert any("DRY RUN MODE" in str(call) for call in print_calls) + + @patch('acepace._load_magnet_links') + @patch('acepace.get_client') + def test_dry_run_mode_does_not_add_torrents(self, mock_get_client, mock_load_magnets): + """Test that dry run mode does not actually add torrents.""" + mock_load_magnets.return_value = ["magnet:?xt=urn:btih:test123"] + mock_client_obj = MagicMock() + mock_get_client.return_value = mock_client_obj + + mock_args = MagicMock() + mock_args.client = "transmission" + mock_args.host = "localhost" + mock_args.port = 9091 + mock_args.username = None + mock_args.password = None + mock_args.download_folder = None + mock_args.tag = None + mock_args.category = None + mock_args.dry_run = True + + acepace._handle_download_command(mock_args) + + # Verify add_torrents was called with dry_run=True + mock_client_obj.add_torrents.assert_called_once() + call_kwargs = mock_client_obj.add_torrents.call_args[1] + assert call_kwargs['dry_run'] is True diff --git a/tests/test_missing_detection.py b/tests/test_missing_detection.py new file mode 100644 index 0000000..78ef4d4 --- /dev/null +++ b/tests/test_missing_detection.py @@ -0,0 +1,283 @@ +"""Unit tests for missing episode detection.""" +import pytest +import os +import sys +import csv +from unittest.mock import patch, MagicMock + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +class TestMissingEpisodeDetection: + """Tests for missing episode detection logic.""" + + def test_detect_missing_episodes(self, temp_dir): + """Test detecting missing episodes by comparing CRC32s.""" + # Setup: Create local file with known CRC32 + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + local_crc32s = acepace.calculate_local_crc32(temp_dir, conn) + + # Simulate episodes from Nyaa + crc32_to_link = { + list(local_crc32s)[0]: "https://nyaa.si/view/12345", # Has this one + "MISSING1": "https://nyaa.si/view/12346", # Missing + "MISSING2": "https://nyaa.si/view/12347", # Missing + } + + # Find missing + missing = [crc32 for crc32 in crc32_to_link if crc32 not in local_crc32s] + + assert len(missing) == 2 + assert "MISSING1" in missing + assert "MISSING2" in missing + + conn.close() + + @patch('acepace.requests.get') + def test_fetch_crc32_links_from_nyaa(self, mock_get): + """Test fetching CRC32 links from Nyaa.""" + html_with_results = """ + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+ [One Pace] Episode 2 [1080p][E5F6A7B8].mkv + Magnet +
+ + + """ + + # Empty page to stop the loop + html_empty = """ + + + +
+ + + """ + + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = html_with_results + + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = html_empty + + # First page has results, second page is empty to stop the loop + mock_get.side_effect = [mock_response1, mock_response2] + + base_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace" + crc32_to_link, crc32_to_text, crc32_to_magnet, _ = acepace.fetch_crc32_links(base_url) + + assert len(crc32_to_link) == 2 + assert "A1B2C3D4" in crc32_to_link + assert "E5F6A7B8" in crc32_to_link + assert "A1B2C3D4" in crc32_to_text + assert "magnet:?xt=urn:btih:abc123" in crc32_to_magnet.values() + + @patch('acepace.requests.get') + def test_fetch_crc32_links_filters_quality(self, mock_get): + """Test that fetch_crc32_links filters episodes by quality (1080p only).""" + html_with_mixed_quality = """ + + + + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+ [One Pace] Episode 2 [720p][E5F6A7B8].mkv + Magnet +
+ [One Pace] Episode 3 [480p][A9B0C1D2].mkv + Magnet +
+ + + """ + + html_empty = """ + + + +
+ + + """ + + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = html_with_mixed_quality + + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = html_empty + + mock_get.side_effect = [mock_response1, mock_response2] + + base_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace" + crc32_to_link, _, _, _ = acepace.fetch_crc32_links(base_url) + + # Should only have 1080p episodes, not 720p or 480p + assert len(crc32_to_link) == 1 + assert "A1B2C3D4" in crc32_to_link # 1080p - should be included + assert "E5F6A7B8" not in crc32_to_link # 720p - should be excluded + assert "A9B0C1D2" not in crc32_to_link # 480p - should be excluded + + @patch('acepace.requests.get') + def test_fetch_crc32_links_stops_on_empty_page(self, mock_get): + """Test that fetching processes all pages based on pagination.""" + # First page has results and pagination showing 2 pages + html_with_results = """ + + +
    +
  • 1
  • +
  • 2
  • +
+ + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+ + + """ + + # Second page has no results (empty table) + html_empty = """ + + + +
+ + + """ + + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = html_with_results + + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = html_empty + + # Page 1 is fetched once (for pagination), page 2 is fetched in the loop + mock_get.side_effect = [mock_response1, mock_response2] + + base_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace" + crc32_to_link, _, _, last_page = acepace.fetch_crc32_links(base_url) + + # Should process both pages (pagination shows 2 pages) + assert len(crc32_to_link) == 1 + assert last_page == 2 # Processed both pages + + @patch('acepace.requests.get') + def test_fetch_title_by_crc32(self, mock_get): + """Test fetching title by CRC32 from Nyaa search.""" + html = """ + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+ + + """ + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + title = acepace.fetch_title_by_crc32("A1B2C3D4") + + assert title == "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv" + + @patch('acepace.requests.get') + def test_fetch_title_by_crc32_no_match(self, mock_get): + """Test fetching title when CRC32 not found.""" + html = """ + + + +
+ + + """ + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + title = acepace.fetch_title_by_crc32("NONEXISTENT") + + assert title is None + + @patch('acepace.requests.get') + def test_fetch_title_by_crc32_multiple_matches(self, mock_get): + """Test fetching title when multiple matches found.""" + html = """ + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv +
+ [One Pace] Episode 1 Alt [1080p][A1B2C3D4].mkv +
+ + + """ + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + mock_get.return_value = mock_response + + title = acepace.fetch_title_by_crc32("A1B2C3D4") + + # Should return None when multiple matches + assert title is None diff --git a/tests/test_path_normalization.py b/tests/test_path_normalization.py new file mode 100644 index 0000000..22ef82d --- /dev/null +++ b/tests/test_path_normalization.py @@ -0,0 +1,350 @@ +"""Unit tests for path normalization and quality filtering.""" +import pytest +import os +import sys +import tempfile +from unittest.mock import patch, MagicMock +from bs4 import BeautifulSoup + +# Add parent directory to path to import acepace +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import acepace + + +def _create_test_row(title): + """Helper function to create a test HTML row with given title. + Args: + title: Episode title to use in the row + Returns: BeautifulSoup row element""" + row_html = f""" + + + {title} + Magnet + + + """ + soup = BeautifulSoup(row_html, "html.parser") + return soup.find("tr") + + +def _process_row_with_assertions(title, expected_success, expected_crc32_in_link=None, expected_text_in_values=None): + """Helper function to process a row and assert results. + Args: + title: Episode title + expected_success: Expected success value (True/False) + expected_crc32_in_link: CRC32 that should be in link dict (None to skip check) + expected_text_in_values: Text that should be in text dict values (None to skip check) + Returns: Tuple of (success, should_warn, crc32_to_link, crc32_to_text)""" + row = _create_test_row(title) + crc32_to_link = {} + crc32_to_text = {} + crc32_to_magnet = {} + + success, _, should_warn = acepace._process_crc32_row( + row, crc32_to_link, crc32_to_text, crc32_to_magnet + ) + + assert success is expected_success + assert should_warn is False + + if expected_crc32_in_link is not None: + if expected_success: + assert expected_crc32_in_link in crc32_to_link + else: + assert expected_crc32_in_link not in crc32_to_link + + if expected_text_in_values is not None: + assert expected_text_in_values in crc32_to_text.values() + + return success, should_warn, crc32_to_link, crc32_to_text + + +class TestPathNormalization: + """Tests for file path normalization functionality.""" + + def test_normalize_file_path_absolute(self, temp_dir): + """Test that normalize_file_path converts to absolute path.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + normalized = acepace.normalize_file_path(test_file) + + assert os.path.isabs(normalized) + assert normalized == os.path.realpath(os.path.abspath(test_file)) + + def test_normalize_file_path_relative(self, temp_dir): + """Test that normalize_file_path handles relative paths.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + # Change to temp_dir and use relative path + original_cwd = os.getcwd() + try: + os.chdir(temp_dir) + normalized = acepace.normalize_file_path("test.mkv") + + assert os.path.isabs(normalized) + assert normalized == os.path.realpath(os.path.abspath(test_file)) + finally: + os.chdir(original_cwd) + + def test_normalize_file_path_resolves_symlinks(self, temp_dir): + """Test that normalize_file_path resolves symlinks.""" + # Create a real file + real_file = os.path.join(temp_dir, "real.mkv") + with open(real_file, "wb") as f: + f.write(b"test content") + + # Create a symlink (if supported) + try: + symlink_file = os.path.join(temp_dir, "link.mkv") + os.symlink(real_file, symlink_file) + + normalized = acepace.normalize_file_path(symlink_file) + real_normalized = acepace.normalize_file_path(real_file) + + # Both should resolve to the same path + assert normalized == real_normalized + except (OSError, AttributeError): + # Symlinks not supported on this platform, skip + pytest.skip("Symlinks not supported on this platform") + + def test_normalize_file_path_nonexistent_file(self): + """Test that normalize_file_path handles nonexistent files gracefully.""" + nonexistent = "/nonexistent/path/file.mkv" + normalized = acepace.normalize_file_path(nonexistent) + + # Should still return normalized absolute path even if file doesn't exist + assert os.path.isabs(normalized) + assert normalized == os.path.normpath(os.path.abspath(nonexistent)) + + def test_normalize_file_path_consistent(self, temp_dir): + """Test that normalize_file_path produces consistent results.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + # Test with different path representations + path1 = test_file + path2 = os.path.join(temp_dir, ".", "test.mkv") + path3 = os.path.join(temp_dir, "..", os.path.basename(temp_dir), "test.mkv") + + normalized1 = acepace.normalize_file_path(path1) + normalized2 = acepace.normalize_file_path(path2) + normalized3 = acepace.normalize_file_path(path3) + + # All should normalize to the same path + assert normalized1 == normalized2 == normalized3 + + +class TestPathNormalizationInCalculateCRC32: + """Tests for path normalization in calculate_local_crc32.""" + + def test_calculate_local_crc32_stores_normalized_paths(self, temp_dir): + """Test that calculate_local_crc32 stores normalized paths in database.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + acepace.calculate_local_crc32(temp_dir, conn) + + # Check database for normalized path + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM crc32_cache") + rows = cursor.fetchall() + + assert len(rows) == 1 + stored_path = rows[0][0] + + # Stored path should be normalized (absolute) + assert os.path.isabs(stored_path) + assert stored_path == acepace.normalize_file_path(test_file) + + conn.close() + + def test_calculate_local_crc32_finds_cached_by_normalized_path(self, temp_dir): + """Test that calculate_local_crc32 finds cached entries using normalized paths.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + + # First calculation + crc32s1 = acepace.calculate_local_crc32(temp_dir, conn) + + # Manually insert with different path representation + normalized_path = acepace.normalize_file_path(test_file) + relative_path = "test.mkv" # Different representation + + # Try to query with relative path - should not find it + cursor = conn.cursor() + cursor.execute("SELECT crc32 FROM crc32_cache WHERE file_path = ?", (relative_path,)) + row = cursor.fetchone() + assert row is None # Should not find with relative path + + # Query with normalized path - should find it + cursor.execute("SELECT crc32 FROM crc32_cache WHERE file_path = ?", (normalized_path,)) + row = cursor.fetchone() + assert row is not None # Should find with normalized path + + # Second calculation should use cache (even with different path representation) + original_cwd = os.getcwd() + try: + os.chdir(temp_dir) + crc32s2 = acepace.calculate_local_crc32(".", conn) + assert crc32s1 == crc32s2 + finally: + os.chdir(original_cwd) + + conn.close() + + +class TestPathNormalizationInCountFiles: + """Tests for path normalization in _count_video_files.""" + + def test_count_video_files_uses_normalized_paths(self, temp_dir): + """Test that _count_video_files uses normalized paths for lookup.""" + test_file = os.path.join(temp_dir, "test.mkv") + with open(test_file, "wb") as f: + f.write(b"test content") + + with patch('acepace.DB_NAME', os.path.join(temp_dir, 'test.db')): + conn = acepace.init_db() + + # Calculate CRC32 (stores normalized path) + acepace.calculate_local_crc32(temp_dir, conn) + + # Count files - should find the cached entry + total, recorded = acepace._count_video_files(temp_dir, conn) + + assert total == 1 + assert recorded == 1 # Should find the cached entry + + conn.close() + + +class TestQualityFiltering: + """Tests for quality filtering in episode processing.""" + + def test_process_crc32_row_accepts_1080p(self): + """Test that _process_crc32_row accepts 1080p episodes.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [1080p][A1B2C3D4].mkv", + expected_success=True, + expected_crc32_in_link="A1B2C3D4", + expected_text_in_values="[One Pace] Episode 1 [1080p][A1B2C3D4].mkv" + ) + + def test_process_crc32_row_rejects_720p(self): + """Test that _process_crc32_row rejects 720p episodes.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [720p][A1B2C3D4].mkv", + expected_success=False, + expected_crc32_in_link="A1B2C3D4" + ) + + def test_process_crc32_row_rejects_480p(self): + """Test that _process_crc32_row rejects 480p episodes.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [480p][A1B2C3D4].mkv", + expected_success=False, + expected_crc32_in_link="A1B2C3D4" + ) + + def test_process_crc32_row_rejects_2160p(self): + """Test that _process_crc32_row rejects 2160p (4K) episodes.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [2160p][A1B2C3D4].mkv", + expected_success=False, + expected_crc32_in_link="A1B2C3D4" + ) + + def test_process_crc32_row_rejects_no_quality(self): + """Test that _process_crc32_row rejects episodes without quality marker.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [A1B2C3D4].mkv", + expected_success=False, + expected_crc32_in_link="A1B2C3D4" + ) + + def test_process_crc32_row_rejects_no_one_pace_marker(self): + """Test that _process_crc32_row rejects episodes without [One Pace] marker.""" + _process_row_with_assertions( + "Episode 1 [1080p][A1B2C3D4].mkv", + expected_success=False, + expected_crc32_in_link="A1B2C3D4" + ) + + def test_process_crc32_row_case_insensitive_quality(self): + """Test that quality filtering is case insensitive.""" + _process_row_with_assertions( + "[One Pace] Episode 1 [1080P][A1B2C3D4].mkv", + expected_success=True, + expected_crc32_in_link="A1B2C3D4" + ) + + @patch('acepace.requests.get') + def test_fetch_crc32_links_filters_by_quality(self, mock_get): + """Test that fetch_crc32_links filters episodes by quality.""" + html_with_mixed_quality = """ + + + + + + + + + + + + +
+ [One Pace] Episode 1 [1080p][A1B2C3D4].mkv + Magnet +
+ [One Pace] Episode 2 [720p][E5F6A7B8].mkv + Magnet +
+ [One Pace] Episode 3 [480p][A9B0C1D2].mkv + Magnet +
+ + + """ + + html_empty = """ + + + +
+ + + """ + + mock_response1 = MagicMock() + mock_response1.status_code = 200 + mock_response1.text = html_with_mixed_quality + + mock_response2 = MagicMock() + mock_response2.status_code = 200 + mock_response2.text = html_empty + + mock_get.side_effect = [mock_response1, mock_response2] + + base_url = "https://nyaa.si/?f=0&c=0_0&q=one+pace" + crc32_to_link, _, _, _ = acepace.fetch_crc32_links(base_url) + + # Should only have 1080p episodes, not 720p or 480p + assert len(crc32_to_link) == 1 + assert "A1B2C3D4" in crc32_to_link # 1080p - should be included + assert "E5F6A7B8" not in crc32_to_link # 720p - should be excluded + assert "A9B0C1D2" not in crc32_to_link # 480p - should be excluded