{"name":"Fairplay API","version":"0.1.0","routes":[{"method":"POST","path":"/comments/:commentId/like","summary":"Like a comment","auth":true,"params":{"commentId":"Comment ID"},"responses":{"201":"{ \"message\": \"Comment liked\", \"likeCount\": 1 }","404":"{ \"error\": \"Comment not found\" }","409":"{ \"error\": \"Comment already liked\" }"}},{"method":"DELETE","path":"/comments/:commentId/like","summary":"Unlike a comment","auth":true,"params":{"commentId":"Comment ID"},"responses":{"200":"{ \"message\": \"Comment unliked\", \"likeCount\": 0 }","404":"{ \"error\": \"Like not found for this comment\" }"}},{"method":"DELETE","path":"/comments/:commentId","summary":"Delete a comment","description":"Deletes a comment. Owners can delete their own comments. Moderators/admins can delete any comment. Comments with replies are soft-deleted and kept as `[deleted]` placeholders so reply threads remain reachable.","auth":true,"params":{"commentId":"Comment ID"},"responses":{"200":"{\n  \"message\": \"Comment deleted|Comment soft deleted\",\n  \"deletionMode\": \"hard|soft\",\n  \"commentId\": \"uuid\"\n}","403":"{ \"error\": \"Not allowed to delete this comment\" }","404":"{ \"error\": \"Comment not found\" }"}},{"method":"GET","path":"/comments/:commentId/replies","summary":"Get replies for a comment (paginated)","description":"Returns the direct replies of a comment. Use this endpoint recursively to implement infinite nested replies. Soft-deleted comments are preserved with their `[deleted]` content so child replies stay reachable. Each reply includes likeCount, likedByMe (only when the request is authenticated), user (with proxied avatarUrl), and _count.replies to indicate if more nested replies are available.","params":{"commentId":"Parent comment ID"},"query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"replies\": [\n    {\n      \"id\": \"string\",\n      \"content\": \"string\",\n      \"createdAt\": \"ISO8601\",\n      \"updatedAt\": \"ISO8601\",\n      \"likeCount\": 2,\n      \"likedByMe\": true,\n      \"user\": { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\", \"avatarUrl\": \"http://localhost:2353/assets/users/<userId>/avatar/<file>\" },\n      \"_count\": { \"replies\": 3 }\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 42, \"totalPages\": 3, \"itemsReturned\": 20 }\n}"}},{"method":"GET","path":"/videos/top/viewed","summary":"Get the 3 most viewed videos","description":"Returns the top 3 publicly playable videos ordered by view count (descending).","responses":{"200":"{\"videos\": [{\"id\":\"string\",\"title\":\"string\",\"description\":\"string|null\",\"duration\":123,\"thumbnailUrl\":\"string|null\",\"viewCount\":\"string\",\"avgRating\":0,\"ratingsCount\":0,\"user\":{\"username\":\"string\",\"displayName\":\"string|null\"}}]}"}},{"method":"GET","path":"/videos/search","summary":"Search publicly available videos and creators","description":"Search videos that are approved, done processing, public, and whose owners are not banned. Also returns non-banned creators who have at least one public approved video.","query":{"q":"string (query term)","page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"results\": [\n    { \"type\": \"video\", \"video\": { \"id\": \"string\", \"title\": \"string\", \"duration\": 123, \"thumbnailUrl\": \"string|null\", \"viewCount\": \"string\", \"avgRating\": 4.5, \"ratingsCount\": 10, \"user\": { \"username\": \"string\", \"displayName\": \"string|null\" }, \"createdAt\": \"ISO8601\" } },\n    { \"type\": \"creator\", \"creator\": { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\", \"avatarUrl\": \"string|null\", \"followerCount\": 42, \"videoCount\": 7, \"createdAt\": \"ISO8601\" } }\n  ],\n  \"videos\": [\n    { \"id\": \"string\", \"title\": \"string\", \"duration\": 123, \"thumbnailUrl\": \"string|null\", \"viewCount\": \"string\", \"avgRating\": 4.5, \"ratingsCount\": 10, \"user\": { \"username\": \"string\", \"displayName\": \"string|null\" }, \"createdAt\": \"ISO8601\" }\n  ],\n  \"creators\": [\n    { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\", \"avatarUrl\": \"string|null\", \"followerCount\": 42, \"videoCount\": 7, \"createdAt\": \"ISO8601\" }\n  ],\n  \"pagination\": {\n    \"videos\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 },\n    \"creators\": { \"page\": 1, \"limit\": 20, \"totalItems\": 12, \"totalPages\": 1, \"itemsReturned\": 12 },\n    \"results\": { \"page\": 1, \"limit\": 40, \"totalItems\": 112, \"totalPages\": 3, \"itemsReturned\": 32 }\n  },\n  \"query\": { \"q\": \"term\" }\n}"}},{"method":"GET","path":"/videos","summary":"List publicly available videos","description":"Returns only videos that are approved, done processing, and public.","query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"videos\": [\n    {\n      \"id\": \"string\",\n      \"title\": \"string\",\n      \"duration\": 123,\n      \"thumbnailUrl\": \"string|null\",\n      \"viewCount\": \"string\",\n      \"avgRating\": 4.5,\n      \"ratingsCount\": 10,\n      \"user\": { \"username\": \"string\", \"displayName\": \"string|null\" }\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 }\n}"}},{"method":"GET","path":"/videos/my","summary":"List my videos","auth":true,"description":"Returns all of your videos regardless of visibility or moderation status, including items that are still processing, failed during processing, or pending moderator approval. Each item includes status fields so the client can display badges such as Pending/Processing/Failed/Rejected. Note: To fetch details or stream a video that is not publicly available (e.g., pending, private, unapproved, or processing failed), the request must be authenticated as the owner.","query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"videos\": [\n    {\n      \"id\": \"string\",\n      \"title\": \"string\",\n      \"description\": \"string|null\",\n      \"duration\": 123,\n      \"thumbnailUrl\": \"string|null\",\n      \"viewCount\": \"string\",\n      \"avgRating\": 4.5,\n      \"ratingsCount\": 10,\n      \"visibility\": \"public|unlisted|private\",\n      \"processingStatus\": \"uploading|processing|failed|done\",\n      \"moderationStatus\": \"pending|approved|rejected\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 }\n}"}},{"method":"GET","path":"/videos/:id","summary":"Get video details","description":"When playback is allowed, the `hls` URLs returned by this endpoint are absolute, canonical URLs built from BASE_URL and already include a short-lived playback token. Clients should reuse those URLs as-is for streaming.","params":{"id":"Video public ID or legacy UUID"},"responses":{"200":"{\n  \"id\": \"string\",\n  \"title\": \"string\",\n  \"hls\": {\n    \"master\": \"string|null\",\n    \"variants\": {\n      \"240p\": \"string|null\",\n      \"480p\": \"string|null\",\n      \"720p\": \"string|null\",\n      \"1080p\": \"string|null\"\n    },\n    \"available\": [\"1080p\",\"720p\"],\n    \"preferred\": \"1080p\"\n  },\n  \"thumbnailUrl\": \"string|null\",\n  \"viewCount\": \"string\",\n  \"avgRating\": 4.5,\n  \"ratingsCount\": 10,\n  \"userRating\": 4\n}","403":"{ \"error\": \"Video not available\" }","404":"{ \"error\": \"Video not found\" }"}},{"method":"PATCH","path":"/videos/:id","summary":"Update video details","description":"Update the title, description, or visibility of a video. Only the video owner can perform this action.","auth":true,"params":{"id":"Video public ID or legacy UUID"},"body":{"title":"string (optional)","description":"string (optional)","visibility":"public | unlisted | private (optional)"},"responses":{"200":"{\"message\": \"Video updated successfully\", \"video\": {\"id\": \"string\", \"title\": \"Updated Title\", \"description\": \"Updated description\", \"thumbnailUrl\": \"https://example.com/thumb.jpg\"}}","403":"{ \"error\": \"You are not authorized to edit this video\" }","404":"{ \"error\": \"Video not found\" }"}},{"method":"DELETE","path":"/videos/:id","summary":"Delete a video","description":"Delete a video permanently. Only video owner and moderators can perform this action.","auth":true,"params":{"id":"Video public ID or legacy UUID"},"responses":{"200":"{ \"message\": \"Video deleted successfully\" }","403":"{ \"error\": \"You are not authorized to delete this video\" }","404":"{ \"error\": \"Video not found\" }"}},{"method":"POST","path":"/videos/:id/thumbnail","summary":"Update video thumbnail","description":"Upload a new thumbnail for a video. Only the video owner can perform this action.","auth":true,"params":{"id":"Video public ID or legacy UUID"},"body":{"thumbnail":"image file"},"responses":{"200":"{ \"message\": \"Thumbnail updated successfully\", \"thumbnailUrl\": \"string|null\" }","400":"{ \"error\": \"No thumbnail file provided\" }","403":"{ \"error\": \"You are not authorized to edit this video\" }","404":"{ \"error\": \"Video not found\" }"}},{"method":"POST","path":"/videos/:videoId/rating","summary":"Rate a video","auth":true,"params":{"videoId":"Video public ID or legacy UUID"},"body":{"score":"number (1-5)"},"responses":{"200":"{\"message\": \"Rating updated\", \"rating\": {\"id\": \"uuid\", \"score\": 4, \"userId\": \"uuid\", \"videoId\": \"uuid\"}}","201":"{\"message\": \"Rating created\", \"rating\": {\"id\": \"uuid\", \"score\": 5, \"userId\": \"uuid\", \"videoId\": \"uuid\"}}","404":"{ \"error\": \"Video not found\" }"}},{"method":"POST","path":"/videos/:videoId/comments","summary":"Add a comment to a video","description":"To reply to another comment, include the `parentId` of the comment you are replying to in the request body.","auth":true,"params":{"videoId":"Video public ID or legacy UUID"},"body":{"content":"string (1-500 chars)","parentId":"string (optional UUID)"},"responses":{"201":"{\"message\": \"Comment added\", \"comment\": {\"id\": \"uuid\", \"content\": \"Great video!\", \"userId\": \"uuid\", \"videoId\": \"uuid\", \"parentId\": null, \"likeCount\": 0, \"createdAt\": \"2024-09-20T13:30:00Z\"}}","404":"{ \"error\": \"Video not found\" }"}},{"method":"GET","path":"/videos/:videoId/comments","summary":"Get comments for a video","description":"Returns top-level (parent) comments for a video. Soft-deleted comments are preserved with their `[deleted]` content so reply threads remain accessible. Each comment includes `likeCount`, `likedByMe` (only when the request is authenticated), and `_count.replies` so clients can decide whether to fetch replies. To fetch replies for any comment, call GET /comments/:commentId/replies with pagination. Use that replies endpoint recursively to implement infinite nesting.","params":{"videoId":"Video public ID or legacy UUID"},"query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"comments\": [\n    {\n      \"id\": \"string\",\n      \"content\": \"string\",\n      \"createdAt\": \"ISO8601\",\n      \"updatedAt\": \"ISO8601\",\n      \"likeCount\": 3,\n      \"likedByMe\": true,\n      \"user\": {\n        \"id\": \"string\",\n        \"username\": \"string\",\n        \"displayName\": \"string|null\",\n        \"avatarUrl\": \"http://localhost:2353/assets/users/<userId>/avatar/<file>\"\n      },\n      \"_count\": { \"replies\": 5 }\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 }\n}"}},{"method":"POST","path":"/auth/register","summary":"Register a new user","body":{"email":"string","username":"string","password":"string"},"responses":{"201":"{ \"message\": \"Account created. Please verify your email.\" }","503":"{\"error\": \"Email delivery is temporarily unavailable. Please try again later.\"}"}},{"method":"POST","path":"/auth/login","summary":"Login user","body":{"emailOrUsername":"string","password":"string"},"responses":{"200":"{\n  \"message\": \"Login successful\",\n  \"user\": {\n    \"id\": \"uuid\",\n    \"email\": \"user@example.com\",\n    \"username\": \"johndoe\",\n    \"role\": \"user\"\n  },\n  \"sessionKey\": \"fp_sess_a1b2c3d4e5f6...\",\n  \"session\": {\n    \"id\": \"uuid\",\n    \"expiresAt\": \"2024-10-20T13:30:00Z\",\n    \"deviceInfo\": \"Mac\",\n    \"ipAddress\": \"192.168.1.1\"\n  }\n}"}},{"method":"GET","path":"/auth/verify-email","summary":"Verify user email address","description":"Validates the token sent by email, marks the account as verified, and creates a new authenticated session.","query":{"token":"Verification token received by email"},"responses":{"200":"{\n  \"message\": \"Email successfully verified\",\n  \"user\": {\n    \"id\": \"uuid\",\n    \"email\": \"user@example.com\",\n    \"username\": \"johndoe\",\n    \"role\": \"user\"\n  },\n  \"sessionKey\": \"fp_sess_a1b2c3d4e5f6...\",\n  \"session\": {\n    \"id\": \"uuid\",\n    \"expiresAt\": \"2024-10-20T13:30:00Z\"\n  }\n}","400":"{\"error\": \"Invalid or expired verification link.\"}"}},{"method":"POST","path":"/auth/resend-verification","summary":"Resend email verification link","body":{"email":"string"},"responses":{"200":"{\"message\": \"If this email exists and is unverified, a new link has been sent.\"}","503":"{\"error\": \"Email delivery is temporarily unavailable. Please try again later.\"}"}},{"method":"POST","path":"/auth/forgot-password","summary":"Request a password reset email","body":{"email":"string"},"responses":{"200":"{\"message\": \"If this email exists and is eligible for password reset, a reset link has been sent.\"}","503":"{\"error\": \"Email delivery is temporarily unavailable. Please try again later.\"}"}},{"method":"POST","path":"/auth/reset-password","summary":"Reset account password using an emailed token","body":{"token":"Password reset token","password":"string"},"responses":{"200":"{\n  \"message\": \"Password has been reset successfully. Please log in with your new password.\",\n  \"sessionsLoggedOut\": 3\n}","400":"{\"error\": \"Invalid or expired password reset link.\"}"}},{"method":"GET","path":"/auth/me","summary":"Get current user profile","auth":true,"responses":{"200":"{\n  \"id\": \"uuid\",\n  \"email\": \"user@example.com\",\n  \"username\": \"johndoe\",\n  \"displayName\": \"John Doe\",\n  \"avatarUrl\": \"https://example.com/avatar.jpg\",\n  \"bannerUrl\": \"https://example.com/banner.jpg\",\n  \"bio\": \"Hello world!\",\n  \"role\": \"user\",\n  \"isVerified\": false,\n  \"followerCount\": 42,\n  \"totalViews\": \"1337\",\n  \"createdAt\": \"2024-01-01T00:00:00Z\"\n}"}},{"method":"PATCH","path":"/auth/me","summary":"Update current user profile","auth":true,"body":{"displayName":"string (optional)","bio":"string (optional)"},"responses":{"200":"{\"message\": \"Profile updated successfully\", \"user\": {\"id\": \"uuid\", \"displayName\": \"John Doe\", \"bio\": \"Updated bio\"}}"}},{"method":"GET","path":"/auth/sessions","summary":"Get all active sessions for current user","description":"Returns all active sessions. Use the \"id\" field to logout specific sessions via DELETE /auth/sessions/:sessionId","auth":true,"responses":{"200":"{\n  \"sessions\": [\n    {\n      \"id\": \"uuid\",\n      \"sessionKey\": \"****c3d4e5f6\",\n      \"ipAddress\": \"192.168.1.1\",\n      \"deviceInfo\": \"Mac\",\n      \"createdAt\": \"2024-09-20T10:00:00Z\",\n      \"lastUsedAt\": \"2024-09-20T13:30:00Z\",\n      \"expiresAt\": \"2024-10-20T10:00:00Z\",\n      \"isCurrent\": true\n    }\n  ],\n  \"total\": 1\n}"}},{"method":"DELETE","path":"/auth/sessions/all","summary":"Logout from all sessions including current","auth":true,"responses":{"200":"{\n  \"message\": \"All sessions logged out successfully\",\n  \"sessionsLoggedOut\": 4\n}"}},{"method":"DELETE","path":"/auth/sessions/others/all","summary":"Logout from all other sessions (keep current)","auth":true,"responses":{"200":"{\n  \"message\": \"All other sessions logged out successfully\",\n  \"sessionsLoggedOut\": 3\n}"}},{"method":"DELETE","path":"/auth/sessions/:sessionId","summary":"Logout from a specific session","auth":true,"params":{"sessionId":"Session UUID"},"responses":{"200":"{\"message\": \"Session logged out successfully\"}"}},{"method":"GET","path":"/admin/users","summary":"Admin: list users","description":"Lists users with optional search and ban filtering. Supports pagination and sorting.","auth":true,"roles":["admin"],"query":{"search":"Search in username/email/displayName (optional)","isBanned":"true|false (optional)","page":"Page number (default 1)","limit":"Items per page (default 20)","sort":"field:dir (default createdAt:desc)"},"responses":{"200":"{\n  \"users\": [\n    {\n      \"id\": \"string\",\n      \"email\": \"string\",\n      \"username\": \"string\",\n      \"displayName\": \"string|null\",\n      \"avatarUrl\": \"string|null\",\n      \"role\": \"user|moderator|admin\",\n      \"isActive\": true,\n      \"isVerified\": false,\n      \"isBanned\": false,\n      \"banReasonPrivate\": \"string|null\",\n      \"createdAt\": \"ISO8601\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 123, \"totalPages\": 7, \"itemsReturned\": 20 }\n}"}},{"method":"GET","path":"/admin/users/:id","summary":"Admin: get user by username or ID","auth":true,"roles":["admin"],"params":{"id":"Username or User ID"},"responses":{"200":"{\n  \"id\": \"string\",\n  \"email\": \"string\",\n  \"username\": \"string\",\n  \"displayName\": \"string|null\",\n  \"avatarUrl\": \"string|null\",\n  \"role\": \"user|moderator|admin\",\n  \"isActive\": true,\n  \"isVerified\": false,\n  \"isBanned\": false,\n  \"banReasonPrivate\": \"string|null\",\n  \"bannedAt\": \"ISO8601|null\",\n  \"createdAt\": \"ISO8601\",\n  \"followerCount\": 0,\n  \"followingCount\": 0,\n  \"videoCount\": 0,\n  \"totalViews\": \"string\"\n}","404":"{ \"error\": \"User not found\" }"}},{"method":"PATCH","path":"/admin/users/:id/role","summary":"Admin: update user role","auth":true,"roles":["admin"],"params":{"id":"Username or User ID"},"body":{"role":"user | moderator | admin"},"responses":{"200":"{\"message\": \"User role updated successfully\", \"user\": {\"id\": \"uuid\", \"username\": \"johndoe\", \"role\": \"moderator\"}}","404":"{ \"error\": \"User not found\" }"}},{"method":"PATCH","path":"/admin/users/:id/ban","summary":"Admin: ban or unban a user","description":"Ban or unban a user with optional public and private reasons. Sets bannedAt when banning.","auth":true,"roles":["admin"],"params":{"id":"Username or User ID"},"body":{"isBanned":"boolean","privateReason":"string?"},"responses":{"200":"{\n  \"message\": \"User banned|User unbanned\",\n  \"user\": {\n    \"id\": \"string\",\n    \"username\": \"string\",\n    \"isBanned\": true,\n    \"banReasonPrivate\": \"string|null\",\n    \"bannedAt\": \"ISO8601|null\"\n  }\n}","404":"{ \"error\": \"User not found\" }"}},{"method":"PUT","path":"/admin/campaign","summary":"Admin: create or update the active advertising campaign","description":"Upserts the singleton campaign record. Only one campaign can exist at a time.","auth":true,"roles":["admin"],"body":{"title":"string","description":"string","link":"http(s) URL","thumbnailUrl":"http(s) URL"},"responses":{"200":"{\n  \"message\": \"Campaign updated successfully\",\n  \"campaign\": {\n    \"id\": \"active\",\n    \"title\": \"string\",\n    \"description\": \"string\",\n    \"link\": \"https://example.com\",\n    \"thumbnailUrl\": \"https://example.com/banner.jpg\",\n    \"createdAt\": \"ISO8601\",\n    \"updatedAt\": \"ISO8601\",\n    \"updatedBy\": {\n      \"id\": \"uuid\",\n      \"username\": \"admin\",\n      \"role\": \"admin\"\n    }\n  }\n}","403":"{ \"error\": \"Admin access required\" }"}},{"method":"GET","path":"/health","summary":"Health check","description":"Returns the status of the API and its dependent services (database, object storage). Returns HTTP 503 if any service is down.","responses":{"200":"{\n  \"status\": \"ok\",\n  \"timestamp\": \"ISO8601\",\n  \"uptimeSeconds\": 3600,\n  \"version\": \"1.0.0\",\n  \"services\": {\n    \"database\": { \"status\": \"ok\", \"latencyMs\": 3 },\n    \"storage\":  { \"status\": \"ok\", \"latencyMs\": 12 }\n  }\n}","503":"{\n  \"status\": \"down\",\n  \"timestamp\": \"ISO8601\",\n  \"uptimeSeconds\": 3600,\n  \"version\": \"1.0.0\",\n  \"services\": {\n    \"database\": { \"status\": \"down\", \"latencyMs\": 5001, \"error\": \"Database unavailable\" },\n    \"storage\":  { \"status\": \"ok\",   \"latencyMs\": 10 }\n  }\n}"}},{"method":"GET","path":"/","summary":"API root: overview and endpoints","responses":{"200":"{ \"name\": \"Fairplay API\", \"version\": \"x.x.x\", \"docs\": \"/docs\" }"}},{"method":"GET","path":"/stream/videos/:userId/:videoId/master.m3u8","summary":"Proxy HLS master playlist","description":"Backend proxy for the HLS master playlist. Clients should use the signed URL returned by GET /videos/:id. No Authorization header is required once the short-lived playback token is embedded in that URL. Note: userId must be the actual user ID (UUID), not username.","auth":false,"params":{"userId":"User ID (UUID)","videoId":"Video ID"},"query":{"token":"Short-lived playback token returned by GET /videos/:id"}},{"method":"GET","path":"/stream/videos/:userId/:videoId/:quality/index.m3u8","summary":"Proxy HLS variant playlist","description":"Backend proxy for an HLS variant playlist. Clients should use the signed URL returned by GET /videos/:id so the playlist can propagate the short-lived playback token to segment requests automatically.","auth":false,"params":{"userId":"User ID (UUID)","videoId":"Video ID","quality":"Video quality (240p, 480p, 720p, 1080p)"},"query":{"token":"Short-lived playback token returned by GET /videos/:id"}},{"method":"GET","path":"/stream/videos/:userId/:videoId/:quality/:segment","summary":"Proxy HLS segment (.ts)","description":"Backend proxy for HLS video segments. Requests are expected to carry the short-lived playback token propagated by the master and variant playlists returned from GET /videos/:id.","auth":false,"params":{"userId":"User ID (UUID)","videoId":"Video ID","quality":"Video quality","segment":"Segment filename"},"query":{"token":"Short-lived playback token returned by GET /videos/:id"}},{"method":"GET","path":"/campaign","summary":"Get the active advertising campaign","description":"Returns the currently configured campaign. When no campaign is configured yet, the value is null.","responses":{"200":"{\n  \"campaign\": {\n    \"id\": \"active\",\n    \"title\": \"string (max 50)\",\n    \"description\": \"string (max 200)\",\n    \"link\": \"https://example.com\",\n    \"thumbnailUrl\": \"https://example.com/banner.jpg\",\n    \"createdAt\": \"ISO8601\",\n    \"updatedAt\": \"ISO8601\"\n  }\n}"}},{"method":"GET","path":"/user/top/creators","summary":"Get the 3 most followed creators","description":"Returns the top 3 non-banned users with at least 1 video, ordered by followerCount (descending).","responses":{"200":"{\"users\": [{\"id\":\"string\",\"username\":\"string\",\"displayName\":\"string|null\",\"avatarUrl\":\"string|null\",\"followerCount\":0,\"followingCount\":0,\"videoCount\":0,\"createdAt\":\"2024-09-20T13:30:00Z\"}]}"}},{"method":"GET","path":"/user/:id","summary":"Get a public user profile by username or ID","description":"When the request is authenticated, the response additionally includes `isFollowing` indicating whether the current user follows this profile.","params":{"id":"Username or User ID"},"responses":{"200":"{\n  \"id\": \"string\",\n  \"username\": \"string\",\n  \"displayName\": \"string|null\",\n  \"avatarUrl\": \"string|null\",\n  \"bannerUrl\": \"string|null\",\n  \"bio\": \"string|null\",\n  \"followerCount\": 0,\n  \"followingCount\": 0,\n  \"videoCount\": 0,\n  \"createdAt\": \"ISO8601\",\n  \"isFollowing\": true\n}","404":"{ \"error\": \"User not found\" }"}},{"method":"GET","path":"/user/:id/videos","summary":"List a user's public videos","params":{"id":"Username or User ID"},"query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"videos\": [\n    { \"id\": \"string\", \"title\": \"string\", \"description\": \"string|null\", \"duration\": 123, \"createdAt\": \"ISO8601\", \"viewCount\": \"string\", \"thumbnailUrl\": \"string|null\" }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 10, \"totalPages\": 1, \"itemsReturned\": 10 }\n}"}},{"method":"GET","path":"/user/:id/followers","summary":"Get a user's followers","params":{"id":"Username or User ID"},"query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"followers\": [\n    { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\", \"avatarUrl\": \"string|null\" }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 }\n}"}},{"method":"GET","path":"/user/:id/following","summary":"Get users someone is following","params":{"id":"Username or User ID"},"query":{"page":"number (default 1)","limit":"number (default 20)"},"responses":{"200":"{\n  \"following\": [\n    { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\", \"avatarUrl\": \"string|null\" }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 100, \"totalPages\": 5, \"itemsReturned\": 20 }\n}"}},{"method":"POST","path":"/user/:id/follow","summary":"Follow a user","auth":true,"params":{"id":"Username or User ID to follow"},"responses":{"204":"No content","409":"{\"error\": \"Already following this user\"}"}},{"method":"DELETE","path":"/user/:id/follow","summary":"Unfollow a user","auth":true,"params":{"id":"Username or User ID to unfollow"},"responses":{"204":"No content","404":"{\"error\": \"Not following this user\"}"}},{"method":"POST","path":"/upload/video","summary":"Upload a video up to 95MB (queued for processing)","auth":true,"body":{"title":"string","description":"string?","tags":"string (comma-separated)","video":"file"},"responses":{"200":"{\n  \"message\": \"Video uploaded successfully and queued for processing\",\n  \"video\": {\n    \"id\": \"string\",\n    \"title\": \"string\"\n  }\n}"}},{"method":"POST","path":"/upload/video-chunks/init","summary":"Initialize chunked video upload (up to 3040MB total)","auth":true,"description":"Chunked uploads use fixed 24MB chunks and are capped at 127 chunks total to protect temporary disk usage.","body":{"title":"string","description":"string?","tags":"string (comma-separated)?","allowComments":"boolean (optional, default: true)","license":"all_rights_reserved|cc_by|cc_by_sa|cc_by_nd|cc_by_nc|cc_by_nc_sa|cc_by_nc_nd|cc0 (optional)","totalSize":"number (bytes)","totalChunks":"number (optional, server computes it if omitted)","originalName":"string?","mimeType":"string?"},"responses":{"201":"{\n  \"uploadId\": \"uuid\",\n  \"chunkSizeBytes\": 25165824,\n  \"chunkSizeMB\": 24,\n  \"totalChunks\": 25,\n  \"totalSize\": 624951296\n}"}},{"method":"POST","path":"/upload/video-chunks/:uploadId/chunk","summary":"Upload one video chunk (max safe chunk size from init response)","auth":true,"params":{"uploadId":"Upload session ID (UUID)"},"body":{"chunkIndex":"number (0-based)","chunk":"file (max 24MB)"},"responses":{"200":"{\n  \"message\": \"Chunk uploaded successfully\",\n  \"uploadId\": \"uuid\",\n  \"chunkIndex\": 0,\n  \"receivedChunks\": 1,\n  \"totalChunks\": 6,\n  \"isComplete\": false\n}"}},{"method":"POST","path":"/upload/video-chunks/:uploadId/complete","summary":"Finalize chunked video upload and queue processing","auth":true,"params":{"uploadId":"Upload session ID (UUID)"},"body":{"thumbnail":"image file (optional)"},"responses":{"200":"{\n  \"message\": \"Video uploaded successfully and queued for processing\",\n  \"video\": {\n    \"id\": \"string\",\n    \"title\": \"string\",\n    \"thumbnailUrl\": \"string|null\"\n  }\n}"}},{"method":"DELETE","path":"/upload/video-chunks/:uploadId","summary":"Abort a chunked video upload session and delete temporary chunks","auth":true,"params":{"uploadId":"Upload session ID (UUID)"},"responses":{"200":"{ \"message\": \"Chunked upload aborted successfully\" }"}},{"method":"POST","path":"/upload/video-bundle","summary":"Upload a video bundle up to 90MB with an optional thumbnail","auth":true,"body":{"title":"string","description":"string?","tags":"string (comma-separated)","allowComments":"boolean (optional, default: true)","video":"file","thumbnail":"image file (optional)"},"responses":{"200":"{\n  \"message\": \"Video uploaded successfully and queued for processing\",\n  \"video\": {\n    \"id\": \"string\",\n    \"title\": \"string\",\n    \"thumbnailUrl\": \"string|null\"\n  }\n}"}},{"method":"POST","path":"/upload/avatar","summary":"Upload user avatar","auth":true,"body":{"avatar":"image file"},"responses":{"200":"{\n  \"message\": \"Avatar uploaded successfully\",\n  \"avatarUrl\": \"string\"\n}"}},{"method":"POST","path":"/upload/banner","summary":"Upload user banner","auth":true,"body":{"banner":"image file"},"responses":{"200":"{\n  \"message\": \"Banner uploaded successfully\",\n  \"bannerUrl\": \"string\"\n}"}},{"method":"GET","path":"/upload/url/:bucket","summary":"Get a presigned URL for one of your stored objects","description":"Only the owning user or staff can request a presigned URL. Public assets and playback should use the proxy endpoints instead.","auth":true,"params":{"bucket":"videos|users"},"query":{"objectName":"Object path inside the bucket (preferred for nested paths)","expiry":"Expiry in seconds between 60 and 604800 (optional)"},"responses":{"200":"{ \"url\": \"string\", \"expiresIn\": 86400, \"objectName\": \"user/video/file.ext\" }","403":"{ \"error\": \"Access denied\" }","404":"{ \"error\": \"Object not found\" }"}},{"method":"GET","path":"/docs","summary":"List API documentation","responses":{"200":"{ \"name\": \"Fairplay API\", \"version\": \"x.x.x\", \"routes\": [ ... ] }"}},{"method":"GET","path":"/assets/users/:userId/avatar/:filename","summary":"Proxy user avatar image","description":"Backend proxy for user avatar images. Consumed by frontend; usually not called directly by users.","params":{"userId":"User ID (UUID)","filename":"Avatar filename (e.g., avatar.jpg)"}},{"method":"GET","path":"/assets/users/:userId/banner/:filename","summary":"Proxy user banner image","description":"Backend proxy for user banner images. Consumed by frontend; usually not called directly by users.","params":{"userId":"User ID (UUID)","filename":"Banner filename (e.g., banner.jpg)"}},{"method":"GET","path":"/assets/videos/:userId/:videoId/thumbnail/:filename","summary":"Proxy video thumbnail image","description":"Backend proxy for video thumbnail images. Consumed by frontend; usually not called directly by users.","params":{"userId":"User ID (UUID)","videoId":"Video ID (UUID)","filename":"Thumbnail filename (e.g., thumbnail.jpg)"}},{"method":"GET","path":"/moderator/videos","summary":"List videos for moderation","description":"Lists videos with advanced filters. Supports filtering by processingStatus, moderationStatus, visibility, owner, and title search. Supports pagination and sorting.","auth":true,"roles":["moderator","admin"],"query":{"processingStatus":"uploading|processing|failed|done (optional)","moderationStatus":"pending|approved|rejected (optional)","visibility":"public|unlisted|private (optional)","userId":"Filter by owner username or ID (optional)","search":"Case-insensitive substring in title (optional)","page":"Page number (default 1)","limit":"Items per page (default 20)","sort":"field:dir (default createdAt:desc)"},"responses":{"200":"{\n  \"videos\": [\n    {\n      \"id\": \"string\",\n      \"title\": \"string\",\n      \"user\": { \"id\": \"string\", \"username\": \"string\", \"displayName\": \"string|null\" },\n      \"thumbnailUrl\": \"string|null\",\n      \"processingStatus\": \"uploading|processing|failed|done\",\n      \"moderationStatus\": \"pending|approved|rejected\",\n      \"visibility\": \"public|unlisted|private\",\n      \"createdAt\": \"ISO8601\"\n    }\n  ],\n  \"pagination\": { \"page\": 1, \"limit\": 20, \"totalItems\": 123, \"totalPages\": 7, \"itemsReturned\": 20 }\n}"}},{"method":"PATCH","path":"/moderator/videos/:id/moderation","summary":"Approve or reject a video","description":"Update only moderationStatus to approved or rejected for a video.","auth":true,"roles":["moderator","admin"],"params":{"id":"Video public ID or legacy UUID"},"body":{"action":"'approve' | 'reject'"},"responses":{"200":"{\n  \"message\": \"Moderation updated\",\n  \"video\": {\n    \"id\": \"string\",\n    \"title\": \"string\",\n    \"moderationStatus\": \"approved|rejected\",\n    \"processingStatus\": \"uploading|processing|failed|done\"\n  }\n}","404":"{ \"error\": \"Video not found\" }"}}]}