Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions backend/api/v1/plex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""
Plex API endpoints.

Provides REST API endpoints for Plex integration functionality.
"""

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from typing import Dict, Any, Optional

from core.plex.auth import start_auth_flow, poll_for_token
from core.plex.scan import trigger_media_scan
from core.plex.extras import check_for_extras
from api.v1.models import ErrorResponse
from app_logger import ModuleLogger

logging = ModuleLogger("PlexAPI")

plex_router = APIRouter(prefix="/plex", tags=["Plex Integration"])


# Request/Response models
class AuthStartRequest(BaseModel):
client_identifier: str
product_name: str


class AuthStartResponse(BaseModel):
pin: str
auth_url: str
expires_in: str


class AuthPollResponse(BaseModel):
status: str # "pending", "success", or "expired"
token: Optional[str] = None
plex_server_address: Optional[str] = None


class ScanRequest(BaseModel):
token: str
server_address: str
media_folder_path: str


class ScanResponse(BaseModel):
success: bool
message: str


class ExtrasRequest(BaseModel):
token: str
server_address: str
media_type: str # "movie" or "show"
tmdb_id: Optional[str] = None
tvdb_id: Optional[str] = None


class ExtraDetail(BaseModel):
title: str
type: str
duration: int


class ExtrasResponse(BaseModel):
has_extras: bool
extras: list[ExtraDetail]
message: str


@plex_router.post(
"/auth/start",
status_code=status.HTTP_200_OK,
response_model=AuthStartResponse,
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "Failed to start authentication flow",
},
},
)
async def start_plex_auth(request: AuthStartRequest) -> AuthStartResponse:
"""
Start the Plex authentication flow.

Generates a PIN and auth URL for the user to authenticate with Plex.
"""
try:
logging.info(f"Starting Plex auth for client: {request.client_identifier}")
result = await start_auth_flow(request.client_identifier, request.product_name)
return AuthStartResponse(**result)
except Exception as e:
logging.error(f"Failed to start Plex auth: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)


@plex_router.get(
"/auth/poll/{pin}",
status_code=status.HTTP_200_OK,
response_model=AuthPollResponse,
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "Failed to poll for token",
},
},
)
async def poll_plex_token(pin: str, client_identifier: str) -> AuthPollResponse:
"""
Poll for Plex authentication token.

Checks if the user has completed authentication for the given PIN.
"""
try:
logging.debug(f"Polling Plex token for PIN: {pin}")
result = await poll_for_token(pin, client_identifier)
return AuthPollResponse(**result)
except Exception as e:
logging.error(f"Failed to poll Plex token: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)


@plex_router.post(
"/scan",
status_code=status.HTTP_200_OK,
response_model=ScanResponse,
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "Failed to trigger media scan",
},
},
)
async def trigger_plex_scan(request: ScanRequest) -> ScanResponse:
"""
Trigger a media library scan on Plex server.

Initiates a scan of the specified media folder path.
"""
try:
logging.info(f"Triggering Plex scan for: {request.media_folder_path}")
result = await trigger_media_scan(
request.token,
request.server_address,
request.media_folder_path
)
return ScanResponse(**result)
except Exception as e:
logging.error(f"Failed to trigger Plex scan: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)


@plex_router.post(
"/extras",
status_code=status.HTTP_200_OK,
response_model=ExtrasResponse,
responses={
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "Failed to check for extras",
},
},
)
async def check_plex_extras(request: ExtrasRequest) -> ExtrasResponse:
"""
Check for media extras in Plex library.

Searches for extras (trailers, behind-the-scenes, etc.) for the specified media item.
"""
try:
logging.info(f"Checking Plex extras for {request.media_type} - TMDB: {request.tmdb_id}, TVDB: {request.tvdb_id}")
result = await check_for_extras(
request.token,
request.server_address,
request.media_type,
request.tmdb_id,
request.tvdb_id
)

# Convert extras to the response model
extras = [
ExtraDetail(
title=extra["title"],
type=extra["type"],
duration=extra["duration"]
)
for extra in result["extras"]
]

return ExtrasResponse(
has_extras=result["has_extras"],
extras=extras,
message=result["message"]
)
except Exception as e:
logging.error(f"Failed to check Plex extras: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
2 changes: 2 additions & 0 deletions backend/api/v1/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from api.v1.logs import logs_router
from api.v1.tasks import tasks_router
from api.v1.trailerprofiles import trailerprofiles_router
from api.v1.plex import plex_router

from app_logger import ModuleLogger

Expand All @@ -40,6 +41,7 @@
authenticated_router.include_router(logs_router)
authenticated_router.include_router(tasks_router)
authenticated_router.include_router(trailerprofiles_router)
authenticated_router.include_router(plex_router)

# Now create API router and add the authenticated router to it
api_v1_router = APIRouter()
Expand Down
8 changes: 8 additions & 0 deletions backend/core/plex/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Plex integration module for Trailarr.

This module provides functionality for:
- Plex authentication (PIN-based OAuth flow)
- Media library scanning
- Checking for media extras
"""
139 changes: 139 additions & 0 deletions backend/core/plex/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""
Plex authentication module.

Handles the Plex PIN-based OAuth authentication flow.
"""

import asyncio
import aiohttp
from typing import Dict, Any
from datetime import datetime, timedelta

from app_logger import ModuleLogger

logging = ModuleLogger("PlexAuth")

# Plex API endpoints
PLEX_PIN_URL = "https://plex.tv/api/v2/pins"
PLEX_TOKEN_URL = "https://plex.tv/api/v2/pins/{pin}"


async def start_auth_flow(client_identifier: str, product_name: str) -> Dict[str, Any]:
"""
Start the Plex authentication flow by requesting a PIN.

Args:
client_identifier: Unique identifier for the client application
product_name: Name of the product/application

Returns:
Dict containing:
- pin: The PIN code for user authentication
- auth_url: URL for user to visit for authentication
- expires_in: Timestamp when the PIN expires

Raises:
Exception: If the PIN request fails
"""
logging.info(f"Starting Plex auth flow for product: {product_name}")

headers = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
}

data = {
"X-Plex-Product": product_name,
"X-Plex-Client-Identifier": client_identifier,
"strong": "true" # Use 4-digit PIN instead of 6-digit
}

try:
async with aiohttp.ClientSession() as session:
async with session.post(PLEX_PIN_URL, headers=headers, data=data) as response:
if response.status != 201:
error_text = await response.text()
logging.error(f"Failed to get PIN from Plex: {response.status} - {error_text}")
raise Exception(f"Failed to get PIN from Plex: {response.status}")

result = await response.json()

pin_data = {
"pin": result["code"],
"auth_url": f"https://app.plex.tv/auth#?clientID={client_identifier}&code={result['code']}&context%5Bdevice%5D%5Bproduct%5D={product_name}",
"expires_in": result["expiresAt"]
}

logging.info(f"Successfully generated PIN: {pin_data['pin']}")
return pin_data

except aiohttp.ClientError as e:
logging.error(f"Network error during PIN request: {e}")
raise Exception(f"Network error during PIN request: {e}")
except Exception as e:
logging.error(f"Unexpected error during PIN request: {e}")
raise


async def poll_for_token(pin: str, client_identifier: str) -> Dict[str, Any]:
"""
Poll the Plex API to check if the user has authenticated with the PIN.

Args:
pin: The PIN code to check
client_identifier: Unique identifier for the client application

Returns:
Dict containing:
- status: "pending", "success", or "expired"
- token: The authentication token (if successful)
- plex_server_address: The Plex server address (if successful)

Raises:
Exception: If the polling request fails
"""
logging.debug(f"Polling for token with PIN: {pin}")

headers = {
"Accept": "application/json",
"X-Plex-Client-Identifier": client_identifier,
}

url = PLEX_TOKEN_URL.format(pin=pin)

try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status != 200:
error_text = await response.text()
logging.error(f"Failed to poll token from Plex: {response.status} - {error_text}")
raise Exception(f"Failed to poll token from Plex: {response.status}")

result = await response.json()

# Check if PIN has expired
expires_at = datetime.fromisoformat(result["expiresAt"].replace("Z", "+00:00"))
if datetime.now().astimezone() > expires_at:
logging.info(f"PIN {pin} has expired")
return {"status": "expired"}

# Check if token is available
if result.get("authToken"):
logging.info(f"Successfully authenticated PIN: {pin}")
# In a real implementation, you would also get the server address
# For now, we'll use a placeholder
return {
"status": "success",
"token": result["authToken"],
"plex_server_address": "http://localhost:32400" # This should be discovered
}
else:
logging.debug(f"PIN {pin} still pending authentication")
return {"status": "pending"}

except aiohttp.ClientError as e:
logging.error(f"Network error during token polling: {e}")
raise Exception(f"Network error during token polling: {e}")
except Exception as e:
logging.error(f"Unexpected error during token polling: {e}")
raise
Loading