diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 0000000..a70894a --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,195 @@ +# Architecture + +**Analysis Date:** 2026-01-24 + +## Pattern Overview + +**Overall:** Plex Agent Plugin Pattern + +This is a single-purpose Plex agent plugin that implements the `Agent.Movies` class interface. It follows Plex's plugin architecture where agents are dynamically loaded and invoked by the Plex Media Server to enrich metadata for specific media types. The agent fetches scene metadata from a remote Stash instance via GraphQL and populates Plex metadata fields. + +**Key Characteristics:** +- Single monolithic module (`Contents/Code/__init__.py`) containing all agent logic +- Entry point driven by Plex framework callbacks (`search()` and `update()` methods) +- Stateless request/response pattern for GraphQL queries to Stash +- Preference-driven configuration using Plex's preference system +- Bidirectional sync capability (pulls from Stash, optionally writes back Plex ratings) + +## Layers + +**Framework Integration Layer:** +- Purpose: Bridge between Plex framework and agent logic +- Location: `Contents/Code/__init__.py` (lines 146-183 for search, 185-535 for update) +- Contains: `StashPlexAgent` class inheriting from `Agent.Movies` +- Depends on: Plex built-in modules (`Agent`, `Prefs`, `HTTP`, `JSON`, `String`, `Locale`, `Proxy`, `MetadataSearchResult`) +- Used by: Plex Media Server runtime + +**Configuration Layer:** +- Purpose: Load and validate user preferences +- Location: `Contents/DefaultPrefs.json` (default values), `Contents/Info.plist` (plugin metadata) +- Contains: 32 configurable preferences controlling behavior flags, prefixes, tag filtering, and connection parameters +- Depends on: Plex preference system +- Used by: All business logic functions + +**API Communication Layer:** +- Purpose: Handle HTTP requests to remote Stash GraphQL endpoint +- Location: `Contents/Code/__init__.py` (lines 22-73: `HttpReq()`, `HttpPost()`) +- Contains: HTTP request construction with query parameter encoding, retry logic, debug logging +- Depends on: Plex `HTTP` module, Python `urllib`, `urllib2`, preferences (hostname, port, HTTPS flag, API key) +- Used by: `search()` and `update()` methods + +**Data Processing Layer:** +- Purpose: Transform Stash metadata into Plex metadata objects +- Location: `Contents/Code/__init__.py` (lines 75-143 for `FormattedTitle()`, lines 232-535 for metadata population in `update()`) +- Contains: Title formatting, date parsing, tag filtering, collection tag creation, rating sync, image processing +- Depends on: API layer (for raw Stash data), preferences, Python `dateutil`, OS utilities +- Used by: `update()` method + +## Data Flow + +**Scene Search Flow:** + +1. Plex calls `search(results, media, lang)` with media file information +2. Extract filename from `media.items[0].parts[0].file` and clean extension +3. Construct GraphQL query to search Stash by filename (path match or include-path match based on preference) +4. Call `HttpReq()` to execute query against Stash GraphQL endpoint +5. Parse response into `movie_data` list of matching scenes +6. For each scene: construct title (scene title + date, or fallback to filename) +7. Optionally apply `FormattedTitle()` transformation if preference enabled +8. Append `MetadataSearchResult` objects to results list with score (100 if exact match, 85 for multiple matches) + +**Metadata Update Flow:** + +1. Plex calls `update(metadata, media, lang, force)` with matched scene ID in `metadata.id` +2. Query Stash for complete scene data using the scene ID (full GraphQL query with 20+ fields) +3. Validate data against three optional pre-flight checks: + - `RequireOrganized`: Scene must be marked organized in Stash + - `RequireURL`: Scene must have URLs defined + - `RequireStashID`: Scene must have stash_ids defined +4. If validation fails, set `allow_scrape = False` and exit early +5. If data exists and validation passes, populate metadata fields: + - Title (with optional formatting) + - Release date and year (parsed via dateutil) + - Studio name + - Rating (converted from Stash 0-100 scale to 0-10) + - Summary/Details + - Collections (site, studio parent, movies, performers, tags based on preferences) + - Genres (from Stash tags, filtered by IgnoreTags) + - Content rating (hardcoded to "XXX") + - Performer roles with photos + - Posters (from scene screenshot and gallery images) +6. If `AddPlexURL` preference enabled: Create GraphQL mutation to append Plex URL back to Stash scene +7. If `SaveUserRatings` preference enabled: Make REST call to local Plex server to sync rating to user rating field + +**State Management:** +- No persistent state; all state comes from request parameters and preferences +- Plex Media Server manages the matching between file and metadata result +- Stash database is the source of truth for scene metadata +- Optional back-sync from Plex ratings to Stash via GraphQL mutation + +## Key Abstractions + +**HTTP Request Wrapper (`HttpReq()` and `HttpPost()`):** +- Purpose: Abstract GraphQL query execution and error handling +- Examples: Lines 22-42 (GET), lines 44-73 (POST) +- Pattern: Construct full URL from preferences, append API key if configured, execute with retry on first failure +- Retry strategy: Failed request automatically retries once (lines 40-42, 71-73) +- Error handling: Catches exceptions, can raise on second failure or silently pass depending on retry flag + +**Title Formatting (`FormattedTitle()`):** +- Purpose: Apply user-defined title template using Stash metadata +- Examples: Lines 75-143 +- Pattern: Template substitution with performer filtering, tag-based performer exclusion, prefix removal +- Filters out performers: with missing names, tagged with IgnoreTags IDs +- Supports variables: `{performer}`, `{title}`, `{date}`, `{studio}`, `{filename}` + +**Collection Tag Creation:** +- Purpose: Generate Plex collection tags from Stash metadata +- Pattern: Multiple conditional blocks (lines 331-401) create collections based on preferences: + - Site collections (from studio name) + - Studio collections (from parent studio) + - Movie collections (from associated movies) + - Performer/Actor collections (from scene performers) + - Tag collections (from scene tags and performer tags) + - Rating collections (from Stash 0-100 rating) + - Organized flag collection +- Each with optional custom prefix (configurable via preferences) +- All wrapped in try/except to prevent single tag failure from stopping metadata update + +**Tag Filtering:** +- Purpose: Exclude specified tag IDs from genres and collections +- Pattern: Split comma-separated IgnoreTags preference, map to list of IDs, check with `if not genre["id"] in ignore_tags` +- Also filters out tags containing "ambiguous" in name (hardcoded, lines 420, 441) + +## Entry Points + +**`Start()`:** +- Location: `Contents/Code/__init__.py`, lines 16-20 +- Triggers: Called once when Plex Media Server loads the agent plugin +- Responsibilities: Set HTTP headers to accept JSON, set cache time, call preferences validation + +**`ValidatePrefs()`:** +- Location: `Contents/Code/__init__.py`, lines 13-14 +- Triggers: Called by `Start()` when agent initializes +- Responsibilities: Currently a no-op; can be expanded to validate preference values + +**`search(results, media, lang)`:** +- Location: `Contents/Code/__init__.py`, lines 155-182 +- Triggers: Called by Plex when a media file is being matched to metadata (user clicks "Fix Match" or during automatic matching) +- Responsibilities: + - Extract filename from media object + - Query Stash for matching scenes + - Format results with title and score + - Return list of possible matches for user selection + +**`update(metadata, media, lang, force)`:** +- Location: `Contents/Code/__init__.py`, lines 185-535 +- Triggers: Called by Plex after user selects a search result or for metadata refresh +- Responsibilities: + - Fetch full scene details from Stash using matched ID + - Validate data against requirements + - Populate all Plex metadata fields (title, date, rating, summary, etc.) + - Create collections, genres, and performer roles + - Optionally sync back to Stash (ratings, Plex URL) + +## Error Handling + +**Strategy:** Defensive with silent failures + +Errors are handled in two ways: +1. **Network/API errors:** Retry once on first failure (lines 40-42, 71-73). If retry fails, raise exception or return corrupted data. +2. **Data processing errors:** Wrapped in try/except blocks that silently pass, allowing partial metadata population even if some operations fail (examples: lines 221-224, 365-369, 415-452, 471-472, 512-523) + +This approach ensures one bad field (e.g., gallery image load failure) doesn't prevent the entire metadata update from completing. + +**Notable error handling patterns:** +- Lines 226-230: Handle case where Stash ID changed but Plex still references old ID +- Lines 337-347: Try/except around each individual collection tag addition +- Lines 452-453: Catch-all for entire tag processing block +- Lines 489-491: Log exception for poster creation but continue + +## Cross-Cutting Concerns + +**Logging:** +- Framework: Plex `Log()` function (built-in) +- Patterns: + - Conditional debug logging controlled by `DEBUG = Prefs['debug']` (lines 7-11, 156) + - Log before API requests (lines 23-24, 45-47) + - Log matched scenes and scores (line 179) + - Log metadata field operations when debug enabled (lines 195-203, 344, 343, 382, 397, 425, 431, 442, 516, 520) + - Full URL logging on retry (line 36, 66-67) + +**Validation:** +- Pre-flight checks before scraping (lines 194-213): RequireOrganized, RequireURL, RequireStashID +- Null checks before accessing nested objects (examples: lines 254, 357, 372, 475, 501) +- Empty string/None checks for titles and dates (lines 87-88, 165, 233, 247) + +**Authentication:** +- API key appended to requests from preference `Prefs['APIKey']` (lines 26-27, 48-49, 461-462, 478, 495) +- HTTPS/HTTP selection from preference `Prefs['UseHTTPS']` (lines 29-32, 59-62, 497-500) +- Hostname and port from preferences `Prefs['Hostname']`, `Prefs['Port']` (lines 34, 64, 511) +- Optional Plex token from environment variable for rating sync (line 274) + +--- + +*Architecture analysis: 2026-01-24* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 0000000..3713a1c --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,263 @@ +# Codebase Concerns + +**Analysis Date:** 2026-01-24 + +## Tech Debt + +**Deprecated Python 2 Syntax:** +- Issue: Code uses Python 2 imports and libraries (`urllib`, `urllib2`) which are incompatible with Python 3 +- Files: `Contents/Code/__init__.py` +- Impact: Plugin cannot run on Python 3 environments; hard maintenance ceiling for future development +- Fix approach: Port to Python 3 by replacing `urllib2` with `urllib.request`, `urllib` with `urllib.parse`, and updating string encoding patterns + +**Broad Exception Handling:** +- Issue: Many bare `except:` clauses with no exception type or logging (lines 223, 267, 346, 369, 384, 399, 427, 434, 450, 452, 471, 523) +- Files: `Contents/Code/__init__.py` (lines 221-225, 265-268, 342-347, 364-370, 380-385, 395-400, 416-453, 458-472, 479-524) +- Impact: Silent failures hide bugs, network issues, malformed data, or permission problems; no visibility into what went wrong during metadata sync +- Fix approach: Replace bare `except:` with specific exception types and add logging to understand failure modes + +**Retry Logic Without Backoff:** +- Issue: `HttpReq()` and `HttpPost()` functions (lines 22-73) retry immediately on failure with no delay or exponential backoff +- Files: `Contents/Code/__init__.py` (lines 22-42, 44-73) +- Impact: Network timeouts or rate-limited Stash instances will hammer the API; cascading failures under load +- Fix approach: Add configurable delay and exponential backoff to retry logic; respect HTTP 429 responses + +**Missing Null Safety Checks:** +- Issue: Frequent unsafe property access assuming nested objects exist without validation (e.g., line 358 `data["studio"]["parent_studio"]["name"]`, line 505 `image["file"]["height"]`) +- Files: `Contents/Code/__init__.py` (multiple locations) +- Impact: KeyError exceptions when Stash returns incomplete data structures; silent failures or crashes +- Fix approach: Add defensive `.get()` calls with sensible defaults throughout data access + +## Known Bugs + +**Loop Iterator Mutation Bug (Line 100-114):** +- Symptoms: Off-by-one error in performer filtering loop; last performer never checked +- Files: `Contents/Code/__init__.py` (line 100) +- Trigger: When `FormattedTitle()` is called with performers that have no name or ignored tags +- Workaround: Manually edit title in Plex to remove unfiltered performers +- Root cause: Loop uses `range(len(performers)-1)` instead of `range(len(performers))`, skipping the last element +- Fix approach: Remove the `-1` from range, and don't mutate list while iterating; collect indices to remove instead + +**Logical Error in Performer Filter (Line 103):** +- Symptoms: Performers without names may not be filtered correctly due to operator precedence +- Files: `Contents/Code/__init__.py` (line 103) +- Trigger: Performer with `name` key present but value is `None` +- Code: `if 'name' in performer and performer['name'] is None or performer['name'] == u'':` +- Issue: Operator precedence causes this to evaluate as `('name' in performer and performer['name'] is None) or (performer['name'] == u'')` - second part fails if 'name' key missing +- Fix approach: Add parentheses for clarity and use helper function for null/empty checks + +**List Mutation During Iteration (Lines 104, 113):** +- Symptoms: Removing items from `performers` list while iterating causes skipped items and index issues +- Files: `Contents/Code/__init__.py` (lines 96-115) +- Trigger: Any scene with performers that have empty names or ignored tags +- Workaround: None - metadata may be silently corrupted +- Fix approach: Build new filtered list instead of mutating during iteration + +**GraphQL Query Escaping Issues:** +- Symptoms: Filenames with special characters may break GraphQL queries or be mismatched +- Files: `Contents/Code/__init__.py` (lines 127-173) +- Trigger: Filename with quotes, backslashes, or GraphQL special characters +- Current approach: Uses `urllib2.quote()` and string replacement for quotes, but GraphQL escaping may be incomplete +- Fix approach: Use proper GraphQL escaping library or prepared queries + +## Security Considerations + +**API Key Exposure in Logs:** +- Risk: Debug logging includes full connection URLs with API keys in plaintext (lines 24, 36, 46, 66) +- Files: `Contents/Code/__init__.py` (lines 24, 36, 46, 66, 67, 286, 311) +- Current mitigation: Debug mode is optional, not enabled by default +- Recommendations: Redact API keys before logging; use structured logging with filtered fields + +**Hardcoded Plex Token Access:** +- Risk: Line 274 reads `PLEXTOKEN` from environment without validation; token used in unencrypted HTTP requests if HTTPS not enabled +- Files: `Contents/Code/__init__.py` (line 274) +- Impact: If plugin runs with debug logging, token could be exposed; HTTP connections transmit token in plaintext +- Current mitigation: Token only accessed when `SaveUserRatings` is enabled (user opt-in) +- Recommendations: Always use HTTPS for Plex requests, add validation for token presence, sanitize from logs + +**GraphQL Query Injection Risk:** +- Risk: Filename values are URL-encoded but directly interpolated into GraphQL queries (line 168) +- Files: `Contents/Code/__init__.py` (lines 161-173) +- Impact: Malicious filenames could potentially inject GraphQL query logic +- Current mitigation: Uses URL encoding and string escaping, but not GraphQL-specific +- Recommendations: Use GraphQL variable parameters instead of string interpolation; validate/sanitize filename input + +**Unsafe HTTP Header Handling:** +- Risk: Headers set globally at module load time (line 18) - not thread-safe or request-specific +- Files: `Contents/Code/__init__.py` (line 18) +- Impact: Plex plugin is single-threaded but global state could cause issues if design changes +- Recommendations: Set headers per-request rather than globally + +## Performance Bottlenecks + +**N+1 GraphQL Queries for Detailed Information:** +- Problem: Initial search query (line 161-163) fetches limited data; update() function (line 189) fetches full scene again separately +- Files: `Contents/Code/__init__.py` (lines 155-182, 185-192) +- Cause: Two separate HTTP round-trips to Stash; no caching between search and update phases +- Improvement path: Cache search results, or fetch complete data in search phase and reuse in update + +**Synchronous HTTP Calls Block Metadata Processing:** +- Problem: All HTTP requests to Stash are synchronous blocking calls (lines 33-42, 63-73, 288, 480, 513) +- Files: `Contents/Code/__init__.py` (lines 22-73, 185-535) +- Cause: Plex framework may have async capabilities, but plugin uses blocking I/O +- Impact: If Stash is slow (>1s response), Plex metadata processing stalls; cascades if multiple files being processed +- Improvement path: Profile Stash API latency; consider async HTTP if Plex framework supports it; add configurable timeouts + +**Image Processing Without Caching:** +- Problem: Gallery images are fetched on every metadata update (lines 501-524), including images already cached +- Files: `Contents/Code/__init__.py` (lines 493-524) +- Cause: No cache invalidation strategy; no ETag or Last-Modified support +- Impact: Bandwidth waste, slow updates when galleries are large +- Improvement path: Add HTTP cache headers support, or track last sync timestamp to skip unchanged images + +**Unbounded Image Array Processing:** +- Problem: All gallery images added to metadata without limit (line 517-521) +- Files: `Contents/Code/__init__.py` (lines 501-524) +- Cause: No pagination or limit on number of images processed +- Impact: If gallery has 100+ images, Plex metadata object becomes huge; performance degrades +- Improvement path: Add configurable limit on number of images; prioritize by date/rating + +**No Rate Limiting or Backoff:** +- Problem: Bulk operations (scanning many files) make sequential unthrottled API calls to Stash +- Files: `Contents/Code/__init__.py` (search and update methods) +- Cause: No queue or rate-limiting mechanism between file scans +- Impact: Can overload Stash server when Plex scans large libraries +- Improvement path: Add configurable delay between requests, or bulk GraphQL mutations for batch updates + +## Fragile Areas + +**Title Formatting with Missing Data:** +- Files: `Contents/Code/__init__.py` (lines 78-143) +- Why fragile: `FormattedTitle()` assumes data structure exists with nested fields; any missing field causes error + - Line 89: No null check before accessing `data['title']` + - Line 93: No null check before accessing `data['date']` + - Line 96: Copy then filter performers, but filter has bugs (loop range issue, mutation during iteration) + - Line 124: No null check for `data['studio']['name']` + - Line 127: `data["files"][0]` assumes files array is non-empty +- Safe modification: Add `.get()` calls with defaults; validate array sizes; use helper function to safely access nested paths +- Test coverage: No tests present for title formatting with incomplete or malformed data + +**Collection Tag Generation:** +- Files: `Contents/Code/__init__.py` (lines 332-401) +- Why fragile: Nested try-except blocks with bare excepts; no logging of why collection add fails + - Lines 342-347: Studio collection creation fails silently if name is None + - Lines 364-370: Parent studio logic branches three ways but all paths attempt to add(site) without null check + - Lines 380-400: Movie and performer collections have missing index bounds checks +- Safe modification: Validate parent studio exists before accessing; check prefix and name are non-empty; add logging +- Test coverage: No tests for collection generation with missing or null fields + +**JSON Decoding Without Validation:** +- Files: `Contents/Code/__init__.py` (lines 288, 530) +- Why fragile: `JSON.ObjectFromString()` may return malformed or unexpected structure + - Line 288: Plex API response structure assumed but not validated before accessing `['MediaContainer']['Metadata'][0]` + - Line 290-298: Multiple nested dictionary accesses without `.get()` - any missing key causes crash +- Safe modification: Use `.get()` with defaults; add schema validation; handle HTTP errors separately from JSON errors +- Test coverage: No tests for malformed API responses + +**Stash GraphQL Response Handling:** +- Files: `Contents/Code/__init__.py` (lines 169-172, 190-191) +- Why fragile: GraphQL errors not distinguished from successful responses + - Line 172: Assumes `request['data']['findScenes']['scenes']` exists without checking for errors + - Line 191: No check for GraphQL errors in response; assumes data exists + - If query fails, code will crash with KeyError instead of failing gracefully +- Safe modification: Check for `response.get('errors')` first; log query errors; return empty results on error +- Test coverage: No tests for Stash API errors or network failures + +## Scaling Limits + +**GraphQL Query Complexity:** +- Current capacity: Single scene lookup via filename-based filtering +- Limit: If library has many scenes with similar filenames, result set could be large (no limit specified in query) +- Scaling path: Add LIMIT to GraphQL queries; implement pagination for large result sets; profile with >1000 scenes + +**Memory Usage for Large Galleries:** +- Current capacity: All gallery images loaded into memory at once (line 501-524) +- Limit: If scene has 100+ gallery images, metadata object becomes very large; Plex may reject or slow down +- Scaling path: Implement image pagination; add configurable max images; lazy-load images + +**No Batching or Batch Operations:** +- Current capacity: Each file scanned triggers separate search() and update() calls +- Limit: Scanning 1000 files = 2000 HTTP calls to Stash; at 500ms each = 16+ minutes +- Scaling path: Implement batch GraphQL queries for multiple scenes; use Stash subscriptions/webhooks instead of polling + +**Environment Variable Dependency:** +- Current capacity: `os.environ['PLEXTOKEN']` must be set for rating sync to work +- Limit: Plex may not always set this in all execution contexts; Docker/containerized setups may not have it +- Scaling path: Make token access optional with fallback; add validation and error handling; document requirement + +## Dependencies at Risk + +**Python 2 Runtime:** +- Risk: Plugin requires Python 2; Python 2 reached end-of-life in January 2020 +- Impact: Security vulnerabilities in Python 2 are not patched; cannot upgrade to newer Plex versions using Python 3+ +- Migration plan: Port code to Python 3; test with Python 3.8+; verify Plex framework supports Python 3 + +**Plex Framework Stability:** +- Risk: Plugin uses Plex Plugin Framework v2 (Info.plist line 20); framework is not open source and may be deprecated +- Impact: Bug fixes, security patches, or API changes in Plex framework not under plugin control +- Current status: No recent framework updates evident; community may have abandoned this plugin type +- Migration plan: Monitor Plex GitHub and forums for framework updates; consider switching to official Plex metadata format (NFO) or Plex labs integration + +**dateutil Library:** +- Risk: External dependency for date parsing (line 2); not explicitly listed in requirements +- Impact: If dateutil not available in Plex runtime, plugin fails at import time +- Current status: Likely bundled with Plex framework, but undocumented +- Migration plan: Verify dateutil is available; add fallback date parser; document runtime requirements + +## Missing Critical Features + +**Error Recovery and Retry UI:** +- Problem: When metadata sync fails (network error, Stash down, malformed data), plugin sets title to "ZZZFIXME" (line 229) - requires manual intervention +- Blocks: Users cannot easily re-sync metadata without manual Plex UI interaction +- Missing: Automatic retry mechanism, queue for failed syncs, admin UI to view/retry errors + +**Logging and Diagnostics:** +- Problem: Debug logging exists but is opt-in; no error log exports or diagnostics tool +- Blocks: When issues occur, users must manually enable debug mode, reproduce, and share logs - time-consuming +- Missing: Permanent error log, diagnostics bundle, health check endpoint + +**Selective Data Sync:** +- Problem: All metadata is synced together; no way to selectively update just ratings or collections +- Blocks: Users wanting to preserve manual Plex edits (e.g., custom descriptions) while syncing ratings must disable all updates +- Missing: Granular sync options per metadata field + +**Conflict Resolution:** +- Problem: If user edits metadata in Plex while Stash is being updated, changes may be overwritten silently +- Blocks: No way to merge user edits with Stash metadata +- Missing: Conflict detection, merge strategy configuration, user notification on conflicts + +## Test Coverage Gaps + +**No Unit Tests:** +- What's not tested: Individual functions like `FormattedTitle()`, `HttpReq()`, `HttpPost()`, title formatting logic +- Files: `Contents/Code/__init__.py` +- Risk: Bugs in title formatting, HTTP handling, and data transformation go undetected until deployed to users +- Priority: **High** - Critical data transformation logic with no test coverage + +**No Integration Tests:** +- What's not tested: Full search-to-update flow with real Stash instance, error scenarios (network timeout, invalid response, missing fields) +- Files: `Contents/Code/__init__.py` (search method lines 155-182, update method lines 185-535) +- Risk: Metadata corruption, silent failures, cascading errors in production environments +- Priority: **High** - Real-world behavior not validated + +**No Error Case Testing:** +- What's not tested: Malformed GraphQL responses, network timeouts, missing performer/studio data, invalid dates, special characters in filenames +- Files: `Contents/Code/__init__.py` (lines 22-73, 155-535) +- Risk: Silent failures or crashes in edge cases; user experience degradation +- Priority: **High** - Data integrity depends on error handling + +**No Regression Tests:** +- What's not tested: Previous bug fixes (loop range issue, list mutation) are not verified to stay fixed +- Files: `Contents/Code/__init__.py` (lines 100-114) +- Risk: Bugs can be reintroduced by future changes without detection +- Priority: **Medium** - Maintainability concern + +**No Performance Tests:** +- What's not tested: Behavior under load (1000+ scenes), large gallery processing, slow Stash response times +- Files: `Contents/Code/__init__.py` (lines 493-524) +- Risk: Plugin may be unusable with large libraries; no way to catch performance regressions +- Priority: **Medium** - Scalability concern + +--- + +*Concerns audit: 2026-01-24* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 0000000..6c4526b --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,153 @@ +# Coding Conventions + +**Analysis Date:** 2026-01-24 + +## Naming Patterns + +**Files:** +- Single monolithic module: `Contents/Code/__init__.py` +- Configuration: `Contents/DefaultPrefs.json`, `Contents/Info.plist` +- Constants: Mixed use - some as uppercase module-level variables (e.g., `DEBUG`, `Prefs`) + +**Functions:** +- camelCase for function names: `ValidatePrefs()`, `FormattedTitle()`, `HttpReq()`, `HttpPost()` +- Descriptive names: `FormattedTitle()`, `remove_prefix()` (inner function) +- Class methods use standard Python conventions: `search()`, `update()` + +**Variables:** +- snake_case for local variables and parameters: `title_format`, `performer`, `api_string`, `ignore_tags`, `collection_tags` +- camelCase for class attributes and preference keys: `preference`, `DEBUG`, `Hostname`, `Port`, `APIKey`, `UseHTTPS` +- Preference keys use PascalCase: `UseFullMediaPath`, `UseFormattedTitle`, `RequireOrganized`, `CreateSiteCollectionTags` +- Loop variables: `idx`, `tag`, `genre`, `performer`, `model`, `gallery`, `image`, `movie` + +**Types:** +- Type hints not used (Python 2 style codebase for Plex framework compatibility) +- Dictionary keys as strings +- Class name in PascalCase: `StashPlexAgent` + +## Code Style + +**Formatting:** +- Line length: Mixed, some lines exceed 100 characters (e.g., lines 189, 281-284, 529) +- String formatting: Mix of `%` operator and `.format()` method + - `%` style: `Log("Requesting: %s" % url)` (line 24) + - `.format()` style: `title_format.format(performer=performer, title=title, ...)` (lines 128-140) +- Indentation: 4 spaces (Python standard) +- No automated formatter detected (.prettierrc, black, autopep8 config not present) + +**Linting:** +- No linting configuration detected (.pylintrc, flake8, pycodestyle config absent) +- Code has inconsistent style suggesting no active linting +- Bare `except:` clauses used throughout (lines 223, 346, 369, 384, 399, 427, 435, 451, 452, 471, 491, 523) + +## Import Organization + +**Order:** +1. Standard library imports (urllib, urllib2, json, os, dateutil) +2. Framework imports (implicit - Plex framework provides Log, HTTP, JSON, etc.) + +**Actual pattern at top of file:** +```python +import os, urllib, urllib2, json +import dateutil.parser as dateparser +import copy +``` + +**Path Aliases:** +- Not used - direct imports + +## Error Handling + +**Patterns:** +- Try/except with generic exception catching for robustness (many bare `except:` blocks) +- Recursive retry logic: `HttpReq()` and `HttpPost()` functions implement manual retry with `retry=True` parameter + - First attempt: `retry=True` (default) + - Failure triggers second call with `retry=False` + - If second call fails, exception raised (lines 22-42, 44-73) +- Silent exception handling: Many `except:` blocks follow with `pass` or `Log()` call (lines 223-224, 346-347, 369-370, 384-385, 399-400, 427-428, 435-436, 451, 452, 471-472, 491-492, 523) +- Graceful degradation: Failed operations don't halt execution, continue with next task + +**Error logging:** +```python +try: + # operation +except Exception as e: + Log.Exception('Exception creating posters: %s' % str(e)) + pass +``` + +## Logging + +**Framework:** Plex Framework `Log()` function (implicitly provided) + +**Patterns:** +- Conditional logging based on `DEBUG` preference: `if DEBUG: Log(...)` +- Log messages include context: `Log("Requesting: %s" % url)` (line 24) +- Exception logging: `Log.Exception()` method used (line 490) +- Logging used for: API requests, debug info, operation confirmation, errors +- No structured logging, no log levels besides implicit exception logging + +**Debug logging enabled:** +```python +DEBUG = preference['debug'] +if DEBUG: + Log('Agent debug logging is enabled!') +``` + +## Comments + +**When to Comment:** +- Inline comments explain non-obvious logic (line 85: "# or whatever") +- TODO comments for future improvements (lines 76-77, 481-482) +- Explanatory comments for complex operations (lines 277: "inspired by https://...") +- Section comments divide code into logical areas (lines 75: "# set title with stash metadata") +- Commented-out code preserved for reference (lines 483-484: `#clear_posters(metadata)`) + +**Comment style:** +- Single `#` for inline and block comments +- No docstrings detected (none of the functions have docstrings) +- Comments explaining "why" more than "what" (e.g., line 487-488 explains decision not to use art) + +## Function Design + +**Size:** +- Large functions (100+ lines): `FormattedTitle()` (lines 78-143), `update()` (lines 185-534) +- Medium functions (30-50 lines): `HttpReq()`, `HttpPost()`, `search()` +- Small utility functions: `ValidatePrefs()` (lines 13-14, empty), `remove_prefix()` (inner function) + +**Parameters:** +- Default parameters used: `HttpReq(url, authenticate=True, retry=True)` (line 22) +- Named parameters when calling: `MetadataSearchResult(id=..., name=..., score=..., lang=...)` +- Optional parameters with sensible defaults + +**Return Values:** +- Functions typically return single values or objects +- No explicit return statements in many cases (returns None implicitly) +- Void-like functions: `ValidatePrefs()`, `Start()`, class methods manipulate object state rather than returning values + +## Module Design + +**Exports:** +- Single module `__init__.py` contains all code +- Class `StashPlexAgent` is primary export (Agent.Movies subclass) +- Module-level functions available globally: `ValidatePrefs`, `Start`, `HttpReq`, `HttpPost`, `FormattedTitle` + +**Barrel Files:** +- Not applicable - single monolithic module + +## Conditional Logic Patterns + +**Guard clauses:** Not used; nested if statements dominate (lines 195-213) + +**Boolean negation inconsistency:** +- `if not data:` (line 226) +- `if not data["studio"] is None:` (line 254, double negative) +- `if "studio" in title_format:` (line 123, membership test preferred) + +**Configuration checking:** +- Repeated pattern: `if Prefs['KeyName']: ... else: ...` throughout update method +- Guard conditions for requirements (lines 195-213): multi-level nested if/else for organized, URL, and stash ID checks + +--- + +*Convention analysis: 2026-01-24* diff --git a/.planning/codebase/INTEGRATIONS.md b/.planning/codebase/INTEGRATIONS.md new file mode 100644 index 0000000..3e665f7 --- /dev/null +++ b/.planning/codebase/INTEGRATIONS.md @@ -0,0 +1,142 @@ +# External Integrations + +**Analysis Date:** 2026-01-24 + +## APIs & External Services + +**Stash GraphQL API:** +- Service: Stash (adult content management system) +- What it's used for: Query and update scene metadata (title, date, performers, tags, galleries, ratings) +- SDK/Client: Custom HTTP wrapper (Plex HTTP module) +- Auth: API key (optional, passed via `apikey` query parameter) +- Endpoints: + - HTTP/HTTPS POST to `{hostname}:{port}/graphql` (lines 60, 62 in `__init__.py`) + - GraphQL queries for scene search and metadata retrieval (line 161-163, 189) + - GraphQL mutations for updating scene URLs (line 529) + +**Plex Media Server API:** +- Service: Plex Media Server (local metadata system) +- What it's used for: Update metadata, sync ratings, retrieve media identifiers +- SDK/Client: Custom HTTP wrapper (urllib/urllib2) +- Auth: X-Plex-Token via environment variable (PLEXTOKEN) or query parameter +- Endpoints: + - Local only: `http://127.0.0.1:32400/library/metadata/{media_id}` (line 281) + - Rating endpoint: `http://127.0.0.1:32400/:/rate` (line 307) + - Environment variable required: `PLEXTOKEN` (line 274) + +## Data Storage + +**Databases:** +- None - This is a plugin that reads from external systems +- Stash connection: Custom HTTP to remote Stash GraphQL API + +**File Storage:** +- Remote: Stash server hosts images and scene files +- Image paths retrieved from Stash API: + - `data["paths"]["screenshot"]` - Scene poster (line 475) + - `data["galleries"][*]["images"]` - Gallery images (line 502) + - `data["performers"][*]["image_path"]` - Performer photos (line 469) + +**Caching:** +- Plex HTTP cache: 0.1 seconds (line 19 in `__init__.py`) +- Query results cached at Plex media server level + +## Authentication & Identity + +**Auth Provider:** +- Custom (Stash API Key) +- Implementation: + - Optional API key from `Prefs['APIKey']` (lines 26-27, 48-49) + - Appended as `&apikey={key}` query parameter to GraphQL requests + - Plex token via environment variable `PLEXTOKEN` for rating sync (line 274) + +## Monitoring & Observability + +**Error Tracking:** +- None detected + +**Logs:** +- Plex Log framework (built-in) +- Debug logging controlled by `debug` preference (line 7-11) +- Log statements throughout `__init__.py` at key decision points +- Exception logging: `Log.Exception()` at line 490 + +## CI/CD & Deployment + +**Hosting:** +- Plex Media Server instance (self-hosted or Plex Cloud) +- Plugin installed in Plex plugins directory + +**CI Pipeline:** +- None detected + +## Environment Configuration + +**Required env vars:** +- `PLEXTOKEN` - Plex authentication token (only when SaveUserRatings enabled, line 274) + +**Optional configuration:** +- All feature flags and connection details via Plex preferences UI +- No .env files or external config files required + +**Secrets location:** +- Plex UI preferences (APIKey stored in Plex plugin settings) +- PLEXTOKEN as environment variable on Plex server + +## Webhooks & Callbacks + +**Incoming:** +- None - This plugin is pull-based + +**Outgoing:** +- Bidirectional GraphQL mutations to Stash: + - `bulkSceneUpdate` mutation to add Plex URL to Stash scenes (line 529) + - Updates triggered when `AddPlexURL` preference is enabled + +## API Query Examples + +**Stash GraphQL - Scene Search (GET):** +``` +/graphql?query=query{findScenes(scene_filter:{path:{value:"\"FILENAME\"",modifier:INCLUDES}}){scenes{id,title,date,studio{id,name},performers{name}}}}&apikey=KEY +``` +(lines 161-163) + +**Stash GraphQL - Scene Details (GET):** +``` +/graphql?query=query{findScene(id:SCENE_ID){id,title,details,urls,date,files{path},rating100,paths{screenshot,stream}movies{movie{id,name}}studio{id,name,image_path,parent_studio{id,name,details}}organized,stash_ids{stash_id,endpoint}tags{id,name}performers{name,image_path,tags{id,name}}movies{movie{name}}galleries{id,title,url}}}&apikey=KEY +``` +(line 189) + +**Stash GraphQL - Update URLs (POST):** +```json +{ + "query": "mutation UpdateSceneURLs($input: BulkSceneUpdateInput!){ bulkSceneUpdate(input: $input) { id, urls } }", + "variables": { + "input": { + "ids": [SCENE_ID], + "urls": { + "values": "plex/library/metadata/MEDIA_ID", + "mode": "ADD" + } + } + }, + "operationName": "UpdateSceneURLs" +} +``` +(line 529) + +**Plex API - Get Rating Key (GET):** +``` +/library/metadata/{media_id}?X-Plex-Token=TOKEN +``` +(line 281) + +**Plex API - Set User Rating (GET-as-POST):** +``` +/:/rate?key=RATING_KEY&identifier=IDENTIFIER&rating=RATING&X-Plex-Token=TOKEN +``` +(line 307) + +--- + +*Integration audit: 2026-01-24* diff --git a/.planning/codebase/STACK.md b/.planning/codebase/STACK.md new file mode 100644 index 0000000..f9281a9 --- /dev/null +++ b/.planning/codebase/STACK.md @@ -0,0 +1,112 @@ +# Technology Stack + +**Analysis Date:** 2026-01-24 + +## Languages + +**Primary:** +- Python 2.7 - Plex Plugin Framework (legacy bundled runtime) + +## Runtime + +**Environment:** +- Plex Media Server Plugin Runtime (Plex Framework 2) +- Bundled within `.bundle` directory structure + +**Package Manager:** +- Not applicable - Plex plugins use bundled dependencies +- Lockfile: Not applicable + +## Frameworks + +**Core:** +- Plex Framework 2 - Plugin framework for metadata agents +- Agent.Movies - Base class for Plex movie metadata agent in `Contents/Code/__init__.py` + +**HTTP/Network:** +- Plex HTTP module - Built-in HTTP client for making requests +- urllib/urllib2 - Standard library for URL encoding and HTTP requests + +## Key Dependencies + +**Critical:** +- dateutil (dateutil.parser) - Date parsing for Stash scene dates +- json - JSON serialization/deserialization +- os - File path operations +- urllib/urllib2 - HTTP requests and URL encoding +- copy - Deep copying of Python objects + +**Framework Integrations:** +- Plex.Agent - Agent class from Plex framework +- Plex.Locale - Localization support (English only currently) +- Plex.HTTP - HTTP client for external requests +- Plex.JSON - JSON parsing utility +- Plex.Log - Logging framework +- Plex.Prefs - Preference/configuration management +- Plex.Proxy - Media proxy for posters and artwork +- Plex.String - String utilities +- Plex.MetadataSearchResult - Search result wrapper + +## Configuration + +**Environment:** +- Configured via Plex plugin preferences interface +- Settings stored in `Contents/DefaultPrefs.json` +- All settings are user-configurable in Plex UI + +**Default Preferences:** +- `Hostname` - Stash server hostname (default: 127.0.0.1) +- `Port` - Stash server port (default: 9999) +- `UseHTTPS` - Toggle HTTPS vs HTTP (default: false) +- `APIKey` - Stash API authentication key (default: empty) +- `UseFullMediaPath` - Query entire file path vs filename (default: false) +- `UseFormattedTitle` - Format scene titles with Stash metadata (default: false) +- `RemovePerformerFromTitle` - Remove performer names from title (default: true) +- `TitleFormat` - Custom title format template (default: "{performer} - {title}") +- `IncludeGalleryImages` - Include gallery images as posters (default: false) +- `SortGalleryImages` - Auto-sort gallery images by orientation (default: false) +- `AppendPerformerTags` - Include performer tags in scene genres (default: false) +- `IgnoreTags` - Stash tag IDs to exclude from import (default: 0) +- `CreateTagCollectionTags` - Tag IDs to create Plex collections from (default: 0) +- `AddOrganizedCollectionTag` - Auto-add organized content to collection (default: false) +- `OrganizedCollectionTagName` - Name for organized collection (default: "Organized") +- `CreateAllTagCollectionTags` - Create collections from all tags (default: false) +- `CreateSiteCollectionTags` - Auto-create site/studio collections (default: true) +- `CustomSiteCollectionPrefix` - Use custom prefix for site collections (default: true) +- `PrefixSiteCollectionTags` - Site collection prefix (default: "Site: ") +- `CreateStudioCollectionTags` - Auto-create studio parent collections (default: true) +- `UseSiteForStudioCollectionTags` - Use site name if studio undefined (default: false) +- `CustomStudioCollectionPrefix` - Use custom prefix for studio collections (default: true) +- `PrefixStudioCollectionTags` - Studio collection prefix (default: "Studio: ") +- `CreateMovieCollectionTags` - Auto-create movie collections (default: false) +- `PrefixMovieCollectionTags` - Movie collection prefix (default: "Movie: ") +- `CreatePerformerCollectionTags` - Auto-create performer/actor collections (default: false) +- `PrefixPerformerCollectionTags` - Performer collection prefix (default: "Actor: ") +- `CreateRatingTags` - Auto-create rating collections (default: false) +- `SaveUserRatings` - Sync Stash ratings to Plex user ratings (default: false) +- `RequireOrganized` - Only import organized content (default: false) +- `RequireURL` - Require scene URL for import (default: false) +- `RequireStashID` - Require Stash ID for import (default: false) +- `AddPlexURL` - Add Plex metadata ID back to Stash (default: false) +- `debug` - Enable debug logging (default: false) + +**Build:** +- No build step - Plex Framework plugins are interpreted at runtime +- Info.plist in `Contents/Info.plist` defines plugin metadata + +## Platform Requirements + +**Development:** +- Plex Media Server (any version supporting Framework 2) +- .bundle directory structure required +- Text editor for Python code modification + +**Production:** +- Plex Media Server (any recent version) +- Stash instance running with GraphQL API accessible +- Network connectivity between Plex server and Stash server +- Optional: PLEXTOKEN environment variable when syncing ratings back to Plex + +--- + +*Stack analysis: 2026-01-24* diff --git a/.planning/codebase/STRUCTURE.md b/.planning/codebase/STRUCTURE.md new file mode 100644 index 0000000..d5fd1c4 --- /dev/null +++ b/.planning/codebase/STRUCTURE.md @@ -0,0 +1,191 @@ +# Codebase Structure + +**Analysis Date:** 2026-01-24 + +## Directory Layout + +``` +StashPlexAgent.bundle/ +├── Contents/ # Plex plugin bundle structure (required) +│ ├── Code/ +│ │ └── __init__.py # Main agent implementation (536 lines) +│ ├── DefaultPrefs.json # User-configurable preferences +│ └── Info.plist # Plugin metadata and framework version +├── README.md # Installation and usage guide +└── .git/ # Git repository + +``` + +## Directory Purposes + +**`Contents/`:** +- Purpose: Root directory for Plex plugin bundle; Plex looks for this exact structure +- Contains: All code and configuration files that make up the agent +- Key files: `Code/__init__.py`, `DefaultPrefs.json`, `Info.plist` +- Note: This directory name is required by Plex Media Server for plugin discovery + +**`Contents/Code/`:** +- Purpose: Python code directory; Plex automatically imports `__init__.py` as the plugin module +- Contains: Single Python file with all agent logic +- Key files: `__init__.py` + +## Key File Locations + +**Entry Points:** +- `Contents/Code/__init__.py`: Main agent module. Plex calls `Start()`, `search()`, and `update()` methods. + +**Configuration:** +- `Contents/DefaultPrefs.json`: Default preference values (32 options). Each preference has id, label, type (text/bool), and default value. +- `Contents/Info.plist`: Plugin bundle metadata. Declares agent class (`Agent`), framework version (`2`), executable name, bundle identifier. + +**Core Logic:** +- `Contents/Code/__init__.py` contains all core functionality: + - HTTP client layer: `HttpReq()` (GET queries), `HttpPost()` (GraphQL mutations) + - Formatting layer: `FormattedTitle()` (template-based title construction) + - Agent class: `StashPlexAgent` with `search()` and `update()` methods + +**Documentation:** +- `README.md`: Installation instructions, feature overview, configuration guide, optional PlexSync plugin information + +## Naming Conventions + +**Files:** +- `__init__.py`: Required by Plex for Python module discovery +- `DefaultPrefs.json`: Exact name required by Plex for preferences +- `Info.plist`: Exact name required by Plex for bundle metadata +- `README.md`: Documentation only, not loaded by Plex + +**Directories:** +- `Contents/`: Required structure for Plex bundles (case-sensitive) +- `Code/`: Required directory name (Plex convention) +- `StashPlexAgent.bundle`: Bundle root must end with `.bundle` suffix + +**Functions:** +- `Start()`, `ValidatePrefs()`: Plex framework callbacks (required names) +- `search()`, `update()`: Plex agent protocol methods (required names/signatures) +- `HttpReq()`, `HttpPost()`: HTTP utility functions (internal naming convention) +- `FormattedTitle()`: Template formatting utility (descriptive) + +**Variables:** +- `preference`: Maps to `Prefs` (Plex built-in) +- `DEBUG`: Boolean from preferences, used to control logging verbosity +- `connectstring`: URL template for GraphQL endpoint +- `query`: GraphQL query string before URL encoding +- `api_string`: API key parameter string +- `metadata`, `media`, `results`: Plex framework objects (required parameter names) + +**Preferences (in DefaultPrefs.json):** +- Connection: `Hostname`, `Port`, `UseHTTPS`, `APIKey` +- Search: `UseFullMediaPath` +- Title: `UseFormattedTitle`, `TitleFormat`, `RemovePerformerFromTitle` +- Collections: `CreateSiteCollectionTags`, `CreateStudioCollectionTags`, `CreateMovieCollectionTags`, `CreatePerformerCollectionTags`, `CreateRatingTags` +- Customization: All collection-related preferences have `CustomXXX` boolean + `PrefixXXX` text pairs +- Tags: `IgnoreTags`, `CreateTagCollectionTags`, `AppendPerformerTags`, `CreateAllTagCollectionTags` +- Filtering: `RequireOrganized`, `RequireURL`, `RequireStashID` +- Images: `IncludeGalleryImages`, `SortGalleryImages` +- Sync: `SaveUserRatings`, `AddPlexURL`, `OrganizedCollectionTagName` +- Debug: `debug` + +## Where to Add New Code + +**New Feature - Metadata Field Population:** +- Primary code: Add logic in `StashPlexAgent.update()` method (currently lines 232-535) +- Pattern: Query field from Stash response data (`data['field_name']`), validate with null check, assign to metadata object +- Example: Lines 257-260 show pattern for rating: get value, validate, set metadata.rating +- Test location: Would require Plex test framework (not currently used) + +**New HTTP Endpoint Interaction:** +- Implementation: Create new query string in `__init__.py`, call `HttpReq()` or `HttpPost()` with query +- Example: `HttpReq()` is used for read queries (lines 169), `HttpPost()` for mutations (line 530) +- Must append API key from `Prefs['APIKey']` if configured +- Must build URL from `Prefs['Hostname']` and `Prefs['Port']` with protocol from `Prefs['UseHTTPS']` + +**New Configuration Option:** +- Add entry to `Contents/DefaultPrefs.json`: id, label, type (text/bool), default value +- Reference in code: `Prefs['id_name']` to read preference value +- Pattern: Lines 106-110 show tag filtering example using comma-separated preference +- Lines 334-340 show boolean+text preference pair for custom prefix options + +**New Collection Tag Generation:** +- Pattern: Follow structure in lines 331-401 (CreateSiteCollectionTags section) +- Template: Check if data exists, build tag string with optional prefix, wrap in try/except +- Prefix pattern: Check `Prefs['CustomXXX']` boolean, if true use `Prefs['PrefixXXX']` text, else empty string +- Debug logging: Add `if DEBUG: Log("message")` before attempting to add tag + +**New GraphQL Query:** +- Location: Add query string in `search()` or `update()` method +- Pattern: Use triple-quoted raw strings (r"""query{...}""") to avoid escape issues +- Placeholders: Use `` in template, replace with `.replace("", value)` +- URL encoding: Apply `urllib2.quote()` to filename values, escape quotes with backslashes +- Response: Access via `response['data']['operationName']['field']` structure + +**Utilities/Helpers:** +- Shared string utilities: Add to module level or inside relevant function +- Example: `remove_prefix()` function is defined inside `FormattedTitle()` (lines 82-85) since only used there +- Consider moving to module level if reused across multiple functions + +## Special Directories + +**`.git/`:** +- Purpose: Git repository history +- Generated: Yes (git repository) +- Committed: Yes +- Contains: Complete commit history of the project + +**`Contents/`:** +- Purpose: Plex plugin bundle structure +- Generated: No (manually created) +- Committed: Yes +- Note: This is the required structure for all Plex plugins; Plex Media Server expects exactly this layout + +## Module Organization + +**Single Module Design:** +- All code lives in `Contents/Code/__init__.py` (536 lines) +- No separate modules or sub-packages +- Plex framework limitation: Plugins must be single entry point, importing dependencies requires bundling + +**Import Structure:** +- Line 1: Standard library imports: `os`, `urllib`, `urllib2`, `json` +- Line 2: External library: `dateutil.parser as dateparser` +- Line 3: Standard library: `copy` +- Lines 6-11: Initialize preferences and debug flag +- Lines 13-20: Plex framework hooks +- Lines 22-73: HTTP communication utilities +- Lines 75-143: Title formatting utility +- Lines 146-535: Agent class implementation + +**Plex Framework Dependencies (implicit via built-ins):** +- `Agent`: Base class for agents +- `Prefs`: Preference access +- `HTTP`: HTTP request utilities +- `JSON`: JSON parsing +- `String`: String utilities (unquote) +- `Locale`: Language constants +- `Proxy`: Image proxy handling +- `MetadataSearchResult`: Search result objects +- `Log`: Logging function + +## Preference System + +**Preference Storage:** +- Preferences stored in `Contents/DefaultPrefs.json` with default values +- User can override defaults through Plex UI (Admin > Agents > Stash Plex Agent) +- Accessed via `Prefs['id']` throughout code +- Example usage: `Prefs['Hostname']` (line 34), `Prefs['debug']` (line 7), `Prefs['APIKey']` (line 26) + +**Preference Groups (Logical Organization):** +1. **Connection Settings**: Hostname, Port, UseHTTPS, APIKey +2. **Query Mode**: UseFullMediaPath (full path vs filename matching) +3. **Title Formatting**: UseFormattedTitle, TitleFormat, RemovePerformerFromTitle +4. **Collection Creation**: Multiple Create*CollectionTags preferences +5. **Collection Customization**: Custom*CollectionPrefix, Prefix*CollectionTags pairs +6. **Tag Handling**: IgnoreTags, CreateTagCollectionTags, AppendPerformerTags, CreateAllTagCollectionTags +7. **Validation Rules**: RequireOrganized, RequireURL, RequireStashID +8. **Image Processing**: IncludeGalleryImages, SortGalleryImages +9. **Bidirectional Sync**: SaveUserRatings, AddPlexURL +10. **Debug**: debug flag + +--- + +*Structure analysis: 2026-01-24* diff --git a/.planning/codebase/TESTING.md b/.planning/codebase/TESTING.md new file mode 100644 index 0000000..2046b86 --- /dev/null +++ b/.planning/codebase/TESTING.md @@ -0,0 +1,190 @@ +# Testing Patterns + +**Analysis Date:** 2026-01-24 + +## Test Framework + +**Status:** Not detected + +**Runner:** +- No test framework installed (pytest, unittest, nose, etc.) +- No test runner configuration files found + +**Test Files:** +- No test files detected in codebase +- No `tests/`, `test/`, or `spec/` directories found +- Pattern `*.test.py`, `*.spec.py` not present + +**Coverage:** +- No coverage configuration detected (`.coveragerc`, `pytest-cov`, `coverage.py`) +- No coverage reporting tools configured + +**Run Commands:** +```bash +# No test commands available +``` + +## Test File Organization + +**Status:** Not applicable - no tests present + +**Location:** +- Would follow Plex plugin convention: tests in separate `Tests/` directory or alongside code in `Contents/Code/` + +**Naming:** +- Suggested pattern based on context: `test_stash_plex_agent.py`, `test_http_requests.py` + +**Structure:** +- Not established in codebase + +## Current Testing Approach + +**Manual Testing Only:** +- Code relies on manual testing via Plex plugin loading +- No automated test suite +- No unit tests for utility functions +- No integration tests for API communication + +**Debug Mode for Testing:** +- `DEBUG` preference flag exists (line 7, line 156 in search method, line 186 in update method) +- Enables verbose logging via `Log()` statements +- Used to inspect HTTP requests and responses: + ```python + if DEBUG: + Log("Requesting: %s" % url) + Log(connecttoken) + Log(request) + ``` + +## Code Characteristics Affecting Testability + +**Hard to Test:** +- Heavy reliance on Plex Framework globals (Log, HTTP, JSON, Prefs, Proxy, String, Locale) +- Framework imports not explicitly declared - assumed provided by Plex environment +- Direct environment variable access: `os.environ['PLEXTOKEN']` (line 274) without abstraction +- Tight coupling to Plex API structures (metadata object, media object) +- Tight coupling to Stash GraphQL API responses +- Bare `except:` clauses catch all exceptions, obscuring failures (lines 223, 346, 369, 384, 399, 427, 435, 451, 452, 471, 491, 523) +- Global state: `preference`, `DEBUG` read at module level + +**Easier to Test:** +- Utility functions like `FormattedTitle()` are relatively isolated (lines 78-143) +- `remove_prefix()` inner function has single responsibility (lines 82-85) +- Configuration preference reading is explicit via `Prefs['key']` dictionary access + +## Missing Critical Test Coverage + +**[Untested area]:** +- **HttpReq() and HttpPost():** Network requests and retry logic untested + - Files: `Contents/Code/__init__.py` (lines 22-42, 44-73) + - Risk: Retry logic, header formatting, error handling for network failures could break silently + - Priority: High + +**[Untested area]:** +- **FormattedTitle():** Title formatting with variable substitution untested + - Files: `Contents/Code/__init__.py` (lines 78-143) + - Risk: String formatting with missing fields, performer filtering, tag-based exclusion logic untested + - Priority: High + +**[Untested area]:** +- **Search matching logic:** Filename matching and scene discovery untested + - Files: `Contents/Code/__init__.py` (lines 155-182 in search method) + - Risk: File path handling, encoding issues, query construction untested + - Priority: High + +**[Untested area]:** +- **Metadata collection and preference logic:** Complex nested conditionals for collection tag creation untested + - Files: `Contents/Code/__init__.py` (lines 331-401 for collection tag creation, lines 404-435 for tag/genre handling) + - Risk: Conditional logic branches, preference combinations, edge cases untested + - Priority: Medium + +**[Untested area]:** +- **Rating synchronization:** Plex user rating sync logic untested + - Files: `Contents/Code/__init__.py` (lines 269-324) + - Risk: HTTP request construction, rating comparison, environment variable access untested + - Priority: Medium + +**[Untested area]:** +- **Gallery image handling:** Image orientation detection and metadata population untested + - Files: `Contents/Code/__init__.py` (lines 493-523) + - Risk: Image dimension comparison logic, orientation categorization untested + - Priority: Low + +## What Tests Would Look Like + +**Hypothetical unit test structure** (based on testable patterns observed): + +```python +# Test utility function (relatively isolated) +def test_formatted_title_with_performer(): + data = { + 'title': 'Test Scene', + 'performers': [{'name': 'Performer Name', 'tags': []}], + 'date': '2023-01-01', + 'studio': {'name': 'Studio Name'}, + 'files': [{'path': '/path/to/file.mp4'}] + } + result = FormattedTitle(data, fallback_title='Fallback') + assert 'Test Scene' in result + +def test_formatted_title_removes_performer_prefix(): + data = { + 'title': 'Performer Name - Test Scene', + 'performers': [{'name': 'Performer Name', 'tags': []}], + 'date': '2023-01-01', + 'studio': {'name': 'Studio Name'}, + 'files': [{'path': '/path/to/file.mp4'}] + } + # RemovePerformerFromTitle preference would be True + result = FormattedTitle(data, fallback_title='Fallback') + assert 'Test Scene' in result + assert result.startswith('Performer Name') # performer added by format string + +def test_remove_prefix(): + # Inner function would need to be extracted to be testable + assert remove_prefix("hello world", "hello ") == "world" + assert remove_prefix("world", "hello") == "world" +``` + +## Mocking Needs + +**Framework dependencies that would need mocking:** +- `Log()` - Plex logging function +- `HTTP.Request()` - Network requests +- `JSON.ObjectFromString()` - JSON parsing +- `Prefs` - Preference dictionary +- `os.environ` - Environment variables +- `String.Unquote()` - String utilities from Plex +- `MetadataSearchResult` - Plex search result object +- `Proxy.Media()` - Plex proxy object + +**External API responses that would need mocking:** +- Stash GraphQL query responses (JSON structure) +- Plex API responses for rating sync +- HTTP responses for image requests + +## Recommended Testing Strategy + +**Phase 1: Minimal Setup** +- Extract testable utility functions into separate module +- Add type hints for better IDE support +- Create mock fixtures for Stash API responses + +**Phase 2: Unit Tests** +- Test `FormattedTitle()` with various input combinations +- Test string prefix removal logic +- Test preference-based decision logic + +**Phase 3: Integration Tests** +- Mock Plex Framework components +- Test HTTP request formation and retry logic +- Test response parsing and error handling + +**Phase 4: Coverage Goals** +- Aim for 70%+ coverage of utility functions +- Focus on error paths and edge cases +- Test all preference combinations that affect behavior + +--- + +*Testing analysis: 2026-01-24* diff --git a/Contents/Code/__init__.py b/Contents/Code/__init__.py index 756eba8..c79bb2a 100644 --- a/Contents/Code/__init__.py +++ b/Contents/Code/__init__.py @@ -19,6 +19,13 @@ def Start(): HTTP.CacheTime = 0.1 ValidatePrefs() +def redact_sensitive_params(url): + """Redact API keys and tokens from URLs for safe logging.""" + import re + url = re.sub(r'([?&])apikey=[^&]*', r'\1apikey=REDACTED', url, flags=re.IGNORECASE) + url = re.sub(r'([?&])X-Plex-Token=[^&]*', r'\1X-Plex-Token=REDACTED', url, flags=re.IGNORECASE) + return url + def HttpReq(url, authenticate=True, retry=True): if DEBUG: Log("Requesting: %s" % url) @@ -33,7 +40,7 @@ def HttpReq(url, authenticate=True, retry=True): try: connecttoken = connectstring % (Prefs['Hostname'].strip(), Prefs['Port'].strip(), url, api_string) if DEBUG: - Log(connecttoken) + Log(redact_sensitive_params(connecttoken)) return JSON.ObjectFromString( HTTP.Request(connecttoken).content) except Exception as e: @@ -63,7 +70,7 @@ def HttpPost(url, authenticate=True, retry=True): try: connecttoken = connectstring % (Prefs['Hostname'].strip(), Prefs['Port'].strip(), api_string) if DEBUG: - Log(connecttoken) + Log(redact_sensitive_params(connecttoken)) Log(url) return JSON.ObjectFromString( HTTP.Request(connecttoken, data=url, headers=graphql_headers,method='POST').content) @@ -120,7 +127,7 @@ def remove_prefix(text, prefix): title = remove_prefix(title, performer) title = remove_prefix(title, " - ") title = title.strip() - if "studio" in title_format: + if "studio" in title_format and data.get('studio'): studio = data['studio']['name'] if "filename" in title_format: @@ -160,10 +167,11 @@ def search(self, results, media, lang = Locale.Language.English): if (Prefs["UseFullMediaPath"]): file_query = r"""query{findScenes(scene_filter:{path:{value:"",modifier:EQUALS}}){scenes{id,title,date,studio{id,name},performers{name}}}}""" else: - file_query = r"""query{findScenes(scene_filter:{path:{value:"\"\"",modifier:INCLUDES}}){scenes{id,title,date,studio{id,name},performers{name}}}}""" + file_query = r"""query{findScenes(scene_filter:{path:{value:"",modifier:INCLUDES}}){scenes{id,title,date,studio{id,name},performers{name}}}}""" filename = os.path.splitext(os.path.basename(filename))[0] if filename: - filename = filename.replace('"', r'\"') + # Escape backslashes first, then double quotes for GraphQL string safety + filename = filename.replace('\\', '\\\\').replace('"', r'\"') filename = urllib2.quote(filename.encode('UTF-8')) query = file_query.replace("", filename) request = HttpReq(query) @@ -186,7 +194,7 @@ def update(self, metadata, media, lang = Locale.Language.English, force=False): DEBUG = Prefs['debug'] Log("update(%s)" % metadata.id) mid = metadata.id - id_query = "query{findScene(id:%s){id,title,details,urls,date,files{path},rating100,paths{screenshot,stream}movies{movie{id,name}}studio{id,name,image_path,parent_studio{id,name,details}}organized,stash_ids{stash_id,endpoint}tags{id,name}performers{name,image_path,tags{id,name}}movies{movie{name}}galleries{id,title,url}}}" + id_query = "query{findScene(id:%s){id,title,details,urls,date,files{path},rating100,paths{screenshot,stream}movies{movie{id,name}}studio{id,name,image_path,parent_studio{id,name,details}}organized,stash_ids{stash_id,endpoint}tags{id,name}performers{name,image_path,tags{id,name}}movies{movie{name}}galleries{id,title,url,images{id,title,file{width,height}}}}}" data = HttpReq(id_query % mid) data = data['data']['findScene'] metadata.collections.clear() @@ -271,55 +279,57 @@ def update(self, metadata, media, lang = Locale.Language.English, force=False): if not stashRating is None: Log('Set media rating to %s' % stashRating) host = "http://127.0.0.1:32400" - token = os.environ['PLEXTOKEN'] - - # get section details - # inspired by https://github.com/suparngp/plex-personal-shows-agent.bundle/blob/master/Contents/Code/__init__.py#L31 - sectionQueryEncoded = urllib.urlencode({ - "X-Plex-Token": token - }) - section_lookup_url = '{host}/library/metadata/{media_id}?{sectionQueryEncoded}'.format( - host=host, - media_id=media.id, - sectionQueryEncoded=sectionQueryEncoded - ) - if DEBUG: - Log('Section lookup request: %s' % section_lookup_url) - ratings_meta = json.loads(HTTP.Request(url=section_lookup_url, immediate=True, headers={'Accept': 'application/json'}).content) - - identifier = ratings_meta['MediaContainer']['identifier'] - if 'ratingKey' in ratings_meta['MediaContainer']['Metadata'][0] and ratings_meta['MediaContainer']['Metadata'][0]['ratingKey']: - rating_key = ratings_meta['MediaContainer']['Metadata'][0]['ratingKey'] + token = os.environ.get('PLEXTOKEN') + if not token: + Log('ERROR: PLEXTOKEN environment variable not set. Cannot sync user ratings.') else: - rating_key = 0 - if 'userRating' in ratings_meta['MediaContainer']['Metadata'][0] and ratings_meta['MediaContainer']['Metadata'][0]['userRating']: - userRating = ratings_meta['MediaContainer']['Metadata'][0]['userRating'] - else: - userRating = 0 - - if float(userRating) != float(stashRating): - rateQueryEncoded = urllib.urlencode({ - "key": rating_key, - "identifier": identifier, - "rating": stashRating, + # get section details + # inspired by https://github.com/suparngp/plex-personal-shows-agent.bundle/blob/master/Contents/Code/__init__.py#L31 + sectionQueryEncoded = urllib.urlencode({ "X-Plex-Token": token }) - rateUrl = '{host}/:/rate?{rateQueryEncoded}'.format( + section_lookup_url = '{host}/library/metadata/{media_id}?{sectionQueryEncoded}'.format( host=host, - rateQueryEncoded=rateQueryEncoded + media_id=media.id, + sectionQueryEncoded=sectionQueryEncoded ) if DEBUG: - Log('Rate request: %s' % rateUrl) - # inspired by https://github.com/pkkid/python-plexapi/blob/9b8c7d522d1ca94a0782b940c03d257d8dd071a0/plexapi/mixins.py#L317 - request = urllib2.Request(url=rateUrl, data=urllib.urlencode({'dummy':'dummy'}), headers={ - 'Content-Type': 'text/html', - }) - request.get_method = lambda: 'GET' - response = urllib2.urlopen(request) - if DEBUG: - Log("setUserRating: response: %s" % response.read()) - else: - Log("User rating %s already set to %s" % (userRating, stashRating)) + Log('Section lookup request: %s' % redact_sensitive_params(section_lookup_url)) + ratings_meta = json.loads(HTTP.Request(url=section_lookup_url, immediate=True, headers={'Accept': 'application/json'}).content) + + identifier = ratings_meta['MediaContainer']['identifier'] + if 'ratingKey' in ratings_meta['MediaContainer']['Metadata'][0] and ratings_meta['MediaContainer']['Metadata'][0]['ratingKey']: + rating_key = ratings_meta['MediaContainer']['Metadata'][0]['ratingKey'] + else: + rating_key = 0 + if 'userRating' in ratings_meta['MediaContainer']['Metadata'][0] and ratings_meta['MediaContainer']['Metadata'][0]['userRating']: + userRating = ratings_meta['MediaContainer']['Metadata'][0]['userRating'] + else: + userRating = 0 + + if float(userRating) != float(stashRating): + rateQueryEncoded = urllib.urlencode({ + "key": rating_key, + "identifier": identifier, + "rating": stashRating, + "X-Plex-Token": token + }) + rateUrl = '{host}/:/rate?{rateQueryEncoded}'.format( + host=host, + rateQueryEncoded=rateQueryEncoded + ) + if DEBUG: + Log('Rate request: %s' % redact_sensitive_params(rateUrl)) + # inspired by https://github.com/pkkid/python-plexapi/blob/9b8c7d522d1ca94a0782b940c03d257d8dd071a0/plexapi/mixins.py#L317 + request = urllib2.Request(url=rateUrl, data=urllib.urlencode({'dummy':'dummy'}), headers={ + 'Content-Type': 'text/html', + }) + request.get_method = lambda: 'GET' + response = urllib2.urlopen(request) + if DEBUG: + Log("setUserRating: response: %s" % response.read()) + else: + Log("User rating %s already set to %s" % (userRating, stashRating)) else: Log("Media has no user rating, skipping")