-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(api): posters router splits into a package by resource #541
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
c2f8376
refactor(api): posters router splits into a package by resource
chodeus 2480010
fix(codeql): one import style for the posters module in this test file
chodeus 5d44e1e
fix(review): validate destructive inputs, confine backfill, live logg…
chodeus 67fd2d1
fix: serialize module-logger setup, store optimized posters by path
chodeus adc301b
Merge remote-tracking branch 'origin/main' into refactor/posters-stag…
chodeus ea68d27
fix: persist the optimized path before deleting the original
chodeus 6fb5f50
style: explain the best-effort rollback suppression
chodeus File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| """ | ||
| Poster management API endpoints for CHUB. | ||
|
|
||
| Provides poster operations including statistics, file management, | ||
| upload operations, and directory analysis functionality. | ||
| """ | ||
|
|
||
| from backend.api.posters._shared import get_cleanarr_logger, router | ||
|
|
||
| # Import order below IS route-registration order, which is FastAPI's matching | ||
| # order — do not sort. `items` must stay last or /{poster_id} shadows the rest. | ||
| from backend.api.posters import catalog # noqa: F401 | ||
| from backend.api.posters import browse # noqa: F401 | ||
| from backend.api.posters import collections # noqa: F401 | ||
| from backend.api.posters import storage # noqa: F401 | ||
| from backend.api.posters import matching # noqa: F401 | ||
| from backend.api.posters import gdrive # noqa: F401 | ||
| from backend.api.posters import files # noqa: F401 | ||
| from backend.api.posters import reports # noqa: F401 | ||
| from backend.api.posters import plex_metadata # noqa: F401 | ||
| from backend.api.posters import items # noqa: F401 | ||
|
|
||
| __all__ = ["get_cleanarr_logger", "router"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Shared router dependencies for the poster API modules.""" | ||
|
|
||
| from typing import Any | ||
|
|
||
| from fastapi import APIRouter, Request | ||
|
|
||
| from backend.api.utils import get_module_logger | ||
|
|
||
| router = APIRouter( | ||
| prefix="/api/posters", | ||
| tags=["Posters"], | ||
| responses={ | ||
| 500: {"description": "Internal server error"}, | ||
| 404: {"description": "Poster or resource not found"}, | ||
| }, | ||
| ) | ||
|
|
||
|
|
||
| def get_cleanarr_logger(request: Request) -> Any: | ||
| """FastAPI dependency: module-dedicated logger for `/plex-metadata/*` calls. | ||
|
|
||
| Routes UI-triggered scans, deletes, and set-active into | ||
| `logs/poster_cleanarr/poster_cleanarr.log` so every user action the | ||
| Poster Cleanarr page takes leaves an audit trail in the module's log — | ||
| not just scheduled module runs. | ||
| """ | ||
| return get_module_logger(request, "poster_cleanarr") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,311 @@ | ||
| """Poster browse, bulk auto-match, and single-file upload endpoints.""" | ||
|
|
||
| import os | ||
| from pathlib import Path | ||
| from typing import Any, Optional | ||
|
|
||
| from fastapi import Depends, File, Query, UploadFile | ||
| from fastapi.responses import JSONResponse | ||
|
|
||
| from backend.api.posters._shared import router | ||
| from backend.api.utils import ( | ||
| error, | ||
| get_database, | ||
| get_logger, | ||
| ok, | ||
| ) | ||
| from backend.util.config import ConfigError | ||
| from backend.util.database import ChubDB | ||
|
|
||
|
|
||
| @router.post( | ||
| "/auto-match", | ||
| summary="Auto-match posters to media", | ||
| description="Trigger automatic poster-to-media matching by enqueuing a background job.", | ||
| responses={ | ||
| 200: { | ||
| "description": "Auto-match job initiated successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "example": { | ||
| "success": True, | ||
| "message": "Auto-match initiated", | ||
| "data": {"job_id": 123}, | ||
| } | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| ) | ||
| async def auto_match_posters( | ||
| logger: Any = Depends(get_logger), | ||
| db: ChubDB = Depends(get_database), | ||
| ) -> JSONResponse: | ||
| """ | ||
| Trigger automatic poster-to-media matching. | ||
|
|
||
| Enqueues a background job that runs the poster matching | ||
| algorithm to associate unmatched posters with media items | ||
| in the library. | ||
|
|
||
| Returns: | ||
| Job ID for tracking the auto-match operation | ||
| """ | ||
| try: | ||
| logger.debug("Serving POST /api/posters/auto-match") | ||
| result = db.worker.enqueue_job( | ||
| "jobs", {"module_name": "poster_renamerr"}, job_type="module_run" | ||
| ) | ||
| if result.get("success"): | ||
| job_id = result.get("data", {}).get("job_id") | ||
| return ok("Auto-match initiated", {"job_id": job_id}) | ||
| return error( | ||
| "Error enqueuing auto-match", code="AUTO_MATCH_ERROR", status_code=500 | ||
| ) | ||
| except Exception as e: | ||
| logger.error(f"Error initiating auto-match: {e}") | ||
| return error( | ||
| "Error initiating auto-match", | ||
| code="AUTO_MATCH_ERROR", | ||
| status_code=500, | ||
| ) | ||
|
|
||
|
|
||
| @router.get( | ||
| "/browse", | ||
| summary="Browse cached posters with filtering", | ||
| description="Browse cached poster files with optional owner, type, and search filters.", | ||
| responses={ | ||
| 200: { | ||
| "description": "Filtered poster listing retrieved successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "example": { | ||
| "success": True, | ||
| "message": "Retrieved 60 of 500 posters", | ||
| "data": { | ||
| "items": [{"id": 1, "title": "The Matrix"}], | ||
| "total": 500, | ||
| "owners": ["Sahara", "Stupifier"], | ||
| }, | ||
| } | ||
| } | ||
| }, | ||
| } | ||
| }, | ||
| ) | ||
| async def browse_posters( | ||
| owner: Optional[str] = Query(None, description="Filter by GDrive owner name"), | ||
| type: Optional[str] = Query( | ||
| None, description="Filter by asset type: movie, show, season, collection" | ||
| ), | ||
| query: Optional[str] = Query(None, description="Search by title"), | ||
| style: Optional[str] = Query( | ||
| None, | ||
| description="Filter by poster style (e.g. CL2K, MM2K). Use 'other' for rows whose source dir didn't match a configured gdrive entry.", | ||
| ), | ||
| image_type: Optional[str] = Query( | ||
| None, | ||
| description="Image type to browse: poster (default), logo, background, squareart, banner, or 'artwork' for the logo+squareart+background set.", | ||
| ), | ||
| limit: int = Query( | ||
| 60, ge=0, le=200, description="Results per page (0 for owners only)" | ||
| ), | ||
| offset: int = Query(0, ge=0, description="Pagination offset"), | ||
| logger: Any = Depends(get_logger), | ||
| db: ChubDB = Depends(get_database), | ||
| ) -> JSONResponse: | ||
| """ | ||
| Browse cached poster files with optional filtering and pagination. | ||
|
|
||
| Args: | ||
| owner: Filter by GDrive owner (derived from folder path) | ||
| type: Filter by asset type (movie, show, season, collection) | ||
| query: Search by title | ||
| style: Filter by poster style (CL2K, MM2K, or 'other' for unclassified) | ||
| limit: Results per page | ||
| offset: Pagination offset | ||
|
|
||
| Returns: | ||
| Paginated poster list with available owner and style values for filtering | ||
| """ | ||
| try: | ||
| logger.debug("Serving GET /api/posters/browse") | ||
| # Default to posters; clamp anything unrecognised so the param can't be | ||
| # used to inject an arbitrary image_type value into the query. | ||
| allowed_image_types = { | ||
| "poster", | ||
| "logo", | ||
| "background", | ||
| "squareart", | ||
| "banner", | ||
| "artwork", | ||
| } | ||
| safe_image_type = image_type if image_type in allowed_image_types else "poster" | ||
| result = db.poster.browse( | ||
| owner=owner, | ||
| asset_type=type, | ||
| query=query, | ||
| style=style, | ||
| limit=limit, | ||
| offset=offset, | ||
| image_type=safe_image_type, | ||
| ) | ||
| # Owner/Style are independent of the Image filter: list EVERY location | ||
| # (image_type=None) so an owner like a logos-only "Extras" drive is | ||
| # selectable regardless of which image type is being browsed. | ||
| result["owners"] = db.poster.get_distinct_owners(image_type=None) | ||
| # Merge styles already stamped on rows with styles derivable from the | ||
| # current gdrive_list config so the dropdown is useful even before | ||
| # poster_renamerr has re-upserted existing rows with the new column. | ||
| db_styles = set(db.poster.get_distinct_styles(image_type=None)) | ||
| configured_styles = set() | ||
| try: | ||
| from backend.util.config import load_config | ||
|
|
||
| config = load_config() | ||
| sync_cfg = getattr(config, "sync_gdrive", None) | ||
| for entry in getattr(sync_cfg, "gdrive_list", None) or []: | ||
| name = (getattr(entry, "name", "") or "").strip() | ||
| if name: | ||
| head = name.split(None, 1)[0] | ||
| if head: | ||
| configured_styles.add(head) | ||
| except ConfigError: | ||
| raise | ||
| except Exception as e: | ||
| logger.debug(f"Could not derive configured styles from config: {e}") | ||
| result["styles"] = sorted(db_styles | configured_styles) | ||
| return ok( | ||
| f"Retrieved {len(result['items'])} of {result['total']} posters", result | ||
| ) | ||
| except ConfigError: | ||
| raise | ||
| except Exception as e: | ||
| logger.error(f"Error browsing posters: {e}") | ||
| return error( | ||
| "Error browsing posters", | ||
| code="POSTER_BROWSE_ERROR", | ||
| status_code=500, | ||
| ) | ||
|
|
||
|
|
||
| @router.post( | ||
| "/upload", | ||
| summary="Upload a poster file", | ||
| description="Upload a poster image file and store it in the configured poster directory.", | ||
| responses={ | ||
| 200: { | ||
| "description": "Poster uploaded successfully", | ||
| "content": { | ||
| "application/json": { | ||
| "example": { | ||
| "success": True, | ||
| "message": "Poster uploaded successfully", | ||
| "data": { | ||
| "filename": "Movie Name (2024).jpg", | ||
| "path": "/posters/Movie Name (2024).jpg", | ||
| }, | ||
| } | ||
| } | ||
| }, | ||
| }, | ||
| 400: {"description": "Invalid file type or missing file"}, | ||
| }, | ||
| ) | ||
| async def upload_poster( | ||
| file: UploadFile = File(..., description="Poster image file"), | ||
| logger: Any = Depends(get_logger), | ||
| ) -> JSONResponse: | ||
| """ | ||
| Upload a poster image file. | ||
|
|
||
| Accepts an image file (jpg, jpeg, png, webp) and stores it | ||
| in the poster destination directory configured in poster_renamerr. | ||
|
|
||
| Args: | ||
| file: The poster image file to upload | ||
|
|
||
| Returns: | ||
| Upload confirmation with filename and storage path | ||
| """ | ||
| # Max 20 MiB — posters are typically 100-500 KiB; anything larger is | ||
| # almost certainly wrong (or a disk-fill attempt). | ||
| MAX_UPLOAD_BYTES = 20 * 1024 * 1024 | ||
| # First bytes that identify a real image. We check the magic even though | ||
| # we already check the extension, because an attacker can rename anything | ||
| # to `.jpg`. | ||
| IMAGE_MAGIC = ( | ||
| b"\xff\xd8\xff", # JPEG | ||
| b"\x89PNG\r\n\x1a\n", # PNG | ||
| b"RIFF", # WEBP (followed by 4-byte size + "WEBP") | ||
| ) | ||
|
|
||
| try: | ||
| logger.debug(f"Serving POST /api/posters/upload filename={file.filename}") | ||
|
|
||
| allowed_extensions = {".jpg", ".jpeg", ".png", ".webp"} | ||
| ext = Path(file.filename).suffix.lower() if file.filename else "" | ||
| if ext not in allowed_extensions: | ||
| return error( | ||
| f"Unsupported file type: {ext}. Allowed: {', '.join(allowed_extensions)}", | ||
| code="UNSUPPORTED_FILE_TYPE", | ||
| status_code=400, | ||
| ) | ||
|
|
||
| # Read one byte over the cap so we can detect oversize without | ||
| # trusting the Content-Length header. | ||
| contents = await file.read(MAX_UPLOAD_BYTES + 1) | ||
| if len(contents) > MAX_UPLOAD_BYTES: | ||
| return error( | ||
| f"File exceeds max upload size of {MAX_UPLOAD_BYTES // (1024 * 1024)} MiB", | ||
| code="FILE_TOO_LARGE", | ||
| status_code=413, | ||
| ) | ||
|
|
||
| head = contents[:12] | ||
| is_image = any(head.startswith(magic) for magic in IMAGE_MAGIC) or ( | ||
| ext == ".webp" and head.startswith(b"RIFF") and head[8:12] == b"WEBP" | ||
| ) | ||
| if not is_image: | ||
| return error( | ||
| "Uploaded file is not a valid image (magic bytes mismatch)", | ||
| code="INVALID_IMAGE", | ||
| status_code=400, | ||
| ) | ||
|
|
||
| from backend.util.config import load_config | ||
|
|
||
| config = load_config() | ||
| dest_dir = getattr(config.poster_renamerr, "destination_dir", None) | ||
| if not dest_dir: | ||
| return error( | ||
| "No poster destination directory configured", | ||
| code="NO_DESTINATION_DIR", | ||
| status_code=400, | ||
| ) | ||
|
|
||
| os.makedirs(dest_dir, exist_ok=True) | ||
|
|
||
| # Sanitize filename | ||
| safe_name = Path(file.filename).name | ||
| dest_path = os.path.join(dest_dir, safe_name) | ||
|
|
||
| with open(dest_path, "wb") as f: | ||
| f.write(contents) | ||
|
|
||
| logger.info(f"Poster uploaded: {dest_path}") | ||
| return ok( | ||
| "Poster uploaded successfully", | ||
| {"filename": safe_name, "path": dest_path, "size_bytes": len(contents)}, | ||
| ) | ||
|
|
||
| except ConfigError: | ||
| raise | ||
| except Exception as e: | ||
| logger.error(f"Error uploading poster: {e}") | ||
| return error( | ||
| "Error uploading poster", | ||
| code="POSTER_UPLOAD_ERROR", | ||
| status_code=500, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.