OCaml bindings to the Peertube ActivityPub video sharing API

init

+2739
+2
.gitignore
··· 1 + _build 2 + peertube-src
+310
PLAN.md
··· 1 + # PeerTube OCaml API Coverage Plan 2 + 3 + ## Current Implementation 4 + 5 + **Implemented (5 endpoints):** 6 + - `GET /api/v1/video-channels/{channel}/videos` - List channel videos with pagination 7 + - `GET /api/v1/videos/{uuid}` - Get video details 8 + - Thumbnail download (static files) 9 + 10 + **Types:** 11 + - `video` - Basic video metadata 12 + - `video_response` - Paginated response wrapper 13 + 14 + ## Proposed Expansion 15 + 16 + ### Phase 1: Enhanced Video Discovery (Priority: High) 17 + 18 + Add types and functions for browsing and searching videos. 19 + 20 + #### New Types 21 + ```ocaml 22 + type sort_order = 23 + | Newest | Oldest | Views | Likes | Trending | Hot | Random 24 + 25 + type video_filter = { 26 + category_id : int option; 27 + licence_id : int option; 28 + language : string option; 29 + nsfw : bool option; 30 + is_local : bool option; 31 + has_hls : bool option; 32 + has_webtorrent : bool option; 33 + skip_count : bool option; (* skip total count for performance *) 34 + } 35 + 36 + type search_params = { 37 + search : string; 38 + start : int; 39 + count : int; 40 + sort : sort_order; 41 + search_target : [`Local | `Everywhere] option; 42 + duration_min : int option; 43 + duration_max : int option; 44 + published_after : Ptime.t option; 45 + published_before : Ptime.t option; 46 + } 47 + ``` 48 + 49 + #### New Endpoints 50 + | Function | Endpoint | Description | 51 + |----------|----------|-------------| 52 + | `list_videos` | `GET /api/v1/videos` | Browse all videos with filters | 53 + | `search_videos` | `GET /api/v1/search/videos` | Full-text search | 54 + | `search_channels` | `GET /api/v1/search/video-channels` | Search channels | 55 + | `get_categories` | `GET /api/v1/videos/categories` | Video category list | 56 + | `get_languages` | `GET /api/v1/videos/languages` | Available languages | 57 + | `get_licences` | `GET /api/v1/videos/licences` | License types | 58 + 59 + ### Phase 2: Channels & Accounts (Priority: High) 60 + 61 + #### New Types 62 + ```ocaml 63 + type channel = { 64 + id : int; 65 + name : string; 66 + display_name : string; 67 + description : string option; 68 + url : string; 69 + host : string; 70 + followers_count : int; 71 + following_count : int; 72 + created_at : Ptime.t; 73 + banner_path : string option; 74 + avatar_path : string option; 75 + owner_account : account_summary option; 76 + } 77 + 78 + type account_summary = { 79 + id : int; 80 + name : string; 81 + display_name : string; 82 + url : string; 83 + host : string; 84 + } 85 + 86 + type account = { 87 + id : int; 88 + name : string; 89 + display_name : string; 90 + description : string option; 91 + url : string; 92 + host : string; 93 + followers_count : int; 94 + following_count : int; 95 + created_at : Ptime.t; 96 + avatar_path : string option; 97 + } 98 + ``` 99 + 100 + #### New Endpoints 101 + | Function | Endpoint | Description | 102 + |----------|----------|-------------| 103 + | `list_channels` | `GET /api/v1/video-channels` | Browse all channels | 104 + | `get_channel` | `GET /api/v1/video-channels/{handle}` | Channel details | 105 + | `list_accounts` | `GET /api/v1/accounts` | Browse accounts | 106 + | `get_account` | `GET /api/v1/accounts/{handle}` | Account details | 107 + | `get_account_videos` | `GET /api/v1/accounts/{handle}/videos` | Videos by account | 108 + | `get_account_channels` | `GET /api/v1/accounts/{handle}/video-channels` | Account's channels | 109 + 110 + ### Phase 3: Playlists (Priority: Medium) 111 + 112 + #### New Types 113 + ```ocaml 114 + type playlist_privacy = Public | Unlisted | Private 115 + 116 + type playlist = { 117 + id : int; 118 + uuid : string; 119 + display_name : string; 120 + description : string option; 121 + privacy : playlist_privacy; 122 + url : string; 123 + videos_length : int; 124 + thumbnail_path : string option; 125 + created_at : Ptime.t; 126 + updated_at : Ptime.t; 127 + owner_account : account_summary; 128 + video_channel : channel option; 129 + } 130 + 131 + type playlist_element = { 132 + id : int; 133 + position : int; 134 + start_timestamp : int option; 135 + stop_timestamp : int option; 136 + video : video; 137 + } 138 + ``` 139 + 140 + #### New Endpoints 141 + | Function | Endpoint | Description | 142 + |----------|----------|-------------| 143 + | `list_playlists` | `GET /api/v1/video-playlists` | Browse playlists | 144 + | `get_playlist` | `GET /api/v1/video-playlists/{id}` | Playlist details | 145 + | `get_playlist_videos` | `GET /api/v1/video-playlists/{id}/videos` | Videos in playlist | 146 + | `get_account_playlists` | `GET /api/v1/accounts/{handle}/video-playlists` | Account's playlists | 147 + 148 + ### Phase 4: Server Information (Priority: Medium) 149 + 150 + #### New Types 151 + ```ocaml 152 + type server_config = { 153 + instance_name : string; 154 + instance_short_description : string; 155 + instance_description : string; 156 + instance_terms : string; 157 + instance_default_nsfw_policy : string; 158 + signup_allowed : bool; 159 + signup_allowed_for_current_ip : bool; 160 + signup_requires_email_verification : bool; 161 + transcoding_enabled : bool; 162 + contact_form_enabled : bool; 163 + } 164 + 165 + type server_stats = { 166 + total_users : int; 167 + total_daily_active_users : int; 168 + total_weekly_active_users : int; 169 + total_monthly_active_users : int; 170 + total_local_videos : int; 171 + total_local_video_views : int; 172 + total_local_video_comments : int; 173 + total_local_video_files_size : int64; 174 + total_videos : int; 175 + total_video_comments : int; 176 + total_local_video_channels : int; 177 + total_local_playlists : int; 178 + total_instance_followers : int; 179 + total_instance_following : int; 180 + } 181 + ``` 182 + 183 + #### New Endpoints 184 + | Function | Endpoint | Description | 185 + |----------|----------|-------------| 186 + | `get_config` | `GET /api/v1/config` | Server configuration | 187 + | `get_about` | `GET /api/v1/config/about` | Instance about page | 188 + | `get_stats` | `GET /api/v1/server/stats` | Server statistics | 189 + 190 + ### Phase 5: Authentication (Priority: Low for read-only clients) 191 + 192 + #### New Types 193 + ```ocaml 194 + type oauth_client = { 195 + client_id : string; 196 + client_secret : string; 197 + } 198 + 199 + type auth_token = { 200 + access_token : string; 201 + token_type : string; 202 + expires_in : int; 203 + refresh_token : string; 204 + } 205 + 206 + type authenticated_session = { 207 + session : Requests.t; 208 + token : auth_token; 209 + expires_at : Ptime.t; 210 + } 211 + ``` 212 + 213 + #### New Functions 214 + | Function | Endpoint | Description | 215 + |----------|----------|-------------| 216 + | `get_oauth_client` | `GET /api/v1/oauth-clients/local` | Get OAuth credentials | 217 + | `login` | `POST /api/v1/users/token` | Authenticate user | 218 + | `refresh_token` | `POST /api/v1/users/token` | Refresh access token | 219 + | `logout` | `POST /api/v1/users/revoke-token` | Revoke token | 220 + 221 + ### Phase 6: User Interactions (Priority: Low, requires auth) 222 + 223 + #### New Endpoints (require authentication) 224 + | Function | Endpoint | Description | 225 + |----------|----------|-------------| 226 + | `rate_video` | `POST /api/v1/videos/{id}/rate` | Like/dislike video | 227 + | `get_my_rating` | `GET /api/v1/videos/{id}/rating` | Get my rating | 228 + | `get_comments` | `GET /api/v1/videos/{id}/comments` | Video comments | 229 + | `post_comment` | `POST /api/v1/videos/{id}/comment-threads` | Add comment | 230 + | `subscribe` | `POST /api/v1/users/me/subscriptions` | Subscribe to channel | 231 + | `unsubscribe` | `DELETE /api/v1/users/me/subscriptions/{uri}` | Unsubscribe | 232 + | `get_subscriptions` | `GET /api/v1/users/me/subscriptions` | My subscriptions | 233 + | `get_subscription_videos` | `GET /api/v1/users/me/subscriptions/videos` | Subscription feed | 234 + | `get_notifications` | `GET /api/v1/users/me/notifications` | My notifications | 235 + | `get_watch_history` | `GET /api/v1/users/me/history/videos` | Watch history | 236 + 237 + ## Implementation Strategy 238 + 239 + ### Step 1: Extend Video Type 240 + Add missing fields to the existing `video` type that are commonly returned: 241 + - `duration` (int, seconds) 242 + - `views` (int) 243 + - `likes` (int) 244 + - `dislikes` (int) 245 + - `category` (category record) 246 + - `licence` (licence record) 247 + - `language` (language record) 248 + - `privacy` (privacy record) 249 + - `is_local` (bool) 250 + - `channel` (channel_summary) 251 + - `account` (account_summary) 252 + 253 + ### Step 2: Create Common Pagination Module 254 + ```ocaml 255 + module Pagination : sig 256 + type 'a response = { 257 + total : int; 258 + data : 'a list; 259 + } 260 + 261 + val fetch_all : 262 + ?page_size:int -> 263 + ?max_pages:int -> 264 + (start:int -> count:int -> 'a response) -> 265 + 'a list 266 + end 267 + ``` 268 + 269 + ### Step 3: Create Module Structure 270 + ``` 271 + lib/ 272 + peertube.ml - Main entry, re-exports submodules 273 + peertube.mli - Public interface 274 + video.ml - Video types and operations 275 + channel.ml - Channel types and operations 276 + account.ml - Account types and operations 277 + playlist.ml - Playlist types and operations 278 + search.ml - Search functionality 279 + server.ml - Server config/stats 280 + auth.ml - Authentication (optional) 281 + ``` 282 + 283 + ### Step 4: Add CLI Commands 284 + Extend `opeertube` with new subcommands: 285 + - `opeertube search <query>` - Search videos 286 + - `opeertube channels` - List/search channels 287 + - `opeertube channel <name>` - Channel details 288 + - `opeertube playlists` - List playlists 289 + - `opeertube playlist <id>` - Playlist videos 290 + - `opeertube server info` - Server configuration 291 + - `opeertube server stats` - Server statistics 292 + 293 + ## File Changes Summary 294 + 295 + | File | Changes | 296 + |------|---------| 297 + | `lib/dune` | No changes needed | 298 + | `lib/peertube.mli` | Add new types and functions | 299 + | `lib/peertube.ml` | Add implementations | 300 + | `bin/opeertube.ml` | Add new CLI commands | 301 + | `dune-project` | No changes needed | 302 + 303 + ## Testing Plan 304 + 305 + 1. Unit tests for JSON codec round-trips 306 + 2. Integration tests against public PeerTube instances: 307 + - https://framatube.org 308 + - https://peertube.social 309 + - https://video.ploud.fr 310 + 3. CLI smoke tests for each command
+201
TODO.md
··· 1 + # PeerTube OCaml Client - Remaining Features 2 + 3 + ## Completed 4 + 5 + - [x] Phase 1: Video Discovery (list, search, categories, languages, licences) 6 + - [x] Phase 2: Channels & Accounts (list, search, get details) 7 + - [x] Phase 3: Playlists (list, search, get details, videos) 8 + - [x] Phase 4: Server Information (config, stats) 9 + - [x] CLI with all read-only operations 10 + 11 + ## Phase 5: Authentication 12 + 13 + The Requests library will have OAuth support, which should simplify this phase. 14 + 15 + ### Types 16 + 17 + ```ocaml 18 + type oauth_client = { 19 + client_id : string; 20 + client_secret : string; 21 + } 22 + 23 + type auth_token = { 24 + access_token : string; 25 + token_type : string; 26 + expires_in : int; 27 + refresh_token : string; 28 + } 29 + ``` 30 + 31 + ### Endpoints 32 + 33 + - [ ] `get_oauth_client` - `GET /api/v1/oauth-clients/local` 34 + - Fetches the instance's OAuth client credentials 35 + - Required before user authentication 36 + 37 + - [ ] `login` - `POST /api/v1/users/token` 38 + - OAuth2 password grant flow 39 + - Parameters: username, password, client_id, client_secret 40 + - Returns access_token and refresh_token 41 + - Use Requests OAuth support for token management 42 + 43 + - [ ] `refresh_token` - `POST /api/v1/users/token` 44 + - OAuth2 refresh token grant 45 + - Automatically refresh expired tokens 46 + - Requests OAuth support should handle this 47 + 48 + - [ ] `logout` - `POST /api/v1/users/revoke-token` 49 + - Revoke the current access token 50 + 51 + ### Implementation Notes 52 + 53 + - Requests OAuth support will handle token storage and automatic refresh 54 + - Consider storing tokens in `~/.config/opeertube/` for CLI persistence 55 + - Add `--login` flag to CLI for interactive authentication 56 + 57 + ## Phase 6: User Interactions (requires auth) 58 + 59 + ### Video Interactions 60 + 61 + - [ ] `rate_video` - `POST /api/v1/videos/{id}/rate` 62 + - Body: `{ "rating": "like" | "dislike" | "none" }` 63 + 64 + - [ ] `get_my_rating` - `GET /api/v1/videos/{id}/rating` 65 + - Returns user's rating for a video 66 + 67 + ### Comments 68 + 69 + - [ ] `get_comments` - `GET /api/v1/videos/{id}/comment-threads` 70 + - Paginated list of top-level comments 71 + - Each comment has nested replies 72 + 73 + - [ ] `get_comment_thread` - `GET /api/v1/videos/{id}/comment-threads/{threadId}` 74 + - Full thread with all replies 75 + 76 + - [ ] `post_comment` - `POST /api/v1/videos/{id}/comment-threads` 77 + - Body: `{ "text": "comment content" }` 78 + 79 + - [ ] `reply_to_comment` - `POST /api/v1/videos/{id}/comments/{commentId}` 80 + - Body: `{ "text": "reply content" }` 81 + 82 + - [ ] `delete_comment` - `DELETE /api/v1/videos/{id}/comments/{commentId}` 83 + 84 + ### Subscriptions 85 + 86 + - [ ] `subscribe` - `POST /api/v1/users/me/subscriptions` 87 + - Body: `{ "uri": "channel@host" }` 88 + 89 + - [ ] `unsubscribe` - `DELETE /api/v1/users/me/subscriptions/{subscriptionHandle}` 90 + 91 + - [ ] `get_subscriptions` - `GET /api/v1/users/me/subscriptions` 92 + - Paginated list of subscribed channels 93 + 94 + - [ ] `get_subscription_videos` - `GET /api/v1/users/me/subscriptions/videos` 95 + - Subscription feed (videos from subscribed channels) 96 + 97 + - [ ] `subscription_exists` - `GET /api/v1/users/me/subscriptions/exist` 98 + - Check if subscribed to specific channels 99 + 100 + ### User Profile 101 + 102 + - [ ] `get_my_info` - `GET /api/v1/users/me` 103 + - Current user's account info 104 + 105 + - [ ] `update_my_info` - `PUT /api/v1/users/me` 106 + - Update display name, description, etc. 107 + 108 + - [ ] `get_my_videos` - `GET /api/v1/users/me/videos` 109 + - Videos uploaded by current user 110 + 111 + ### Watch History & Notifications 112 + 113 + - [ ] `get_watch_history` - `GET /api/v1/users/me/history/videos` 114 + - Paginated watch history 115 + 116 + - [ ] `delete_watch_history` - `POST /api/v1/users/me/history/videos/remove` 117 + - Clear watch history 118 + 119 + - [ ] `get_notifications` - `GET /api/v1/users/me/notifications` 120 + - User notifications 121 + 122 + - [ ] `mark_notifications_read` - `POST /api/v1/users/me/notifications/read` 123 + - Mark notifications as read 124 + 125 + - [ ] `mark_all_notifications_read` - `POST /api/v1/users/me/notifications/read-all` 126 + 127 + ## Phase 7: Content Upload (requires auth) 128 + 129 + ### Video Upload 130 + 131 + - [ ] `upload_video` - `POST /api/v1/videos/upload` 132 + - Multipart form upload 133 + - Required fields: channelId, name, videofile 134 + - Optional: description, tags, privacy, etc. 135 + 136 + - [ ] `upload_video_resumable_init` - `POST /api/v1/videos/upload-resumable` 137 + - Initialize resumable upload for large files 138 + 139 + - [ ] `upload_video_resumable` - `PUT /api/v1/videos/upload-resumable` 140 + - Continue resumable upload 141 + 142 + - [ ] `update_video` - `PUT /api/v1/videos/{id}` 143 + - Update video metadata 144 + 145 + - [ ] `delete_video` - `DELETE /api/v1/videos/{id}` 146 + 147 + ### Playlist Management 148 + 149 + - [ ] `create_playlist` - `POST /api/v1/video-playlists` 150 + - Create a new playlist 151 + 152 + - [ ] `update_playlist` - `PUT /api/v1/video-playlists/{playlistId}` 153 + 154 + - [ ] `delete_playlist` - `DELETE /api/v1/video-playlists/{playlistId}` 155 + 156 + - [ ] `add_video_to_playlist` - `POST /api/v1/video-playlists/{playlistId}/videos` 157 + - Body: `{ "videoId": id }` 158 + 159 + - [ ] `remove_video_from_playlist` - `DELETE /api/v1/video-playlists/{playlistId}/videos/{playlistElementId}` 160 + 161 + - [ ] `reorder_playlist` - `POST /api/v1/video-playlists/{playlistId}/videos/reorder` 162 + 163 + ## CLI Enhancements 164 + 165 + ### Authentication Commands 166 + 167 + - [ ] `opeertube login` - Interactive login, store token 168 + - [ ] `opeertube logout` - Revoke and delete stored token 169 + - [ ] `opeertube whoami` - Show current user info 170 + 171 + ### Authenticated Operations 172 + 173 + - [ ] `opeertube like VIDEO_UUID` - Like a video 174 + - [ ] `opeertube dislike VIDEO_UUID` - Dislike a video 175 + - [ ] `opeertube subscribe CHANNEL` - Subscribe to channel 176 + - [ ] `opeertube unsubscribe CHANNEL` - Unsubscribe 177 + - [ ] `opeertube subscriptions` - List subscriptions 178 + - [ ] `opeertube feed` - Show subscription feed 179 + - [ ] `opeertube history` - Show watch history 180 + - [ ] `opeertube notifications` - Show notifications 181 + 182 + ### Upload Commands 183 + 184 + - [ ] `opeertube upload FILE --channel CHANNEL --name NAME [options]` 185 + - [ ] `opeertube update VIDEO_UUID [options]` 186 + - [ ] `opeertube delete VIDEO_UUID` 187 + 188 + ## Code Quality 189 + 190 + - [ ] Add unit tests for JSON codec round-trips 191 + - [ ] Add integration tests against public instances 192 + - [ ] Add CI configuration (GitHub Actions) 193 + - [ ] Generate opam file for release 194 + - [ ] Add rate limiting / retry logic for API calls 195 + - [ ] Consider caching for frequently accessed data 196 + 197 + ## Documentation 198 + 199 + - [ ] Add examples to mli documentation 200 + - [ ] Create tutorial for common use cases 201 + - [ ] Document error handling patterns
+5
bin/dune
··· 1 + (executable 2 + (name opeertube) 3 + (public_name opeertube) 4 + (package peertube) 5 + (libraries peertube eio_main cmdliner logs logs.cli logs.fmt fmt.tty fmt.cli))
+570
bin/opeertube.ml
··· 1 + (** opeertube - PeerTube API command-line client *) 2 + 3 + open Cmdliner 4 + 5 + let setup_log style_renderer level = 6 + Fmt_tty.setup_std_outputs ?style_renderer (); 7 + Logs.set_level level; 8 + Logs.set_reporter (Logs_fmt.reporter ()) 9 + 10 + let setup_log_term = 11 + Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ()) 12 + 13 + let base_url_arg = 14 + let doc = "Base URL of the PeerTube instance (e.g., https://video.example.com)" in 15 + Arg.(required & opt (some string) None & info [ "u"; "url" ] ~docv:"URL" ~doc) 16 + 17 + let channel_arg = 18 + let doc = "Channel name" in 19 + Arg.(required & pos 0 (some string) None & info [] ~docv:"CHANNEL" ~doc) 20 + 21 + let handle_arg = 22 + let doc = "Account or channel handle" in 23 + Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc) 24 + 25 + let id_arg = 26 + let doc = "Playlist ID or UUID" in 27 + Arg.(required & pos 0 (some string) None & info [] ~docv:"ID" ~doc) 28 + 29 + let uuid_arg = 30 + let doc = "Video UUID" in 31 + Arg.(required & pos 0 (some string) None & info [] ~docv:"UUID" ~doc) 32 + 33 + let query_arg = 34 + let doc = "Search query" in 35 + Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" ~doc) 36 + 37 + let count_arg = 38 + let doc = "Number of results per page" in 39 + Arg.(value & opt int 20 & info [ "c"; "count" ] ~docv:"N" ~doc) 40 + 41 + let start_arg = 42 + let doc = "Starting offset for pagination" in 43 + Arg.(value & opt int 0 & info [ "s"; "start" ] ~docv:"N" ~doc) 44 + 45 + let max_pages_arg = 46 + let doc = "Maximum number of pages to fetch (unlimited if not specified)" in 47 + Arg.(value & opt (some int) None & info [ "m"; "max-pages" ] ~docv:"N" ~doc) 48 + 49 + let all_flag = 50 + let doc = "Fetch all results using automatic pagination" in 51 + Arg.(value & flag & info [ "a"; "all" ] ~doc) 52 + 53 + let json_flag = 54 + let doc = "Output as JSON" in 55 + Arg.(value & flag & info [ "j"; "json" ] ~doc) 56 + 57 + let output_arg = 58 + let doc = "Output file path for thumbnail" in 59 + Arg.(required & pos 1 (some string) None & info [] ~docv:"OUTPUT" ~doc) 60 + 61 + let handle_api_error f = 62 + try f () 63 + with Peertube.Api_error (status, msg) -> 64 + Fmt.epr "Error: %s (HTTP %d)@." msg status; 65 + exit 1 66 + 67 + (* Video printing *) 68 + 69 + let print_video ?(json = false) (v : Peertube.video) = 70 + if json then 71 + let json_str = Jsont_bytesrw.encode_string Peertube.video_jsont v in 72 + match json_str with 73 + | Ok s -> print_endline s 74 + | Error e -> Fmt.epr "JSON encode error: %s@." e 75 + else 76 + Fmt.pr "@[<v>%s@, UUID: %s@, ID: %d@, URL: %s@, Duration: %ds@, Views: %d@, Published: %a@, Tags: [%a]@]@." 77 + v.name v.uuid v.id v.url v.duration v.views 78 + (Ptime.pp_rfc3339 ()) v.published_at 79 + Fmt.(list ~sep:(any ", ") string) v.tags 80 + 81 + let print_videos ?(json = false) videos = 82 + if json then begin 83 + let codec = Jsont.(list Peertube.video_jsont) in 84 + let json_str = Jsont_bytesrw.encode_string codec videos in 85 + match json_str with 86 + | Ok s -> print_endline s 87 + | Error e -> Fmt.epr "JSON encode error: %s@." e 88 + end 89 + else 90 + List.iter (print_video ~json:false) videos 91 + 92 + (* Channel printing *) 93 + 94 + let print_channel_summary (c : Peertube.channel_summary) = 95 + Fmt.pr "@[<v>%s (@%s)@, ID: %d@, URL: %s@]@." 96 + c.channel_display_name c.channel_name c.channel_id c.channel_url 97 + 98 + let print_channel ?(json = false) (c : Peertube.channel) = 99 + if json then 100 + let json_str = Jsont_bytesrw.encode_string Peertube.channel_jsont c in 101 + match json_str with 102 + | Ok s -> print_endline s 103 + | Error e -> Fmt.epr "JSON encode error: %s@." e 104 + else begin 105 + Fmt.pr "@[<v>%s (@%s)@, ID: %d@, URL: %s@, Followers: %d@, Created: %a@]@." 106 + c.channel.channel_display_name c.channel.channel_name 107 + c.channel.channel_id c.channel.channel_url 108 + c.channel_followers_count 109 + (Ptime.pp_rfc3339 ()) c.channel_created_at; 110 + Option.iter (fun d -> Fmt.pr " Description: %s@." d) c.channel_description 111 + end 112 + 113 + let print_channels ?(json = false) channels = 114 + if json then begin 115 + let codec = Jsont.(list Peertube.channel_jsont) in 116 + let json_str = Jsont_bytesrw.encode_string codec channels in 117 + match json_str with 118 + | Ok s -> print_endline s 119 + | Error e -> Fmt.epr "JSON encode error: %s@." e 120 + end 121 + else 122 + List.iter (fun (c : Peertube.channel) -> print_channel_summary c.channel) channels 123 + 124 + (* Account printing *) 125 + 126 + let print_account_summary (a : Peertube.account_summary) = 127 + Fmt.pr "@[<v>%s (@%s)@, ID: %d@, URL: %s@]@." 128 + a.account_display_name a.account_name a.account_id a.account_url 129 + 130 + let print_account ?(json = false) (a : Peertube.account) = 131 + if json then 132 + let json_str = Jsont_bytesrw.encode_string Peertube.account_jsont a in 133 + match json_str with 134 + | Ok s -> print_endline s 135 + | Error e -> Fmt.epr "JSON encode error: %s@." e 136 + else begin 137 + Fmt.pr "@[<v>%s (@%s)@, ID: %d@, URL: %s@, Followers: %d@, Created: %a@]@." 138 + a.account.account_display_name a.account.account_name 139 + a.account.account_id a.account.account_url 140 + a.account_followers_count 141 + (Ptime.pp_rfc3339 ()) a.account_created_at; 142 + Option.iter (fun d -> Fmt.pr " Description: %s@." d) a.account_description 143 + end 144 + 145 + let print_accounts ?(json = false) accounts = 146 + if json then begin 147 + let codec = Jsont.(list Peertube.account_jsont) in 148 + let json_str = Jsont_bytesrw.encode_string codec accounts in 149 + match json_str with 150 + | Ok s -> print_endline s 151 + | Error e -> Fmt.epr "JSON encode error: %s@." e 152 + end 153 + else 154 + List.iter (fun (a : Peertube.account) -> print_account_summary a.account) accounts 155 + 156 + (* Playlist printing *) 157 + 158 + let print_playlist ?(json = false) (p : Peertube.playlist) = 159 + if json then 160 + let json_str = Jsont_bytesrw.encode_string Peertube.playlist_jsont p in 161 + match json_str with 162 + | Ok s -> print_endline s 163 + | Error e -> Fmt.epr "JSON encode error: %s@." e 164 + else 165 + Fmt.pr "@[<v>%s@, UUID: %s@, ID: %d@, Videos: %d@, URL: %s@, Created: %a@]@." 166 + p.playlist_display_name p.playlist_uuid p.playlist_id 167 + p.playlist_videos_length p.playlist_url 168 + (Ptime.pp_rfc3339 ()) p.playlist_created_at 169 + 170 + let print_playlists ?(json = false) playlists = 171 + if json then begin 172 + let codec = Jsont.(list Peertube.playlist_jsont) in 173 + let json_str = Jsont_bytesrw.encode_string codec playlists in 174 + match json_str with 175 + | Ok s -> print_endline s 176 + | Error e -> Fmt.epr "JSON encode error: %s@." e 177 + end 178 + else 179 + List.iter (print_playlist ~json:false) playlists 180 + 181 + (* Video commands *) 182 + 183 + let list_channel_videos () base_url channel count start all max_pages json = 184 + handle_api_error @@ fun () -> 185 + Eio_main.run @@ fun env -> 186 + Eio.Switch.run @@ fun sw -> 187 + let session = Requests.create ~sw env in 188 + if all then begin 189 + let videos = 190 + Peertube.fetch_all_channel_videos ?max_pages ~page_size:count ~session 191 + ~base_url ~channel () 192 + in 193 + Fmt.pr "Found %d videos@.@." (List.length videos); 194 + print_videos ~json videos 195 + end 196 + else begin 197 + let response = 198 + Peertube.fetch_channel_videos ~count ~start ~session ~base_url ~channel () 199 + in 200 + Fmt.pr "Showing %d of %d videos (offset %d)@.@." 201 + (List.length response.data) response.total start; 202 + print_videos ~json response.data 203 + end 204 + 205 + let list_channel_videos_cmd = 206 + let doc = "List videos from a PeerTube channel" in 207 + let info = Cmd.info "channel-videos" ~doc in 208 + Cmd.v info 209 + Term.( 210 + const list_channel_videos $ setup_log_term $ base_url_arg $ channel_arg 211 + $ count_arg $ start_arg $ all_flag $ max_pages_arg $ json_flag) 212 + 213 + let browse_videos () base_url count start json = 214 + handle_api_error @@ fun () -> 215 + Eio_main.run @@ fun env -> 216 + Eio.Switch.run @@ fun sw -> 217 + let session = Requests.create ~sw env in 218 + let response = 219 + Peertube.list_videos ~count ~start ~session ~base_url () 220 + in 221 + Fmt.pr "Showing %d of %d videos (offset %d)@.@." 222 + (List.length response.data) response.total start; 223 + print_videos ~json response.data 224 + 225 + let browse_videos_cmd = 226 + let doc = "Browse all videos on the instance" in 227 + let info = Cmd.info "browse" ~doc in 228 + Cmd.v info 229 + Term.( 230 + const browse_videos $ setup_log_term $ base_url_arg 231 + $ count_arg $ start_arg $ json_flag) 232 + 233 + let search_videos () base_url query count start json = 234 + handle_api_error @@ fun () -> 235 + Eio_main.run @@ fun env -> 236 + Eio.Switch.run @@ fun sw -> 237 + let session = Requests.create ~sw env in 238 + let response = 239 + Peertube.search_videos ~query ~count ~start ~session ~base_url () 240 + in 241 + Fmt.pr "Found %d results for '%s' (showing %d, offset %d)@.@." 242 + response.total query (List.length response.data) start; 243 + print_videos ~json response.data 244 + 245 + let search_videos_cmd = 246 + let doc = "Search for videos" in 247 + let info = Cmd.info "search" ~doc in 248 + Cmd.v info 249 + Term.( 250 + const search_videos $ setup_log_term $ base_url_arg $ query_arg 251 + $ count_arg $ start_arg $ json_flag) 252 + 253 + let get_video () base_url uuid json = 254 + handle_api_error @@ fun () -> 255 + Eio_main.run @@ fun env -> 256 + Eio.Switch.run @@ fun sw -> 257 + let session = Requests.create ~sw env in 258 + let video = Peertube.fetch_video_details ~session ~base_url ~uuid () in 259 + print_video ~json video 260 + 261 + let get_video_cmd = 262 + let doc = "Get details for a specific video by UUID" in 263 + let info = Cmd.info "get" ~doc in 264 + Cmd.v info 265 + Term.(const get_video $ setup_log_term $ base_url_arg $ uuid_arg $ json_flag) 266 + 267 + let download_thumbnail () base_url uuid output = 268 + handle_api_error @@ fun () -> 269 + Eio_main.run @@ fun env -> 270 + Eio.Switch.run @@ fun sw -> 271 + let session = Requests.create ~sw env in 272 + let video = Peertube.fetch_video_details ~session ~base_url ~uuid () in 273 + match Peertube.download_thumbnail ~session ~base_url ~video ~output_path:output () with 274 + | Ok () -> Fmt.pr "Thumbnail saved to %s@." output 275 + | Error (`Msg msg) -> Fmt.epr "Error: %s@." msg; exit 1 276 + 277 + let download_thumbnail_cmd = 278 + let doc = "Download a video's thumbnail" in 279 + let info = Cmd.info "thumbnail" ~doc in 280 + Cmd.v info 281 + Term.( 282 + const download_thumbnail $ setup_log_term $ base_url_arg $ uuid_arg 283 + $ output_arg) 284 + 285 + (* Channel commands *) 286 + 287 + let list_channels () base_url count start json = 288 + handle_api_error @@ fun () -> 289 + Eio_main.run @@ fun env -> 290 + Eio.Switch.run @@ fun sw -> 291 + let session = Requests.create ~sw env in 292 + let response = 293 + Peertube.list_channels ~count ~start ~session ~base_url () 294 + in 295 + Fmt.pr "Showing %d of %d channels (offset %d)@.@." 296 + (List.length response.data) response.total start; 297 + print_channels ~json response.data 298 + 299 + let list_channels_cmd = 300 + let doc = "List channels on the instance" in 301 + let info = Cmd.info "list" ~doc in 302 + Cmd.v info 303 + Term.( 304 + const list_channels $ setup_log_term $ base_url_arg 305 + $ count_arg $ start_arg $ json_flag) 306 + 307 + let search_channels () base_url query count start json = 308 + handle_api_error @@ fun () -> 309 + Eio_main.run @@ fun env -> 310 + Eio.Switch.run @@ fun sw -> 311 + let session = Requests.create ~sw env in 312 + let response = 313 + Peertube.search_channels ~query ~count ~start ~session ~base_url () 314 + in 315 + Fmt.pr "Found %d channels for '%s' (showing %d, offset %d)@.@." 316 + response.total query (List.length response.data) start; 317 + print_channels ~json response.data 318 + 319 + let search_channels_cmd = 320 + let doc = "Search for channels" in 321 + let info = Cmd.info "search" ~doc in 322 + Cmd.v info 323 + Term.( 324 + const search_channels $ setup_log_term $ base_url_arg $ query_arg 325 + $ count_arg $ start_arg $ json_flag) 326 + 327 + let get_channel () base_url handle json = 328 + handle_api_error @@ fun () -> 329 + Eio_main.run @@ fun env -> 330 + Eio.Switch.run @@ fun sw -> 331 + let session = Requests.create ~sw env in 332 + let channel = Peertube.get_channel ~session ~base_url ~handle () in 333 + print_channel ~json channel 334 + 335 + let get_channel_cmd = 336 + let doc = "Get details for a specific channel" in 337 + let info = Cmd.info "get" ~doc in 338 + Cmd.v info 339 + Term.(const get_channel $ setup_log_term $ base_url_arg $ handle_arg $ json_flag) 340 + 341 + let channels_cmd = 342 + let doc = "Channel operations" in 343 + let info = Cmd.info "channels" ~doc in 344 + Cmd.group info [ list_channels_cmd; search_channels_cmd; get_channel_cmd ] 345 + 346 + (* Account commands *) 347 + 348 + let list_accounts () base_url count start json = 349 + handle_api_error @@ fun () -> 350 + Eio_main.run @@ fun env -> 351 + Eio.Switch.run @@ fun sw -> 352 + let session = Requests.create ~sw env in 353 + let response = 354 + Peertube.list_accounts ~count ~start ~session ~base_url () 355 + in 356 + Fmt.pr "Showing %d of %d accounts (offset %d)@.@." 357 + (List.length response.data) response.total start; 358 + print_accounts ~json response.data 359 + 360 + let list_accounts_cmd = 361 + let doc = "List accounts on the instance" in 362 + let info = Cmd.info "list" ~doc in 363 + Cmd.v info 364 + Term.( 365 + const list_accounts $ setup_log_term $ base_url_arg 366 + $ count_arg $ start_arg $ json_flag) 367 + 368 + let get_account () base_url handle json = 369 + handle_api_error @@ fun () -> 370 + Eio_main.run @@ fun env -> 371 + Eio.Switch.run @@ fun sw -> 372 + let session = Requests.create ~sw env in 373 + let account = Peertube.get_account ~session ~base_url ~handle () in 374 + print_account ~json account 375 + 376 + let get_account_cmd = 377 + let doc = "Get details for a specific account" in 378 + let info = Cmd.info "get" ~doc in 379 + Cmd.v info 380 + Term.(const get_account $ setup_log_term $ base_url_arg $ handle_arg $ json_flag) 381 + 382 + let get_account_videos () base_url handle count start json = 383 + handle_api_error @@ fun () -> 384 + Eio_main.run @@ fun env -> 385 + Eio.Switch.run @@ fun sw -> 386 + let session = Requests.create ~sw env in 387 + let response = 388 + Peertube.get_account_videos ~count ~start ~session ~base_url ~handle () 389 + in 390 + Fmt.pr "Showing %d of %d videos (offset %d)@.@." 391 + (List.length response.data) response.total start; 392 + print_videos ~json response.data 393 + 394 + let get_account_videos_cmd = 395 + let doc = "Get videos from an account" in 396 + let info = Cmd.info "videos" ~doc in 397 + Cmd.v info 398 + Term.( 399 + const get_account_videos $ setup_log_term $ base_url_arg $ handle_arg 400 + $ count_arg $ start_arg $ json_flag) 401 + 402 + let accounts_cmd = 403 + let doc = "Account operations" in 404 + let info = Cmd.info "accounts" ~doc in 405 + Cmd.group info [ list_accounts_cmd; get_account_cmd; get_account_videos_cmd ] 406 + 407 + (* Playlist commands *) 408 + 409 + let list_playlists () base_url count start json = 410 + handle_api_error @@ fun () -> 411 + Eio_main.run @@ fun env -> 412 + Eio.Switch.run @@ fun sw -> 413 + let session = Requests.create ~sw env in 414 + let response = 415 + Peertube.list_playlists ~count ~start ~session ~base_url () 416 + in 417 + Fmt.pr "Showing %d of %d playlists (offset %d)@.@." 418 + (List.length response.data) response.total start; 419 + print_playlists ~json response.data 420 + 421 + let list_playlists_cmd = 422 + let doc = "List playlists on the instance" in 423 + let info = Cmd.info "list" ~doc in 424 + Cmd.v info 425 + Term.( 426 + const list_playlists $ setup_log_term $ base_url_arg 427 + $ count_arg $ start_arg $ json_flag) 428 + 429 + let search_playlists () base_url query count start json = 430 + handle_api_error @@ fun () -> 431 + Eio_main.run @@ fun env -> 432 + Eio.Switch.run @@ fun sw -> 433 + let session = Requests.create ~sw env in 434 + let response = 435 + Peertube.search_playlists ~query ~count ~start ~session ~base_url () 436 + in 437 + Fmt.pr "Found %d playlists for '%s' (showing %d, offset %d)@.@." 438 + response.total query (List.length response.data) start; 439 + print_playlists ~json response.data 440 + 441 + let search_playlists_cmd = 442 + let doc = "Search for playlists" in 443 + let info = Cmd.info "search" ~doc in 444 + Cmd.v info 445 + Term.( 446 + const search_playlists $ setup_log_term $ base_url_arg $ query_arg 447 + $ count_arg $ start_arg $ json_flag) 448 + 449 + let get_playlist () base_url id json = 450 + handle_api_error @@ fun () -> 451 + Eio_main.run @@ fun env -> 452 + Eio.Switch.run @@ fun sw -> 453 + let session = Requests.create ~sw env in 454 + let playlist = Peertube.get_playlist ~session ~base_url ~id () in 455 + print_playlist ~json playlist 456 + 457 + let get_playlist_cmd = 458 + let doc = "Get details for a specific playlist" in 459 + let info = Cmd.info "get" ~doc in 460 + Cmd.v info 461 + Term.(const get_playlist $ setup_log_term $ base_url_arg $ id_arg $ json_flag) 462 + 463 + let get_playlist_videos () base_url id count start json = 464 + handle_api_error @@ fun () -> 465 + Eio_main.run @@ fun env -> 466 + Eio.Switch.run @@ fun sw -> 467 + let session = Requests.create ~sw env in 468 + let response = 469 + Peertube.get_playlist_videos ~count ~start ~session ~base_url ~id () 470 + in 471 + Fmt.pr "Showing %d of %d playlist elements (offset %d)@.@." 472 + (List.length response.data) response.total start; 473 + List.iter 474 + (fun (e : Peertube.playlist_element) -> 475 + match e.element_video with 476 + | Some v -> print_video ~json v 477 + | None -> Fmt.pr "[Deleted video at position %d]@." e.element_position) 478 + response.data 479 + 480 + let get_playlist_videos_cmd = 481 + let doc = "Get videos in a playlist" in 482 + let info = Cmd.info "videos" ~doc in 483 + Cmd.v info 484 + Term.( 485 + const get_playlist_videos $ setup_log_term $ base_url_arg $ id_arg 486 + $ count_arg $ start_arg $ json_flag) 487 + 488 + let playlists_cmd = 489 + let doc = "Playlist operations" in 490 + let info = Cmd.info "playlists" ~doc in 491 + Cmd.group info 492 + [ list_playlists_cmd; search_playlists_cmd; get_playlist_cmd; 493 + get_playlist_videos_cmd ] 494 + 495 + (* Server commands *) 496 + 497 + let server_info () base_url json = 498 + handle_api_error @@ fun () -> 499 + Eio_main.run @@ fun env -> 500 + Eio.Switch.run @@ fun sw -> 501 + let session = Requests.create ~sw env in 502 + let config = Peertube.get_config ~session ~base_url () in 503 + if json then 504 + let json_str = Jsont_bytesrw.encode_string Peertube.server_config_jsont config in 505 + match json_str with 506 + | Ok s -> print_endline s 507 + | Error e -> Fmt.epr "JSON encode error: %s@." e 508 + else begin 509 + Fmt.pr "@[<v>Instance: %s@, %s@, Version: %s@, Signup allowed: %b@, Transcoding: %b@]@." 510 + config.config_instance.instance_name 511 + config.config_instance.instance_short_description 512 + config.config_server_version 513 + config.config_signup_allowed 514 + config.config_transcoding_enabled 515 + end 516 + 517 + let server_info_cmd = 518 + let doc = "Get server configuration" in 519 + let info = Cmd.info "info" ~doc in 520 + Cmd.v info 521 + Term.(const server_info $ setup_log_term $ base_url_arg $ json_flag) 522 + 523 + let server_stats () base_url json = 524 + handle_api_error @@ fun () -> 525 + Eio_main.run @@ fun env -> 526 + Eio.Switch.run @@ fun sw -> 527 + let session = Requests.create ~sw env in 528 + let stats = Peertube.get_stats ~session ~base_url () in 529 + if json then 530 + let json_str = Jsont_bytesrw.encode_string Peertube.server_stats_jsont stats in 531 + match json_str with 532 + | Ok s -> print_endline s 533 + | Error e -> Fmt.epr "JSON encode error: %s@." e 534 + else begin 535 + Fmt.pr "@[<v>Users: %d (daily active: %d, weekly: %d, monthly: %d)@,Local videos: %d (views: %d)@,Total videos: %d@,Local channels: %d@,Local playlists: %d@,Instance followers: %d / following: %d@]@." 536 + stats.stats_total_users 537 + stats.stats_total_daily_active_users 538 + stats.stats_total_weekly_active_users 539 + stats.stats_total_monthly_active_users 540 + stats.stats_total_local_videos 541 + stats.stats_total_local_video_views 542 + stats.stats_total_videos 543 + stats.stats_total_local_video_channels 544 + stats.stats_total_local_playlists 545 + stats.stats_total_instance_followers 546 + stats.stats_total_instance_following 547 + end 548 + 549 + let server_stats_cmd = 550 + let doc = "Get server statistics" in 551 + let info = Cmd.info "stats" ~doc in 552 + Cmd.v info 553 + Term.(const server_stats $ setup_log_term $ base_url_arg $ json_flag) 554 + 555 + let server_cmd = 556 + let doc = "Server information" in 557 + let info = Cmd.info "server" ~doc in 558 + Cmd.group info [ server_info_cmd; server_stats_cmd ] 559 + 560 + (* Main command *) 561 + 562 + let main_cmd = 563 + let doc = "PeerTube API command-line client" in 564 + let info = Cmd.info "opeertube" ~version:"0.1.0" ~doc in 565 + Cmd.group info 566 + [ browse_videos_cmd; search_videos_cmd; get_video_cmd; 567 + list_channel_videos_cmd; download_thumbnail_cmd; 568 + channels_cmd; accounts_cmd; playlists_cmd; server_cmd ] 569 + 570 + let () = exit (Cmd.eval main_cmd)
+24
dune-project
··· 1 + (lang dune 3.16) 2 + (name peertube) 3 + 4 + (generate_opam_files true) 5 + 6 + (source (github avsm/ocaml-peertube)) 7 + (license ISC) 8 + (authors "Anil Madhavapeddy") 9 + (maintainers "anil@recoil.org") 10 + 11 + (package 12 + (name peertube) 13 + (synopsis "PeerTube API client for OCaml using Eio") 14 + (description "An OCaml client library for the PeerTube video platform API, built on Eio for effect-based I/O") 15 + (depends 16 + (ocaml (>= 5.1.0)) 17 + (eio (>= 1.0)) 18 + (eio_main (>= 1.0)) 19 + (requests (>= 0.1)) 20 + (jsont (>= 0.1)) 21 + (ptime (>= 1.0)) 22 + (fmt (>= 0.9)) 23 + (logs (>= 0.7)) 24 + (cmdliner (>= 1.2))))
+4
lib/dune
··· 1 + (library 2 + (name peertube) 3 + (public_name peertube) 4 + (libraries requests jsont jsont.bytesrw ptime fmt logs))
+1108
lib/peertube.ml
··· 1 + (** PeerTube API client implementation using Eio, Requests, and jsont. *) 2 + 3 + let log_src = Logs.Src.create "peertube" ~doc:"PeerTube API client" 4 + 5 + module Log = (val Logs.src_log log_src : Logs.LOG) 6 + 7 + exception Api_error of int * string 8 + 9 + (* Error handling *) 10 + 11 + let error_response_jsont : string Jsont.t = 12 + let make _type error _status detail = 13 + match detail with 14 + | Some d -> d 15 + | None -> Option.value ~default:"Unknown error" error 16 + in 17 + Jsont.Object.map ~kind:"error_response" make 18 + |> Jsont.Object.mem "type" Jsont.string ~dec_absent:"" ~enc:(Fun.const "") 19 + |> Jsont.Object.mem "error" (Jsont.option Jsont.string) 20 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(Fun.const None) 21 + |> Jsont.Object.mem "status" Jsont.int ~dec_absent:0 ~enc:(Fun.const 0) 22 + |> Jsont.Object.mem "detail" (Jsont.option Jsont.string) 23 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:Option.some 24 + |> Jsont.Object.skip_unknown 25 + |> Jsont.Object.finish 26 + 27 + let raise_api_error status body = 28 + let msg = 29 + match Jsont_bytesrw.decode_string error_response_jsont body with 30 + | Ok msg -> msg 31 + | Error _ -> Printf.sprintf "HTTP %d" status 32 + in 33 + Log.err (fun m -> m "API error %d: %s" status msg); 34 + raise (Api_error (status, msg)) 35 + 36 + (* Common types *) 37 + 38 + type 'a labeled = { id : 'a; label : string } 39 + 40 + type privacy = Public | Unlisted | Private | Internal 41 + 42 + type video_sort = Newest | Oldest | Views | Likes | Trending | Hot | Random | Best 43 + 44 + let string_of_video_sort = function 45 + | Newest -> "-publishedAt" 46 + | Oldest -> "publishedAt" 47 + | Views -> "-views" 48 + | Likes -> "-likes" 49 + | Trending -> "-trending" 50 + | Hot -> "-hot" 51 + | Random -> "random" 52 + | Best -> "-best" 53 + 54 + (* Account types *) 55 + 56 + type account_summary = { 57 + account_id : int; 58 + account_name : string; 59 + account_display_name : string; 60 + account_url : string; 61 + account_host : string; 62 + account_avatar_path : string option; 63 + } 64 + 65 + type account = { 66 + account : account_summary; 67 + account_description : string option; 68 + account_created_at : Ptime.t; 69 + account_followers_count : int; 70 + account_following_count : int; 71 + account_following_hosts_count : int option; 72 + } 73 + 74 + (* Channel types *) 75 + 76 + type channel_summary = { 77 + channel_id : int; 78 + channel_name : string; 79 + channel_display_name : string; 80 + channel_url : string; 81 + channel_host : string; 82 + channel_avatar_path : string option; 83 + } 84 + 85 + type channel = { 86 + channel : channel_summary; 87 + channel_description : string option; 88 + channel_support : string option; 89 + channel_created_at : Ptime.t; 90 + channel_followers_count : int; 91 + channel_following_count : int; 92 + channel_banner_path : string option; 93 + channel_owner_account : account_summary option; 94 + } 95 + 96 + (* Video types *) 97 + 98 + type video = { 99 + id : int; 100 + uuid : string; 101 + short_uuid : string option; 102 + name : string; 103 + description : string option; 104 + url : string; 105 + embed_path : string; 106 + published_at : Ptime.t; 107 + originally_published_at : Ptime.t option; 108 + updated_at : Ptime.t option; 109 + thumbnail_path : string option; 110 + preview_path : string option; 111 + tags : string list; 112 + duration : int; 113 + views : int; 114 + likes : int; 115 + dislikes : int; 116 + is_local : bool; 117 + is_live : bool; 118 + privacy : privacy; 119 + category : int labeled option; 120 + licence : int labeled option; 121 + language : string labeled option; 122 + channel : channel_summary option; 123 + account : account_summary option; 124 + } 125 + 126 + type 'a paginated = { total : int; data : 'a list } 127 + 128 + (* Playlist types *) 129 + 130 + type playlist_privacy = PlaylistPublic | PlaylistUnlisted | PlaylistPrivate 131 + 132 + type playlist_type = Regular | WatchLater 133 + 134 + type playlist = { 135 + playlist_id : int; 136 + playlist_uuid : string; 137 + playlist_short_uuid : string option; 138 + playlist_display_name : string; 139 + playlist_description : string option; 140 + playlist_privacy : playlist_privacy; 141 + playlist_url : string; 142 + playlist_thumbnail_path : string option; 143 + playlist_videos_length : int; 144 + playlist_type : playlist_type; 145 + playlist_created_at : Ptime.t; 146 + playlist_updated_at : Ptime.t; 147 + playlist_owner_account : account_summary option; 148 + playlist_video_channel : channel_summary option; 149 + } 150 + 151 + type playlist_element = { 152 + element_id : int; 153 + element_position : int; 154 + element_start_timestamp : int option; 155 + element_stop_timestamp : int option; 156 + element_video : video option; 157 + } 158 + 159 + (* Server types *) 160 + 161 + type instance_info = { 162 + instance_name : string; 163 + instance_short_description : string; 164 + instance_description : string option; 165 + instance_terms : string option; 166 + instance_is_nsfw : bool; 167 + instance_default_nsfw_policy : string; 168 + instance_default_client_route : string; 169 + } 170 + 171 + type server_config = { 172 + config_instance : instance_info; 173 + config_server_version : string; 174 + config_server_commit : string option; 175 + config_signup_allowed : bool; 176 + config_signup_allowed_for_current_ip : bool; 177 + config_signup_requires_email_verification : bool; 178 + config_transcoding_enabled : bool; 179 + config_contact_form_enabled : bool; 180 + } 181 + 182 + type server_stats = { 183 + stats_total_users : int; 184 + stats_total_daily_active_users : int; 185 + stats_total_weekly_active_users : int; 186 + stats_total_monthly_active_users : int; 187 + stats_total_local_videos : int; 188 + stats_total_local_video_views : int; 189 + stats_total_local_video_comments : int; 190 + stats_total_local_video_files_size : int64; 191 + stats_total_videos : int; 192 + stats_total_video_comments : int; 193 + stats_total_local_video_channels : int; 194 + stats_total_local_playlists : int; 195 + stats_total_instance_followers : int; 196 + stats_total_instance_following : int; 197 + } 198 + 199 + (* JSON codecs - helpers *) 200 + 201 + let parse_date str = 202 + match Ptime.of_rfc3339 str with 203 + | Ok (date, _, _) -> Ok date 204 + | Error _ -> 205 + Log.warn (fun m -> m "Failed to parse RFC3339 date: %s" str); 206 + Error (Fmt.str "Invalid RFC3339 date: %s" str) 207 + 208 + let ptime_jsont : Ptime.t Jsont.t = 209 + Jsont.of_of_string ~kind:"ptime" ~enc:Ptime.to_rfc3339 parse_date 210 + 211 + let privacy_jsont : privacy Jsont.t = 212 + let privacy_of_int = function 213 + | 1 -> Public 214 + | 2 -> Unlisted 215 + | 3 -> Private 216 + | 4 -> Internal 217 + | _ -> Public 218 + in 219 + let int_of_privacy = function 220 + | Public -> 1 221 + | Unlisted -> 2 222 + | Private -> 3 223 + | Internal -> 4 224 + in 225 + let make id _label = privacy_of_int id in 226 + Jsont.Object.map ~kind:"privacy" make 227 + |> Jsont.Object.mem "id" Jsont.int ~enc:int_of_privacy 228 + |> Jsont.Object.mem "label" Jsont.string ~dec_absent:"" ~enc:(Fun.const "") 229 + |> Jsont.Object.skip_unknown 230 + |> Jsont.Object.finish 231 + 232 + let playlist_privacy_jsont : playlist_privacy Jsont.t = 233 + let privacy_of_int = function 234 + | 1 -> PlaylistPublic 235 + | 2 -> PlaylistUnlisted 236 + | 3 -> PlaylistPrivate 237 + | _ -> PlaylistPublic 238 + in 239 + let int_of_privacy = function 240 + | PlaylistPublic -> 1 241 + | PlaylistUnlisted -> 2 242 + | PlaylistPrivate -> 3 243 + in 244 + let make id _label = privacy_of_int id in 245 + Jsont.Object.map ~kind:"playlist_privacy" make 246 + |> Jsont.Object.mem "id" Jsont.int ~enc:int_of_privacy 247 + |> Jsont.Object.mem "label" Jsont.string ~dec_absent:"" ~enc:(Fun.const "") 248 + |> Jsont.Object.skip_unknown 249 + |> Jsont.Object.finish 250 + 251 + let playlist_type_jsont : playlist_type Jsont.t = 252 + let type_of_int = function 1 -> Regular | 2 -> WatchLater | _ -> Regular in 253 + let int_of_type = function Regular -> 1 | WatchLater -> 2 in 254 + let make id _label = type_of_int id in 255 + Jsont.Object.map ~kind:"playlist_type" make 256 + |> Jsont.Object.mem "id" Jsont.int ~enc:int_of_type 257 + |> Jsont.Object.mem "label" Jsont.string ~dec_absent:"" ~enc:(Fun.const "") 258 + |> Jsont.Object.skip_unknown 259 + |> Jsont.Object.finish 260 + 261 + let int_labeled_jsont : int labeled Jsont.t = 262 + let make id label : int labeled = { id; label } in 263 + Jsont.Object.map ~kind:"int_labeled" make 264 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun (l : int labeled) -> l.id) 265 + |> Jsont.Object.mem "label" Jsont.string ~enc:(fun (l : int labeled) -> l.label) 266 + |> Jsont.Object.skip_unknown 267 + |> Jsont.Object.finish 268 + 269 + let string_labeled_jsont : string labeled Jsont.t = 270 + let make id label : string labeled = { id; label } in 271 + Jsont.Object.map ~kind:"string_labeled" make 272 + |> Jsont.Object.mem "id" Jsont.string ~enc:(fun (l : string labeled) -> l.id) 273 + |> Jsont.Object.mem "label" Jsont.string ~enc:(fun (l : string labeled) -> l.label) 274 + |> Jsont.Object.skip_unknown 275 + |> Jsont.Object.finish 276 + 277 + (* Extract avatar path from nested avatars array *) 278 + let avatar_path_jsont : string option Jsont.t = 279 + let avatar_jsont = 280 + let make path _width = path in 281 + Jsont.Object.map ~kind:"avatar" make 282 + |> Jsont.Object.mem "path" Jsont.string ~enc:Fun.id 283 + |> Jsont.Object.mem "width" Jsont.int ~dec_absent:0 ~enc:(Fun.const 0) 284 + |> Jsont.Object.skip_unknown 285 + |> Jsont.Object.finish 286 + in 287 + Jsont.map ~kind:"avatar_path" 288 + ~dec:(function [] -> None | x :: _ -> Some x) 289 + ~enc:(function None -> [] | Some p -> [ p ]) 290 + (Jsont.list avatar_jsont) 291 + 292 + (* Account codecs *) 293 + 294 + let account_summary_jsont : account_summary Jsont.t = 295 + let make account_id account_name account_display_name account_url account_host 296 + account_avatar_path = 297 + { account_id; account_name; account_display_name; account_url; account_host; 298 + account_avatar_path } 299 + in 300 + Jsont.Object.map ~kind:"account_summary" make 301 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun a -> a.account_id) 302 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun a -> a.account_name) 303 + |> Jsont.Object.mem "displayName" Jsont.string 304 + ~enc:(fun a -> a.account_display_name) 305 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun a -> a.account_url) 306 + |> Jsont.Object.mem "host" Jsont.string ~enc:(fun a -> a.account_host) 307 + |> Jsont.Object.mem "avatars" avatar_path_jsont 308 + ~dec_absent:None ~enc_omit:Option.is_none 309 + ~enc:(fun a -> a.account_avatar_path) 310 + |> Jsont.Object.skip_unknown 311 + |> Jsont.Object.finish 312 + 313 + let account_jsont : account Jsont.t = 314 + let make account_id account_name account_display_name account_url account_host 315 + account_avatar_path account_description account_created_at 316 + account_followers_count account_following_count 317 + account_following_hosts_count = 318 + let account = 319 + { account_id; account_name; account_display_name; account_url; 320 + account_host; account_avatar_path } 321 + in 322 + { account; account_description; account_created_at; account_followers_count; 323 + account_following_count; account_following_hosts_count } 324 + in 325 + Jsont.Object.map ~kind:"account" make 326 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun (a : account) -> a.account.account_id) 327 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun (a : account) -> a.account.account_name) 328 + |> Jsont.Object.mem "displayName" Jsont.string 329 + ~enc:(fun (a : account) -> a.account.account_display_name) 330 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun (a : account) -> a.account.account_url) 331 + |> Jsont.Object.mem "host" Jsont.string ~enc:(fun (a : account) -> a.account.account_host) 332 + |> Jsont.Object.mem "avatars" avatar_path_jsont 333 + ~dec_absent:None ~enc_omit:Option.is_none 334 + ~enc:(fun (a : account) -> a.account.account_avatar_path) 335 + |> Jsont.Object.mem "description" (Jsont.option Jsont.string) 336 + ~dec_absent:None ~enc_omit:Option.is_none 337 + ~enc:(fun a -> a.account_description) 338 + |> Jsont.Object.mem "createdAt" ptime_jsont 339 + ~enc:(fun a -> a.account_created_at) 340 + |> Jsont.Object.mem "followersCount" Jsont.int 341 + ~dec_absent:0 ~enc:(fun a -> a.account_followers_count) 342 + |> Jsont.Object.mem "followingCount" Jsont.int 343 + ~dec_absent:0 ~enc:(fun a -> a.account_following_count) 344 + |> Jsont.Object.mem "hostRedundancyAllowed" (Jsont.option Jsont.int) 345 + ~dec_absent:None ~enc_omit:Option.is_none 346 + ~enc:(fun a -> a.account_following_hosts_count) 347 + |> Jsont.Object.skip_unknown 348 + |> Jsont.Object.finish 349 + 350 + let account_paginated_jsont : account paginated Jsont.t = 351 + let make total data = { total; data } in 352 + Jsont.Object.map ~kind:"account_paginated" make 353 + |> Jsont.Object.mem "total" Jsont.int ~enc:(fun r -> r.total) 354 + |> Jsont.Object.mem "data" (Jsont.list account_jsont) ~enc:(fun r -> r.data) 355 + |> Jsont.Object.skip_unknown 356 + |> Jsont.Object.finish 357 + 358 + (* Channel codecs *) 359 + 360 + let channel_summary_jsont : channel_summary Jsont.t = 361 + let make channel_id channel_name channel_display_name channel_url channel_host 362 + channel_avatar_path = 363 + { channel_id; channel_name; channel_display_name; channel_url; channel_host; 364 + channel_avatar_path } 365 + in 366 + Jsont.Object.map ~kind:"channel_summary" make 367 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun c -> c.channel_id) 368 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun c -> c.channel_name) 369 + |> Jsont.Object.mem "displayName" Jsont.string 370 + ~enc:(fun c -> c.channel_display_name) 371 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun c -> c.channel_url) 372 + |> Jsont.Object.mem "host" Jsont.string ~enc:(fun c -> c.channel_host) 373 + |> Jsont.Object.mem "avatars" avatar_path_jsont 374 + ~dec_absent:None ~enc_omit:Option.is_none 375 + ~enc:(fun c -> c.channel_avatar_path) 376 + |> Jsont.Object.skip_unknown 377 + |> Jsont.Object.finish 378 + 379 + let channel_jsont : channel Jsont.t = 380 + let make channel_id channel_name channel_display_name channel_url channel_host 381 + channel_avatar_path channel_description channel_support channel_created_at 382 + channel_followers_count channel_following_count channel_banner_path 383 + channel_owner_account = 384 + let channel = 385 + { channel_id; channel_name; channel_display_name; channel_url; 386 + channel_host; channel_avatar_path } 387 + in 388 + { channel; channel_description; channel_support; channel_created_at; 389 + channel_followers_count; channel_following_count; channel_banner_path; 390 + channel_owner_account } 391 + in 392 + Jsont.Object.map ~kind:"channel" make 393 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun (c : channel) -> c.channel.channel_id) 394 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun (c : channel) -> c.channel.channel_name) 395 + |> Jsont.Object.mem "displayName" Jsont.string 396 + ~enc:(fun (c : channel) -> c.channel.channel_display_name) 397 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun (c : channel) -> c.channel.channel_url) 398 + |> Jsont.Object.mem "host" Jsont.string ~enc:(fun (c : channel) -> c.channel.channel_host) 399 + |> Jsont.Object.mem "avatars" avatar_path_jsont 400 + ~dec_absent:None ~enc_omit:Option.is_none 401 + ~enc:(fun (c : channel) -> c.channel.channel_avatar_path) 402 + |> Jsont.Object.mem "description" (Jsont.option Jsont.string) 403 + ~dec_absent:None ~enc_omit:Option.is_none 404 + ~enc:(fun c -> c.channel_description) 405 + |> Jsont.Object.mem "support" (Jsont.option Jsont.string) 406 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun c -> c.channel_support) 407 + |> Jsont.Object.mem "createdAt" ptime_jsont 408 + ~enc:(fun c -> c.channel_created_at) 409 + |> Jsont.Object.mem "followersCount" Jsont.int 410 + ~dec_absent:0 ~enc:(fun c -> c.channel_followers_count) 411 + |> Jsont.Object.mem "followingCount" Jsont.int 412 + ~dec_absent:0 ~enc:(fun c -> c.channel_following_count) 413 + |> Jsont.Object.mem "banners" avatar_path_jsont 414 + ~dec_absent:None ~enc_omit:Option.is_none 415 + ~enc:(fun c -> c.channel_banner_path) 416 + |> Jsont.Object.mem "ownerAccount" (Jsont.option account_summary_jsont) 417 + ~dec_absent:None ~enc_omit:Option.is_none 418 + ~enc:(fun c -> c.channel_owner_account) 419 + |> Jsont.Object.skip_unknown 420 + |> Jsont.Object.finish 421 + 422 + let channel_paginated_jsont : channel paginated Jsont.t = 423 + let make total data = { total; data } in 424 + Jsont.Object.map ~kind:"channel_paginated" make 425 + |> Jsont.Object.mem "total" Jsont.int ~enc:(fun r -> r.total) 426 + |> Jsont.Object.mem "data" (Jsont.list channel_jsont) ~enc:(fun r -> r.data) 427 + |> Jsont.Object.skip_unknown 428 + |> Jsont.Object.finish 429 + 430 + (* Video codecs *) 431 + 432 + let video_jsont : video Jsont.t = 433 + let make id uuid short_uuid name description url embed_path published_at 434 + originally_published_at updated_at thumbnail_path preview_path tags 435 + duration views likes dislikes is_local is_live privacy category licence 436 + language channel account = 437 + { id; uuid; short_uuid; name; description; url; embed_path; published_at; 438 + originally_published_at; updated_at; thumbnail_path; preview_path; tags; 439 + duration; views; likes; dislikes; is_local; is_live; privacy; category; 440 + licence; language; channel; account } 441 + in 442 + Jsont.Object.map ~kind:"video" make 443 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun v -> v.id) 444 + |> Jsont.Object.mem "uuid" Jsont.string ~enc:(fun v -> v.uuid) 445 + |> Jsont.Object.mem "shortUUID" (Jsont.option Jsont.string) 446 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.short_uuid) 447 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun v -> v.name) 448 + |> Jsont.Object.mem "description" (Jsont.option Jsont.string) 449 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.description) 450 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun v -> v.url) 451 + |> Jsont.Object.mem "embedPath" Jsont.string ~enc:(fun v -> v.embed_path) 452 + |> Jsont.Object.mem "publishedAt" ptime_jsont ~enc:(fun v -> v.published_at) 453 + |> Jsont.Object.mem "originallyPublishedAt" (Jsont.option ptime_jsont) 454 + ~dec_absent:None ~enc_omit:Option.is_none 455 + ~enc:(fun v -> v.originally_published_at) 456 + |> Jsont.Object.mem "updatedAt" (Jsont.option ptime_jsont) 457 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.updated_at) 458 + |> Jsont.Object.mem "thumbnailPath" (Jsont.option Jsont.string) 459 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.thumbnail_path) 460 + |> Jsont.Object.mem "previewPath" (Jsont.option Jsont.string) 461 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.preview_path) 462 + |> Jsont.Object.mem "tags" Jsont.(list string) 463 + ~dec_absent:[] ~enc_omit:(fun l -> l = []) ~enc:(fun v -> v.tags) 464 + |> Jsont.Object.mem "duration" Jsont.int ~dec_absent:0 ~enc:(fun v -> v.duration) 465 + |> Jsont.Object.mem "views" Jsont.int ~dec_absent:0 ~enc:(fun v -> v.views) 466 + |> Jsont.Object.mem "likes" Jsont.int ~dec_absent:0 ~enc:(fun v -> v.likes) 467 + |> Jsont.Object.mem "dislikes" Jsont.int ~dec_absent:0 ~enc:(fun v -> v.dislikes) 468 + |> Jsont.Object.mem "isLocal" Jsont.bool ~dec_absent:true ~enc:(fun v -> v.is_local) 469 + |> Jsont.Object.mem "isLive" Jsont.bool ~dec_absent:false ~enc:(fun v -> v.is_live) 470 + |> Jsont.Object.mem "privacy" privacy_jsont 471 + ~dec_absent:Public ~enc:(fun v -> v.privacy) 472 + |> Jsont.Object.mem "category" (Jsont.option int_labeled_jsont) 473 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.category) 474 + |> Jsont.Object.mem "licence" (Jsont.option int_labeled_jsont) 475 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.licence) 476 + |> Jsont.Object.mem "language" (Jsont.option string_labeled_jsont) 477 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.language) 478 + |> Jsont.Object.mem "channel" (Jsont.option channel_summary_jsont) 479 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.channel) 480 + |> Jsont.Object.mem "account" (Jsont.option account_summary_jsont) 481 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun v -> v.account) 482 + |> Jsont.Object.skip_unknown 483 + |> Jsont.Object.finish 484 + 485 + let video_paginated_jsont : video paginated Jsont.t = 486 + let make total data = { total; data } in 487 + Jsont.Object.map ~kind:"video_paginated" make 488 + |> Jsont.Object.mem "total" Jsont.int ~enc:(fun r -> r.total) 489 + |> Jsont.Object.mem "data" (Jsont.list video_jsont) ~enc:(fun r -> r.data) 490 + |> Jsont.Object.skip_unknown 491 + |> Jsont.Object.finish 492 + 493 + (* Playlist codecs *) 494 + 495 + let playlist_jsont : playlist Jsont.t = 496 + let make playlist_id playlist_uuid playlist_short_uuid playlist_display_name 497 + playlist_description playlist_privacy playlist_url playlist_thumbnail_path 498 + playlist_videos_length playlist_type playlist_created_at 499 + playlist_updated_at playlist_owner_account playlist_video_channel = 500 + { playlist_id; playlist_uuid; playlist_short_uuid; playlist_display_name; 501 + playlist_description; playlist_privacy; playlist_url; 502 + playlist_thumbnail_path; playlist_videos_length; playlist_type; 503 + playlist_created_at; playlist_updated_at; playlist_owner_account; 504 + playlist_video_channel } 505 + in 506 + Jsont.Object.map ~kind:"playlist" make 507 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun p -> p.playlist_id) 508 + |> Jsont.Object.mem "uuid" Jsont.string ~enc:(fun p -> p.playlist_uuid) 509 + |> Jsont.Object.mem "shortUUID" (Jsont.option Jsont.string) 510 + ~dec_absent:None ~enc_omit:Option.is_none 511 + ~enc:(fun p -> p.playlist_short_uuid) 512 + |> Jsont.Object.mem "displayName" Jsont.string 513 + ~enc:(fun p -> p.playlist_display_name) 514 + |> Jsont.Object.mem "description" (Jsont.option Jsont.string) 515 + ~dec_absent:None ~enc_omit:Option.is_none 516 + ~enc:(fun p -> p.playlist_description) 517 + |> Jsont.Object.mem "privacy" playlist_privacy_jsont 518 + ~enc:(fun p -> p.playlist_privacy) 519 + |> Jsont.Object.mem "url" Jsont.string ~enc:(fun p -> p.playlist_url) 520 + |> Jsont.Object.mem "thumbnailPath" (Jsont.option Jsont.string) 521 + ~dec_absent:None ~enc_omit:Option.is_none 522 + ~enc:(fun p -> p.playlist_thumbnail_path) 523 + |> Jsont.Object.mem "videosLength" Jsont.int 524 + ~dec_absent:0 ~enc:(fun p -> p.playlist_videos_length) 525 + |> Jsont.Object.mem "type" playlist_type_jsont ~enc:(fun p -> p.playlist_type) 526 + |> Jsont.Object.mem "createdAt" ptime_jsont 527 + ~enc:(fun p -> p.playlist_created_at) 528 + |> Jsont.Object.mem "updatedAt" ptime_jsont 529 + ~enc:(fun p -> p.playlist_updated_at) 530 + |> Jsont.Object.mem "ownerAccount" (Jsont.option account_summary_jsont) 531 + ~dec_absent:None ~enc_omit:Option.is_none 532 + ~enc:(fun p -> p.playlist_owner_account) 533 + |> Jsont.Object.mem "videoChannel" (Jsont.option channel_summary_jsont) 534 + ~dec_absent:None ~enc_omit:Option.is_none 535 + ~enc:(fun p -> p.playlist_video_channel) 536 + |> Jsont.Object.skip_unknown 537 + |> Jsont.Object.finish 538 + 539 + let playlist_paginated_jsont : playlist paginated Jsont.t = 540 + let make total data = { total; data } in 541 + Jsont.Object.map ~kind:"playlist_paginated" make 542 + |> Jsont.Object.mem "total" Jsont.int ~enc:(fun r -> r.total) 543 + |> Jsont.Object.mem "data" (Jsont.list playlist_jsont) ~enc:(fun r -> r.data) 544 + |> Jsont.Object.skip_unknown 545 + |> Jsont.Object.finish 546 + 547 + let playlist_element_jsont : playlist_element Jsont.t = 548 + let make element_id element_position element_start_timestamp 549 + element_stop_timestamp element_video = 550 + { element_id; element_position; element_start_timestamp; 551 + element_stop_timestamp; element_video } 552 + in 553 + Jsont.Object.map ~kind:"playlist_element" make 554 + |> Jsont.Object.mem "id" Jsont.int ~enc:(fun e -> e.element_id) 555 + |> Jsont.Object.mem "position" Jsont.int ~enc:(fun e -> e.element_position) 556 + |> Jsont.Object.mem "startTimestamp" (Jsont.option Jsont.int) 557 + ~dec_absent:None ~enc_omit:Option.is_none 558 + ~enc:(fun e -> e.element_start_timestamp) 559 + |> Jsont.Object.mem "stopTimestamp" (Jsont.option Jsont.int) 560 + ~dec_absent:None ~enc_omit:Option.is_none 561 + ~enc:(fun e -> e.element_stop_timestamp) 562 + |> Jsont.Object.mem "video" (Jsont.option video_jsont) 563 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun e -> e.element_video) 564 + |> Jsont.Object.skip_unknown 565 + |> Jsont.Object.finish 566 + 567 + let playlist_element_paginated_jsont : playlist_element paginated Jsont.t = 568 + let make total data = { total; data } in 569 + Jsont.Object.map ~kind:"playlist_element_paginated" make 570 + |> Jsont.Object.mem "total" Jsont.int ~enc:(fun r -> r.total) 571 + |> Jsont.Object.mem "data" (Jsont.list playlist_element_jsont) 572 + ~enc:(fun r -> r.data) 573 + |> Jsont.Object.skip_unknown 574 + |> Jsont.Object.finish 575 + 576 + (* Server codecs *) 577 + 578 + let instance_info_jsont : instance_info Jsont.t = 579 + let make instance_name instance_short_description instance_description 580 + instance_terms instance_is_nsfw instance_default_nsfw_policy 581 + instance_default_client_route = 582 + { instance_name; instance_short_description; instance_description; 583 + instance_terms; instance_is_nsfw; instance_default_nsfw_policy; 584 + instance_default_client_route } 585 + in 586 + Jsont.Object.map ~kind:"instance_info" make 587 + |> Jsont.Object.mem "name" Jsont.string ~enc:(fun i -> i.instance_name) 588 + |> Jsont.Object.mem "shortDescription" Jsont.string 589 + ~dec_absent:"" ~enc:(fun i -> i.instance_short_description) 590 + |> Jsont.Object.mem "description" (Jsont.option Jsont.string) 591 + ~dec_absent:None ~enc_omit:Option.is_none 592 + ~enc:(fun i -> i.instance_description) 593 + |> Jsont.Object.mem "terms" (Jsont.option Jsont.string) 594 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:(fun i -> i.instance_terms) 595 + |> Jsont.Object.mem "isNSFW" Jsont.bool ~dec_absent:false 596 + ~enc:(fun i -> i.instance_is_nsfw) 597 + |> Jsont.Object.mem "defaultNSFWPolicy" Jsont.string 598 + ~dec_absent:"display" ~enc:(fun i -> i.instance_default_nsfw_policy) 599 + |> Jsont.Object.mem "defaultClientRoute" Jsont.string 600 + ~dec_absent:"/videos/trending" ~enc:(fun i -> i.instance_default_client_route) 601 + |> Jsont.Object.skip_unknown 602 + |> Jsont.Object.finish 603 + 604 + let signup_jsont = 605 + let make allowed allowed_for_current_ip requires_email_verification = 606 + (allowed, allowed_for_current_ip, requires_email_verification) 607 + in 608 + Jsont.Object.map ~kind:"signup" make 609 + |> Jsont.Object.mem "allowed" Jsont.bool ~dec_absent:false 610 + ~enc:(fun (a, _, _) -> a) 611 + |> Jsont.Object.mem "allowedForCurrentIP" Jsont.bool ~dec_absent:false 612 + ~enc:(fun (_, a, _) -> a) 613 + |> Jsont.Object.mem "requiresEmailVerification" Jsont.bool ~dec_absent:false 614 + ~enc:(fun (_, _, r) -> r) 615 + |> Jsont.Object.skip_unknown 616 + |> Jsont.Object.finish 617 + 618 + let transcoding_jsont = 619 + Jsont.Object.map ~kind:"transcoding" Fun.id 620 + |> Jsont.Object.mem "enabled" Jsont.bool ~dec_absent:false ~enc:Fun.id 621 + |> Jsont.Object.skip_unknown 622 + |> Jsont.Object.finish 623 + 624 + let contact_form_jsont = 625 + Jsont.Object.map ~kind:"contact_form" Fun.id 626 + |> Jsont.Object.mem "enabled" Jsont.bool ~dec_absent:false ~enc:Fun.id 627 + |> Jsont.Object.skip_unknown 628 + |> Jsont.Object.finish 629 + 630 + let server_config_jsont : server_config Jsont.t = 631 + let make config_instance config_server_version config_server_commit signup 632 + transcoding contact_form = 633 + let config_signup_allowed, config_signup_allowed_for_current_ip, 634 + config_signup_requires_email_verification = signup in 635 + { config_instance; config_server_version; config_server_commit; 636 + config_signup_allowed; config_signup_allowed_for_current_ip; 637 + config_signup_requires_email_verification; 638 + config_transcoding_enabled = transcoding; 639 + config_contact_form_enabled = contact_form } 640 + in 641 + Jsont.Object.map ~kind:"server_config" make 642 + |> Jsont.Object.mem "instance" instance_info_jsont 643 + ~enc:(fun c -> c.config_instance) 644 + |> Jsont.Object.mem "serverVersion" Jsont.string 645 + ~dec_absent:"unknown" ~enc:(fun c -> c.config_server_version) 646 + |> Jsont.Object.mem "serverCommit" (Jsont.option Jsont.string) 647 + ~dec_absent:None ~enc_omit:Option.is_none 648 + ~enc:(fun c -> c.config_server_commit) 649 + |> Jsont.Object.mem "signup" signup_jsont 650 + ~enc:(fun c -> 651 + (c.config_signup_allowed, c.config_signup_allowed_for_current_ip, 652 + c.config_signup_requires_email_verification)) 653 + |> Jsont.Object.mem "transcoding" transcoding_jsont 654 + ~enc:(fun c -> c.config_transcoding_enabled) 655 + |> Jsont.Object.mem "contactForm" contact_form_jsont 656 + ~enc:(fun c -> c.config_contact_form_enabled) 657 + |> Jsont.Object.skip_unknown 658 + |> Jsont.Object.finish 659 + 660 + let server_stats_jsont : server_stats Jsont.t = 661 + let make stats_total_users stats_total_daily_active_users 662 + stats_total_weekly_active_users stats_total_monthly_active_users 663 + stats_total_local_videos stats_total_local_video_views 664 + stats_total_local_video_comments stats_total_local_video_files_size 665 + stats_total_videos stats_total_video_comments 666 + stats_total_local_video_channels stats_total_local_playlists 667 + stats_total_instance_followers stats_total_instance_following = 668 + { stats_total_users; stats_total_daily_active_users; 669 + stats_total_weekly_active_users; stats_total_monthly_active_users; 670 + stats_total_local_videos; stats_total_local_video_views; 671 + stats_total_local_video_comments; stats_total_local_video_files_size; 672 + stats_total_videos; stats_total_video_comments; 673 + stats_total_local_video_channels; stats_total_local_playlists; 674 + stats_total_instance_followers; stats_total_instance_following } 675 + in 676 + Jsont.Object.map ~kind:"server_stats" make 677 + |> Jsont.Object.mem "totalUsers" Jsont.int ~dec_absent:0 678 + ~enc:(fun s -> s.stats_total_users) 679 + |> Jsont.Object.mem "totalDailyActiveUsers" Jsont.int ~dec_absent:0 680 + ~enc:(fun s -> s.stats_total_daily_active_users) 681 + |> Jsont.Object.mem "totalWeeklyActiveUsers" Jsont.int ~dec_absent:0 682 + ~enc:(fun s -> s.stats_total_weekly_active_users) 683 + |> Jsont.Object.mem "totalMonthlyActiveUsers" Jsont.int ~dec_absent:0 684 + ~enc:(fun s -> s.stats_total_monthly_active_users) 685 + |> Jsont.Object.mem "totalLocalVideos" Jsont.int ~dec_absent:0 686 + ~enc:(fun s -> s.stats_total_local_videos) 687 + |> Jsont.Object.mem "totalLocalVideoViews" Jsont.int ~dec_absent:0 688 + ~enc:(fun s -> s.stats_total_local_video_views) 689 + |> Jsont.Object.mem "totalLocalVideoComments" Jsont.int ~dec_absent:0 690 + ~enc:(fun s -> s.stats_total_local_video_comments) 691 + |> Jsont.Object.mem "totalLocalVideoFilesSize" Jsont.int64 ~dec_absent:0L 692 + ~enc:(fun s -> s.stats_total_local_video_files_size) 693 + |> Jsont.Object.mem "totalVideos" Jsont.int ~dec_absent:0 694 + ~enc:(fun s -> s.stats_total_videos) 695 + |> Jsont.Object.mem "totalVideoComments" Jsont.int ~dec_absent:0 696 + ~enc:(fun s -> s.stats_total_video_comments) 697 + |> Jsont.Object.mem "totalLocalVideoChannels" Jsont.int ~dec_absent:0 698 + ~enc:(fun s -> s.stats_total_local_video_channels) 699 + |> Jsont.Object.mem "totalLocalPlaylists" Jsont.int ~dec_absent:0 700 + ~enc:(fun s -> s.stats_total_local_playlists) 701 + |> Jsont.Object.mem "totalInstanceFollowers" Jsont.int ~dec_absent:0 702 + ~enc:(fun s -> s.stats_total_instance_followers) 703 + |> Jsont.Object.mem "totalInstanceFollowing" Jsont.int ~dec_absent:0 704 + ~enc:(fun s -> s.stats_total_instance_following) 705 + |> Jsont.Object.skip_unknown 706 + |> Jsont.Object.finish 707 + 708 + (* HTTP helpers *) 709 + 710 + let get_json ~session ~url codec = 711 + Log.debug (fun m -> m "GET %s" url); 712 + let response = Requests.get session url in 713 + let status = Requests.Response.status_code response in 714 + if Requests.Response.ok response then begin 715 + Log.debug (fun m -> m "Response: %d OK" status); 716 + let body = Requests.Response.text response in 717 + match Jsont_bytesrw.decode_string codec body with 718 + | Ok result -> result 719 + | Error e -> 720 + Log.err (fun m -> m "JSON parse error: %s" e); 721 + failwith (Fmt.str "JSON parse error: %s" e) 722 + end 723 + else begin 724 + let body = Requests.Response.text response in 725 + raise_api_error status body 726 + end 727 + 728 + let build_url base parts params = 729 + let path = String.concat "/" parts in 730 + let params = List.filter_map Fun.id params in 731 + let query = 732 + if params = [] then "" 733 + else "?" ^ String.concat "&" (List.map (fun (k, v) -> k ^ "=" ^ v) params) 734 + in 735 + base ^ "/" ^ path ^ query 736 + 737 + (* Video operations *) 738 + 739 + let list_videos ?(count = 20) ?(start = 0) ?(sort = Newest) ?(nsfw = false) 740 + ?is_local ?is_live ?category_id ?tags ~session ~base_url () = 741 + let url = 742 + build_url base_url [ "api"; "v1"; "videos" ] 743 + [ 744 + Some ("count", string_of_int count); 745 + Some ("start", string_of_int start); 746 + Some ("sort", string_of_video_sort sort); 747 + Some ("nsfw", if nsfw then "true" else "false"); 748 + Option.map (fun b -> ("isLocal", if b then "true" else "false")) is_local; 749 + Option.map (fun b -> ("isLive", if b then "true" else "false")) is_live; 750 + Option.map (fun i -> ("categoryOneOf", string_of_int i)) category_id; 751 + Option.map (fun t -> ("tagsOneOf", t)) tags; 752 + ] 753 + in 754 + let result = get_json ~session ~url video_paginated_jsont in 755 + Log.info (fun m -> 756 + m "Listed %d videos (total: %d)" (List.length result.data) result.total); 757 + result 758 + 759 + let search_videos ~query ?(count = 20) ?(start = 0) ?(sort = Best) 760 + ?(search_target = `Local) ?duration_min ?duration_max ?published_after 761 + ?published_before ~session ~base_url () = 762 + let url = 763 + build_url base_url [ "api"; "v1"; "search"; "videos" ] 764 + [ 765 + Some ("search", query); 766 + Some ("count", string_of_int count); 767 + Some ("start", string_of_int start); 768 + Some ("sort", string_of_video_sort sort); 769 + Some 770 + ( "searchTarget", 771 + match search_target with `Local -> "local" | `Search_index -> "search-index" ); 772 + Option.map (fun d -> ("durationMin", string_of_int d)) duration_min; 773 + Option.map (fun d -> ("durationMax", string_of_int d)) duration_max; 774 + Option.map (fun t -> ("startDate", Ptime.to_rfc3339 t)) published_after; 775 + Option.map (fun t -> ("endDate", Ptime.to_rfc3339 t)) published_before; 776 + ] 777 + in 778 + let result = get_json ~session ~url video_paginated_jsont in 779 + Log.info (fun m -> 780 + m "Search '%s' found %d videos (total: %d)" query 781 + (List.length result.data) result.total); 782 + result 783 + 784 + let fetch_channel_videos ?(count = 20) ?(start = 0) ~session ~base_url ~channel 785 + () = 786 + let url = 787 + build_url base_url [ "api"; "v1"; "video-channels"; channel; "videos" ] 788 + [ 789 + Some ("count", string_of_int count); 790 + Some ("start", string_of_int start); 791 + ] 792 + in 793 + let result = get_json ~session ~url video_paginated_jsont in 794 + Log.info (fun m -> 795 + m "Fetched %d videos from channel %s (total: %d)" 796 + (List.length result.data) channel result.total); 797 + result 798 + 799 + let fetch_all_channel_videos ?(page_size = 20) ?max_pages ~session ~base_url 800 + ~channel () = 801 + Log.info (fun m -> 802 + m "Fetching all videos from channel %s (page_size=%d, max_pages=%s)" 803 + channel page_size 804 + (match max_pages with None -> "unlimited" | Some n -> string_of_int n)); 805 + let rec fetch_pages page start acc = 806 + let response = 807 + fetch_channel_videos ~count:page_size ~start ~session ~base_url ~channel 808 + () 809 + in 810 + let all_videos = acc @ response.data in 811 + let fetched_count = start + List.length response.data in 812 + let more_available = fetched_count < response.total in 813 + let under_max_pages = 814 + match max_pages with None -> true | Some max -> page < max 815 + in 816 + Log.debug (fun m -> 817 + m "Page %d: fetched %d, total so far %d/%d" page 818 + (List.length response.data) fetched_count response.total); 819 + if more_available && under_max_pages then 820 + fetch_pages (page + 1) fetched_count all_videos 821 + else begin 822 + Log.info (fun m -> 823 + m "Finished fetching %d videos in %d pages" (List.length all_videos) 824 + page); 825 + all_videos 826 + end 827 + in 828 + fetch_pages 1 0 [] 829 + 830 + let fetch_video_details ~session ~base_url ~uuid () = 831 + let url = build_url base_url [ "api"; "v1"; "videos"; uuid ] [] in 832 + let video = get_json ~session ~url video_jsont in 833 + Log.info (fun m -> m "Fetched video details: %s (%s)" video.name uuid); 834 + video 835 + 836 + (* Categories/languages/licences lookup - returns key-value pairs *) 837 + 838 + module StringMap = Map.Make (String) 839 + 840 + let dict_jsont (key_codec : 'a Jsont.t) : ('a * string) list Jsont.t = 841 + let map_jsont = Jsont.(Object.as_string_map string) in 842 + Jsont.map ~kind:"dict" 843 + ~dec:(fun smap -> 844 + let bindings = StringMap.bindings smap in 845 + List.filter_map 846 + (fun (k, v) -> 847 + match Jsont_bytesrw.decode_string key_codec ("\"" ^ k ^ "\"") with 848 + | Ok key -> Some (key, v) 849 + | Error _ -> None) 850 + bindings) 851 + ~enc:(fun pairs -> 852 + List.fold_left 853 + (fun m (k, v) -> 854 + match Jsont_bytesrw.encode_string key_codec k with 855 + | Ok s -> 856 + let key = String.sub s 1 (String.length s - 2) in 857 + StringMap.add key v m 858 + | Error _ -> m) 859 + StringMap.empty pairs) 860 + map_jsont 861 + 862 + let get_categories ~session ~base_url () = 863 + let url = build_url base_url [ "api"; "v1"; "videos"; "categories" ] [] in 864 + let result = get_json ~session ~url (dict_jsont Jsont.int) in 865 + Log.info (fun m -> m "Fetched %d categories" (List.length result)); 866 + result 867 + 868 + let get_licences ~session ~base_url () = 869 + let url = build_url base_url [ "api"; "v1"; "videos"; "licences" ] [] in 870 + let result = get_json ~session ~url (dict_jsont Jsont.int) in 871 + Log.info (fun m -> m "Fetched %d licences" (List.length result)); 872 + result 873 + 874 + let get_languages ~session ~base_url () = 875 + let url = build_url base_url [ "api"; "v1"; "videos"; "languages" ] [] in 876 + let result = get_json ~session ~url (dict_jsont Jsont.string) in 877 + Log.info (fun m -> m "Fetched %d languages" (List.length result)); 878 + result 879 + 880 + (* Channel operations *) 881 + 882 + let list_channels ?(count = 20) ?(start = 0) ?(sort = "-createdAt") ~session 883 + ~base_url () = 884 + let url = 885 + build_url base_url [ "api"; "v1"; "video-channels" ] 886 + [ 887 + Some ("count", string_of_int count); 888 + Some ("start", string_of_int start); 889 + Some ("sort", sort); 890 + ] 891 + in 892 + let result = get_json ~session ~url channel_paginated_jsont in 893 + Log.info (fun m -> 894 + m "Listed %d channels (total: %d)" (List.length result.data) result.total); 895 + result 896 + 897 + let search_channels ~query ?(count = 20) ?(start = 0) ~session ~base_url () = 898 + let url = 899 + build_url base_url [ "api"; "v1"; "search"; "video-channels" ] 900 + [ 901 + Some ("search", query); 902 + Some ("count", string_of_int count); 903 + Some ("start", string_of_int start); 904 + ] 905 + in 906 + let result = get_json ~session ~url channel_paginated_jsont in 907 + Log.info (fun m -> 908 + m "Search '%s' found %d channels (total: %d)" query 909 + (List.length result.data) result.total); 910 + result 911 + 912 + let get_channel ~session ~base_url ~handle () = 913 + let url = build_url base_url [ "api"; "v1"; "video-channels"; handle ] [] in 914 + let channel = get_json ~session ~url channel_jsont in 915 + Log.info (fun m -> 916 + m "Fetched channel: %s" channel.channel.channel_display_name); 917 + channel 918 + 919 + (* Account operations *) 920 + 921 + let list_accounts ?(count = 20) ?(start = 0) ?(sort = "-createdAt") ~session 922 + ~base_url () = 923 + let url = 924 + build_url base_url [ "api"; "v1"; "accounts" ] 925 + [ 926 + Some ("count", string_of_int count); 927 + Some ("start", string_of_int start); 928 + Some ("sort", sort); 929 + ] 930 + in 931 + let result = get_json ~session ~url account_paginated_jsont in 932 + Log.info (fun m -> 933 + m "Listed %d accounts (total: %d)" (List.length result.data) result.total); 934 + result 935 + 936 + let get_account ~session ~base_url ~handle () = 937 + let url = build_url base_url [ "api"; "v1"; "accounts"; handle ] [] in 938 + let account = get_json ~session ~url account_jsont in 939 + Log.info (fun m -> 940 + m "Fetched account: %s" account.account.account_display_name); 941 + account 942 + 943 + let get_account_videos ?(count = 20) ?(start = 0) ~session ~base_url ~handle () 944 + = 945 + let url = 946 + build_url base_url [ "api"; "v1"; "accounts"; handle; "videos" ] 947 + [ 948 + Some ("count", string_of_int count); 949 + Some ("start", string_of_int start); 950 + ] 951 + in 952 + let result = get_json ~session ~url video_paginated_jsont in 953 + Log.info (fun m -> 954 + m "Fetched %d videos from account %s (total: %d)" 955 + (List.length result.data) handle result.total); 956 + result 957 + 958 + let get_account_channels ?(count = 20) ?(start = 0) ~session ~base_url ~handle 959 + () = 960 + let url = 961 + build_url base_url [ "api"; "v1"; "accounts"; handle; "video-channels" ] 962 + [ 963 + Some ("count", string_of_int count); 964 + Some ("start", string_of_int start); 965 + ] 966 + in 967 + let result = get_json ~session ~url channel_paginated_jsont in 968 + Log.info (fun m -> 969 + m "Fetched %d channels from account %s (total: %d)" 970 + (List.length result.data) handle result.total); 971 + result 972 + 973 + (* Playlist operations *) 974 + 975 + let list_playlists ?(count = 20) ?(start = 0) ~session ~base_url () = 976 + let url = 977 + build_url base_url [ "api"; "v1"; "video-playlists" ] 978 + [ 979 + Some ("count", string_of_int count); 980 + Some ("start", string_of_int start); 981 + ] 982 + in 983 + let result = get_json ~session ~url playlist_paginated_jsont in 984 + Log.info (fun m -> 985 + m "Listed %d playlists (total: %d)" (List.length result.data) result.total); 986 + result 987 + 988 + let search_playlists ~query ?(count = 20) ?(start = 0) ~session ~base_url () = 989 + let url = 990 + build_url base_url [ "api"; "v1"; "search"; "video-playlists" ] 991 + [ 992 + Some ("search", query); 993 + Some ("count", string_of_int count); 994 + Some ("start", string_of_int start); 995 + ] 996 + in 997 + let result = get_json ~session ~url playlist_paginated_jsont in 998 + Log.info (fun m -> 999 + m "Search '%s' found %d playlists (total: %d)" query 1000 + (List.length result.data) result.total); 1001 + result 1002 + 1003 + let get_playlist ~session ~base_url ~id () = 1004 + let url = build_url base_url [ "api"; "v1"; "video-playlists"; id ] [] in 1005 + let playlist = get_json ~session ~url playlist_jsont in 1006 + Log.info (fun m -> m "Fetched playlist: %s" playlist.playlist_display_name); 1007 + playlist 1008 + 1009 + let get_playlist_videos ?(count = 20) ?(start = 0) ~session ~base_url ~id () = 1010 + let url = 1011 + build_url base_url [ "api"; "v1"; "video-playlists"; id; "videos" ] 1012 + [ 1013 + Some ("count", string_of_int count); 1014 + Some ("start", string_of_int start); 1015 + ] 1016 + in 1017 + let result = get_json ~session ~url playlist_element_paginated_jsont in 1018 + Log.info (fun m -> 1019 + m "Fetched %d videos from playlist (total: %d)" (List.length result.data) 1020 + result.total); 1021 + result 1022 + 1023 + let get_account_playlists ?(count = 20) ?(start = 0) ~session ~base_url ~handle 1024 + () = 1025 + let url = 1026 + build_url base_url [ "api"; "v1"; "accounts"; handle; "video-playlists" ] 1027 + [ 1028 + Some ("count", string_of_int count); 1029 + Some ("start", string_of_int start); 1030 + ] 1031 + in 1032 + let result = get_json ~session ~url playlist_paginated_jsont in 1033 + Log.info (fun m -> 1034 + m "Fetched %d playlists from account %s (total: %d)" 1035 + (List.length result.data) handle result.total); 1036 + result 1037 + 1038 + (* Server operations *) 1039 + 1040 + let get_config ~session ~base_url () = 1041 + let url = build_url base_url [ "api"; "v1"; "config" ] [] in 1042 + let config = get_json ~session ~url server_config_jsont in 1043 + Log.info (fun m -> 1044 + m "Fetched config for: %s (v%s)" config.config_instance.instance_name 1045 + config.config_server_version); 1046 + config 1047 + 1048 + let get_stats ~session ~base_url () = 1049 + let url = build_url base_url [ "api"; "v1"; "server"; "stats" ] [] in 1050 + let stats = get_json ~session ~url server_stats_jsont in 1051 + Log.info (fun m -> 1052 + m "Fetched stats: %d users, %d videos" stats.stats_total_users 1053 + stats.stats_total_videos); 1054 + stats 1055 + 1056 + (* Utilities *) 1057 + 1058 + let thumbnail_url ~base_url video = 1059 + match video.thumbnail_path with 1060 + | Some path -> Some (base_url ^ path) 1061 + | None -> None 1062 + 1063 + let download_thumbnail ~session ~base_url ~video ~output_path () = 1064 + match thumbnail_url ~base_url video with 1065 + | None -> 1066 + Log.warn (fun m -> m "No thumbnail available for video %s" video.uuid); 1067 + Error 1068 + (`Msg (Printf.sprintf "No thumbnail available for video %s" video.uuid)) 1069 + | Some url -> 1070 + Log.debug (fun m -> m "Downloading thumbnail from %s" url); 1071 + let response = Requests.get session url in 1072 + let status = Requests.Response.status_code response in 1073 + if Requests.Response.ok response then begin 1074 + let body = Requests.Response.text response in 1075 + try 1076 + let oc = open_out_bin output_path in 1077 + output_string oc body; 1078 + close_out oc; 1079 + Log.info (fun m -> 1080 + m "Downloaded thumbnail for %s to %s" video.uuid output_path); 1081 + Ok () 1082 + with exn -> 1083 + Log.err (fun m -> 1084 + m "Failed to write thumbnail to %s: %s" output_path 1085 + (Printexc.to_string exn)); 1086 + Error 1087 + (`Msg 1088 + (Printf.sprintf "Failed to write thumbnail: %s" 1089 + (Printexc.to_string exn))) 1090 + end 1091 + else begin 1092 + Log.err (fun m -> 1093 + m "HTTP error %d downloading thumbnail from %s" status url); 1094 + Error 1095 + (`Msg (Printf.sprintf "HTTP error downloading thumbnail: %d" status)) 1096 + end 1097 + 1098 + let to_tuple video = 1099 + let description = Option.value ~default:"" video.description in 1100 + let published_date = 1101 + Option.value ~default:video.published_at video.originally_published_at 1102 + in 1103 + ( description, 1104 + published_date, 1105 + video.name, 1106 + video.url, 1107 + video.uuid, 1108 + string_of_int video.id )
+477
lib/peertube.mli
··· 1 + (** PeerTube API client for OCaml using Eio. 2 + 3 + This library provides a typed client for the PeerTube video platform API, 4 + using Eio for effect-based I/O and jsont for JSON serialization. *) 5 + 6 + (** {1 Logging} *) 7 + 8 + (** Log source for the PeerTube client. *) 9 + val log_src : Logs.src 10 + 11 + (** {1 Errors} *) 12 + 13 + (** API error with HTTP status code and message. *) 14 + exception Api_error of int * string 15 + 16 + (** {1 Common Types} *) 17 + 18 + (** A labeled value with numeric ID (used for categories, licences, languages). *) 19 + type 'a labeled = { 20 + id : 'a; 21 + label : string; 22 + } 23 + 24 + (** Video privacy levels. *) 25 + type privacy = Public | Unlisted | Private | Internal 26 + 27 + (** Video sort options. *) 28 + type video_sort = 29 + | Newest 30 + | Oldest 31 + | Views 32 + | Likes 33 + | Trending 34 + | Hot 35 + | Random 36 + | Best 37 + 38 + (** {1 Account Types} *) 39 + 40 + (** Summary of an account (embedded in other responses). *) 41 + type account_summary = { 42 + account_id : int; 43 + account_name : string; 44 + account_display_name : string; 45 + account_url : string; 46 + account_host : string; 47 + account_avatar_path : string option; 48 + } 49 + 50 + (** Full account details. *) 51 + type account = { 52 + account : account_summary; 53 + account_description : string option; 54 + account_created_at : Ptime.t; 55 + account_followers_count : int; 56 + account_following_count : int; 57 + account_following_hosts_count : int option; 58 + } 59 + 60 + (** {1 Channel Types} *) 61 + 62 + (** Summary of a channel (embedded in other responses). *) 63 + type channel_summary = { 64 + channel_id : int; 65 + channel_name : string; 66 + channel_display_name : string; 67 + channel_url : string; 68 + channel_host : string; 69 + channel_avatar_path : string option; 70 + } 71 + 72 + (** Full channel details. *) 73 + type channel = { 74 + channel : channel_summary; 75 + channel_description : string option; 76 + channel_support : string option; 77 + channel_created_at : Ptime.t; 78 + channel_followers_count : int; 79 + channel_following_count : int; 80 + channel_banner_path : string option; 81 + channel_owner_account : account_summary option; 82 + } 83 + 84 + (** {1 Video Types} *) 85 + 86 + (** A PeerTube video record. *) 87 + type video = { 88 + id : int; 89 + uuid : string; 90 + short_uuid : string option; 91 + name : string; 92 + description : string option; 93 + url : string; 94 + embed_path : string; 95 + published_at : Ptime.t; 96 + originally_published_at : Ptime.t option; 97 + updated_at : Ptime.t option; 98 + thumbnail_path : string option; 99 + preview_path : string option; 100 + tags : string list; 101 + duration : int; 102 + views : int; 103 + likes : int; 104 + dislikes : int; 105 + is_local : bool; 106 + is_live : bool; 107 + privacy : privacy; 108 + category : int labeled option; 109 + licence : int labeled option; 110 + language : string labeled option; 111 + channel : channel_summary option; 112 + account : account_summary option; 113 + } 114 + 115 + (** A paginated response. *) 116 + type 'a paginated = { 117 + total : int; 118 + data : 'a list; 119 + } 120 + 121 + (** {1 Playlist Types} *) 122 + 123 + (** Playlist privacy levels. *) 124 + type playlist_privacy = PlaylistPublic | PlaylistUnlisted | PlaylistPrivate 125 + 126 + (** Playlist type. *) 127 + type playlist_type = Regular | WatchLater 128 + 129 + (** A video playlist. *) 130 + type playlist = { 131 + playlist_id : int; 132 + playlist_uuid : string; 133 + playlist_short_uuid : string option; 134 + playlist_display_name : string; 135 + playlist_description : string option; 136 + playlist_privacy : playlist_privacy; 137 + playlist_url : string; 138 + playlist_thumbnail_path : string option; 139 + playlist_videos_length : int; 140 + playlist_type : playlist_type; 141 + playlist_created_at : Ptime.t; 142 + playlist_updated_at : Ptime.t; 143 + playlist_owner_account : account_summary option; 144 + playlist_video_channel : channel_summary option; 145 + } 146 + 147 + (** An element in a playlist. *) 148 + type playlist_element = { 149 + element_id : int; 150 + element_position : int; 151 + element_start_timestamp : int option; 152 + element_stop_timestamp : int option; 153 + element_video : video option; 154 + } 155 + 156 + (** {1 Server Types} *) 157 + 158 + (** Basic server/instance information. *) 159 + type instance_info = { 160 + instance_name : string; 161 + instance_short_description : string; 162 + instance_description : string option; 163 + instance_terms : string option; 164 + instance_is_nsfw : bool; 165 + instance_default_nsfw_policy : string; 166 + instance_default_client_route : string; 167 + } 168 + 169 + (** Server configuration. *) 170 + type server_config = { 171 + config_instance : instance_info; 172 + config_server_version : string; 173 + config_server_commit : string option; 174 + config_signup_allowed : bool; 175 + config_signup_allowed_for_current_ip : bool; 176 + config_signup_requires_email_verification : bool; 177 + config_transcoding_enabled : bool; 178 + config_contact_form_enabled : bool; 179 + } 180 + 181 + (** Server statistics. *) 182 + type server_stats = { 183 + stats_total_users : int; 184 + stats_total_daily_active_users : int; 185 + stats_total_weekly_active_users : int; 186 + stats_total_monthly_active_users : int; 187 + stats_total_local_videos : int; 188 + stats_total_local_video_views : int; 189 + stats_total_local_video_comments : int; 190 + stats_total_local_video_files_size : int64; 191 + stats_total_videos : int; 192 + stats_total_video_comments : int; 193 + stats_total_local_video_channels : int; 194 + stats_total_local_playlists : int; 195 + stats_total_instance_followers : int; 196 + stats_total_instance_following : int; 197 + } 198 + 199 + (** {1 JSON Codecs} *) 200 + 201 + val video_jsont : video Jsont.t 202 + val video_paginated_jsont : video paginated Jsont.t 203 + val channel_jsont : channel Jsont.t 204 + val channel_summary_jsont : channel_summary Jsont.t 205 + val channel_paginated_jsont : channel paginated Jsont.t 206 + val account_jsont : account Jsont.t 207 + val account_summary_jsont : account_summary Jsont.t 208 + val account_paginated_jsont : account paginated Jsont.t 209 + val playlist_jsont : playlist Jsont.t 210 + val playlist_paginated_jsont : playlist paginated Jsont.t 211 + val playlist_element_jsont : playlist_element Jsont.t 212 + val playlist_element_paginated_jsont : playlist_element paginated Jsont.t 213 + val server_config_jsont : server_config Jsont.t 214 + val server_stats_jsont : server_stats Jsont.t 215 + 216 + (** {1 Video Operations} *) 217 + 218 + (** List videos with optional filtering and sorting. 219 + 220 + @param count Number of videos per page (default: 20) 221 + @param start Starting offset for pagination (default: 0) 222 + @param sort Sort order (default: Newest) 223 + @param nsfw Include NSFW videos (default: false) 224 + @param is_local Only local videos (default: None = both) 225 + @param is_live Only live videos (default: None = both) 226 + @param category_id Filter by category ID 227 + @param tags Filter by tags (comma-separated) *) 228 + val list_videos : 229 + ?count:int -> 230 + ?start:int -> 231 + ?sort:video_sort -> 232 + ?nsfw:bool -> 233 + ?is_local:bool -> 234 + ?is_live:bool -> 235 + ?category_id:int -> 236 + ?tags:string -> 237 + session:Requests.t -> 238 + base_url:string -> 239 + unit -> 240 + video paginated 241 + 242 + (** Search for videos. 243 + 244 + @param query Search query string 245 + @param count Number of results per page (default: 20) 246 + @param start Starting offset (default: 0) 247 + @param sort Sort order (default: Best for search) 248 + @param search_target Search locally or everywhere (default: local) 249 + @param duration_min Minimum duration in seconds 250 + @param duration_max Maximum duration in seconds 251 + @param published_after Only videos published after this date 252 + @param published_before Only videos published before this date *) 253 + val search_videos : 254 + query:string -> 255 + ?count:int -> 256 + ?start:int -> 257 + ?sort:video_sort -> 258 + ?search_target:[ `Local | `Search_index ] -> 259 + ?duration_min:int -> 260 + ?duration_max:int -> 261 + ?published_after:Ptime.t -> 262 + ?published_before:Ptime.t -> 263 + session:Requests.t -> 264 + base_url:string -> 265 + unit -> 266 + video paginated 267 + 268 + (** Fetch videos from a channel with pagination. *) 269 + val fetch_channel_videos : 270 + ?count:int -> 271 + ?start:int -> 272 + session:Requests.t -> 273 + base_url:string -> 274 + channel:string -> 275 + unit -> 276 + video paginated 277 + 278 + (** Fetch all videos from a channel using automatic pagination. *) 279 + val fetch_all_channel_videos : 280 + ?page_size:int -> 281 + ?max_pages:int -> 282 + session:Requests.t -> 283 + base_url:string -> 284 + channel:string -> 285 + unit -> 286 + video list 287 + 288 + (** Fetch detailed information for a single video by UUID. *) 289 + val fetch_video_details : 290 + session:Requests.t -> 291 + base_url:string -> 292 + uuid:string -> 293 + unit -> 294 + video 295 + 296 + (** Get available video categories. *) 297 + val get_categories : 298 + session:Requests.t -> 299 + base_url:string -> 300 + unit -> 301 + (int * string) list 302 + 303 + (** Get available video licences. *) 304 + val get_licences : 305 + session:Requests.t -> 306 + base_url:string -> 307 + unit -> 308 + (int * string) list 309 + 310 + (** Get available video languages. *) 311 + val get_languages : 312 + session:Requests.t -> 313 + base_url:string -> 314 + unit -> 315 + (string * string) list 316 + 317 + (** {1 Channel Operations} *) 318 + 319 + (** List all channels on the instance. 320 + 321 + @param count Number per page (default: 20) 322 + @param start Starting offset (default: 0) 323 + @param sort Sort order: createdAt, -createdAt, etc. *) 324 + val list_channels : 325 + ?count:int -> 326 + ?start:int -> 327 + ?sort:string -> 328 + session:Requests.t -> 329 + base_url:string -> 330 + unit -> 331 + channel paginated 332 + 333 + (** Search for channels. 334 + 335 + @param query Search query string *) 336 + val search_channels : 337 + query:string -> 338 + ?count:int -> 339 + ?start:int -> 340 + session:Requests.t -> 341 + base_url:string -> 342 + unit -> 343 + channel paginated 344 + 345 + (** Get details for a specific channel. *) 346 + val get_channel : 347 + session:Requests.t -> 348 + base_url:string -> 349 + handle:string -> 350 + unit -> 351 + channel 352 + 353 + (** {1 Account Operations} *) 354 + 355 + (** List accounts on the instance. *) 356 + val list_accounts : 357 + ?count:int -> 358 + ?start:int -> 359 + ?sort:string -> 360 + session:Requests.t -> 361 + base_url:string -> 362 + unit -> 363 + account paginated 364 + 365 + (** Get details for a specific account. *) 366 + val get_account : 367 + session:Requests.t -> 368 + base_url:string -> 369 + handle:string -> 370 + unit -> 371 + account 372 + 373 + (** Get videos from a specific account. *) 374 + val get_account_videos : 375 + ?count:int -> 376 + ?start:int -> 377 + session:Requests.t -> 378 + base_url:string -> 379 + handle:string -> 380 + unit -> 381 + video paginated 382 + 383 + (** Get channels owned by an account. *) 384 + val get_account_channels : 385 + ?count:int -> 386 + ?start:int -> 387 + session:Requests.t -> 388 + base_url:string -> 389 + handle:string -> 390 + unit -> 391 + channel paginated 392 + 393 + (** {1 Playlist Operations} *) 394 + 395 + (** List playlists on the instance. *) 396 + val list_playlists : 397 + ?count:int -> 398 + ?start:int -> 399 + session:Requests.t -> 400 + base_url:string -> 401 + unit -> 402 + playlist paginated 403 + 404 + (** Search for playlists. *) 405 + val search_playlists : 406 + query:string -> 407 + ?count:int -> 408 + ?start:int -> 409 + session:Requests.t -> 410 + base_url:string -> 411 + unit -> 412 + playlist paginated 413 + 414 + (** Get details for a specific playlist. *) 415 + val get_playlist : 416 + session:Requests.t -> 417 + base_url:string -> 418 + id:string -> 419 + unit -> 420 + playlist 421 + 422 + (** Get videos in a playlist. *) 423 + val get_playlist_videos : 424 + ?count:int -> 425 + ?start:int -> 426 + session:Requests.t -> 427 + base_url:string -> 428 + id:string -> 429 + unit -> 430 + playlist_element paginated 431 + 432 + (** Get playlists owned by an account. *) 433 + val get_account_playlists : 434 + ?count:int -> 435 + ?start:int -> 436 + session:Requests.t -> 437 + base_url:string -> 438 + handle:string -> 439 + unit -> 440 + playlist paginated 441 + 442 + (** {1 Server Operations} *) 443 + 444 + (** Get server configuration. *) 445 + val get_config : 446 + session:Requests.t -> 447 + base_url:string -> 448 + unit -> 449 + server_config 450 + 451 + (** Get server statistics. *) 452 + val get_stats : 453 + session:Requests.t -> 454 + base_url:string -> 455 + unit -> 456 + server_stats 457 + 458 + (** {1 Utilities} *) 459 + 460 + (** Get the full thumbnail URL for a video. *) 461 + val thumbnail_url : base_url:string -> video -> string option 462 + 463 + (** Download a video's thumbnail to a file. *) 464 + val download_thumbnail : 465 + session:Requests.t -> 466 + base_url:string -> 467 + video:video -> 468 + output_path:string -> 469 + unit -> 470 + (unit, [ `Msg of string ]) result 471 + 472 + (** Convert a video to a tuple for external use. 473 + Returns [(description, published_date, title, url, uuid, id_string)]. *) 474 + val to_tuple : video -> string * Ptime.t * string * string * string * string 475 + 476 + (** Convert video_sort to API string. *) 477 + val string_of_video_sort : video_sort -> string
+38
peertube.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "PeerTube API client for OCaml using Eio" 4 + description: 5 + "An OCaml client library for the PeerTube video platform API, built on Eio for effect-based I/O" 6 + maintainer: ["anil@recoil.org"] 7 + authors: ["Anil Madhavapeddy"] 8 + license: "ISC" 9 + homepage: "https://github.com/avsm/ocaml-peertube" 10 + bug-reports: "https://github.com/avsm/ocaml-peertube/issues" 11 + depends: [ 12 + "dune" {>= "3.16"} 13 + "ocaml" {>= "5.1.0"} 14 + "eio" {>= "1.0"} 15 + "eio_main" {>= "1.0"} 16 + "requests" {>= "0.1"} 17 + "jsont" {>= "0.1"} 18 + "ptime" {>= "1.0"} 19 + "fmt" {>= "0.9"} 20 + "logs" {>= "0.7"} 21 + "cmdliner" {>= "1.2"} 22 + "odoc" {with-doc} 23 + ] 24 + build: [ 25 + ["dune" "subst"] {dev} 26 + [ 27 + "dune" 28 + "build" 29 + "-p" 30 + name 31 + "-j" 32 + jobs 33 + "@install" 34 + "@runtest" {with-test} 35 + "@doc" {with-doc} 36 + ] 37 + ] 38 + dev-repo: "git+https://github.com/avsm/ocaml-peertube.git"