├── README.md ├── config.ini ├── dashboard-example.json ├── plexInfluxdbCollector.py └── requirements.txt /README.md: -------------------------------------------------------------------------------- 1 | **Plex to InfluxDB Extended** 2 | ------------------------------ 3 | This project forked from the original, awesome [Plex-Data-Collector-For-InfluxDB by barrycarey](https://github.com/barrycarey/Plex-Data-Collector-For-InfluxDB). 4 | 5 | ![Screenshot](https://puu.sh/tarSA/aea875c453.png) 6 | 7 | This is a tool for collecting info from your Plex server and sending it to InfluxDB. This is ideal for displaying Plex specific information in a tool such as Grafana. 8 | 9 | **Docker** 10 | 11 | You can find a docker image for running this script on [Docker Hub](https://hub.docker.com/r/kurzondax/plex-to-influx-extended/) 12 | 13 | 14 | ## Known Issues 15 | * Any libraries containing TV shows that aren't named "TV Shows" won't return season or episode counts for the library statistics. The title "TV Shows" was hard coded in to the original python script that I forked this from. This can be fixed so that episodes will be counted for any library containing TV shows, but it may be a few days before I'll have time to work on it. 16 | * Currently, the python script assumes that the Plex server is always listening on port 32400. If your server uses a different port, no data will be collected. I will be adding code to recognize IPaddress:Port from the config file. 17 | * Exceedingly large libraries may cause the script to bog down when updating total library stats. I will be adding a new value to the config.ini file that will allow specifying a separate interval for collecting library stats. 18 | 19 | ## Configuration within config.ini 20 | 21 | #### GENERAL 22 | |Key |Description | 23 | |:--------------|:-------------------------------------------------------------------------------------------------------------------| 24 | |Delay |Delay between updating metrics | 25 | |Output |Write console output while tool is running | 26 | #### INFLUXDB 27 | |Key |Description | 28 | |:--------------|:-------------------------------------------------------------------------------------------------------------------| 29 | |Address |IP address or FQDN of influxdb server | 30 | |Port |InfluxDB port to connect to. 8086 in most cases | 31 | |Database |Database to write collected stats to | 32 | |Username |User that has access to the database | 33 | |Password |Password for above user | 34 | #### PLEX 35 | |Key |Description | 36 | |:--------------|:-------------------------------------------------------------------------------------------------------------------| 37 | |Username |Plex username | 38 | |Password |Plex Password | 39 | |Servers |A comma separated list of servers you wish to pull data from. | 40 | #### LOGGING 41 | |Key |Description | 42 | |:--------------|:-------------------------------------------------------------------------------------------------------------------| 43 | |Enable |Output logging messages to provided log file | 44 | |Level |Minimum type of message to log. Valid options are: critical, error, warning, info, debug | 45 | |LogFile |File to log messages to. Can be relative or absolute path | 46 | |CensorLogs |Censor certain things like server names and IP addresses from logs | 47 | 48 | 49 | ### Usage 50 | 51 | Enter your desired information in config.ini and run plexInfluxdbCollector.py 52 | 53 | Optionally, you can specify the --config argument to load the config file from a different location. 54 | 55 | A Docker image is also available here: https://hub.docker.com/r/kurzondax/plex-to-influx-extended/ 56 | 57 | #### Requirements 58 | 59 | *Python 3+* 60 | 61 | You will need the [*influxdb library*](https://github.com/influxdata/influxdb-python) installed to use this. Typically this can be installed from the command line using: 62 | 63 | ``` 64 | pip3 install influxdb 65 | ``` 66 | 67 | ## InfluxDB Fields 68 | |Field |Description | 69 | |:------------------|:------------------------------------------------------------------------------------------------------| 70 | |media_type | Movie, TV Show, Music, or Unknown | 71 | |stream_title | For TV and Music: An aggregation of the grandparent title (series or artist name) and track or episode title. For movies, the movie title| 72 | |grandparent_title | Blank for movies. Series title for TV shows. Artist title for music | 73 | |parent_title | Blank for movies. Season title (e.g. Season 1) for TV Shows. Album title for music | 74 | |parent_index | Blank for movies and music. Season number for TV shows | 75 | |title | Movie, episode, or track title | 76 | |year | Blank for music. Series or Movie year of release | 77 | |index | Blank for movies. Episode number for TV Shows. Track number for music. | 78 | |container | File type of the media (e.g. mkv, mp4, mp3, flac, etc.) | 79 | |resolution | Video resolution (e.g. 4k, 1080p, 720p) for movies and TV. Audio bitrate for music. | 80 | |video_codec | Codec for video stream (e.g. h264, h265, mpeg) | 81 | |audio_codec | Codec for audio stream | 82 | |transcode_video | DirectPlay, DirectStream (container remux usually because audio is being transcoded or client doesn't natively support container), or Transcoding| 83 | |transcode_audio | DirectPlay, DirectStream, or Transcode | 84 | |transcode_summary | Reflects transcoding status of both video and audio (or just audio in the case of music) | 85 | |video_framerate | Frame rate for video stream. Blank for music | 86 | |length_ms | Length of track, epsidode, or movie in milliseconds | 87 | |player | Device name of client playing the media | 88 | |user | User name playing the media | 89 | |platform | OS or application playing the media (e.g. Android, Firefox, etc.) | 90 | |start_time | Time when playback session started OR when first seen by Plex-Data-Collector-Extended (see notes below)| 91 | |duration | How long the playback session has been active (see notes below) | 92 | |position_ms | Current playback position in milliseconds | 93 | |pos_percent | Current playback position as a percent of length. Value is between 0 and 1. | 94 | |status | Current session status (playing, paused, buffering) | 95 | 96 | 97 | #### Notes: 98 | The start_time and duration fields are manually calculated by the application based on when it first receives data about a session. It is accurate to within the value used for ***delay*** in the config.ini file. 99 | Since the Plex API doesn't provide retrospective information for streams that are already in-progress, the duration and start_time will be calculated based on when Plex-Data-Collector-Extend is started. 100 | 101 | ***Grafana and start_time field*** 102 | If you are using Grafana to generate a dashboard, the start_time field will appear to have an incorrect date. To resolve this, use a math operator to multiply the start_time field by 1000. 103 | 104 | ## InfluxDB Tags 105 | |Tag |Description | 106 | |:------------------|:------------------------------------------------------------------------------------------------------| 107 | |host |Name of Plex server that stream is being played from | 108 | |player_address |IP address of client | 109 | |session_id |Plex internal ID number for playback session | 110 | 111 | 112 | # Version History 113 | 114 | |Version |Description | 115 | |:------------------|:------------------------------------------------------------------------------------------------------| 116 | |v0.1.3.1 | Fixed dumb mistake with processing the year field | 117 | |v0.1.3 | Fixed issue with TV Shows not being properly written to Now Playing | 118 | |v0.1.2 | Stats for libraries containing TV shows will now be gathered regardless of the actual name of the library. Previously, season and episode stats would only be gathered if the library was specifically named "TV Shows". 119 | | |Collecting of library stats will now only be performed every 30 minutes by default. This should lessen impact of extremely large libraries causing delays in now_playing and active_streams stats being updated. Eventually, I'd like to move the library stat collection out to a separate thread. 120 | | |Added new LibraryDelay option to config.ini under GENERAL section to allow specifying how long (in seconds) to wait between gathering library stats. If the value is missing from the INI, a default value of 1800 seconds (30 minutes) will be used. 121 | |v0.1.1 | Fixed crash when Plex does not provide a year for an active stream | 122 | |v0.1 | Initial release | -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | [GENERAL] 2 | Delay = 2 3 | LibraryDelay = 1800 4 | Output = False 5 | 6 | [INFLUXDB] 7 | Address = 8 | Port = 8086 9 | Database = plex_data 10 | Username = 11 | Password = 12 | Verify_SSL = False 13 | 14 | [PLEX] 15 | Username = 16 | Password = 17 | # List of servers seperated by a comma. 18 | # Example: 192.168.1.20,10.0.0.20 19 | Servers = 20 | 21 | [LOGGING] 22 | Enable = True 23 | # Valid Options: critical, error, warning, info, debug 24 | Level = error 25 | LogFile = output.log 26 | # Removes things such as server names and ip addresses from logs 27 | CensorLogs = True 28 | 29 | # Any log messages greater than or equal to this number will also be printed to the console 30 | # Output must also be true under GENERAL 31 | # DEBUG: 0 32 | # INFO: 1 33 | # WARNING: 2 34 | # ERROR: 3 35 | # CRITICAL: 4 36 | PrintThreshold = 1 -------------------------------------------------------------------------------- /dashboard-example.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_PLEX_DATA", 5 | "label": "Plex Data", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "influxdb", 9 | "pluginName": "InfluxDB" 10 | } 11 | ], 12 | "__requires": [ 13 | { 14 | "type": "panel", 15 | "id": "singlestat", 16 | "name": "Singlestat", 17 | "version": "" 18 | }, 19 | { 20 | "type": "panel", 21 | "id": "table", 22 | "name": "Table", 23 | "version": "" 24 | }, 25 | { 26 | "type": "grafana", 27 | "id": "grafana", 28 | "name": "Grafana", 29 | "version": "4.0.2" 30 | }, 31 | { 32 | "type": "datasource", 33 | "id": "influxdb", 34 | "name": "InfluxDB", 35 | "version": "1.0.0" 36 | } 37 | ], 38 | "id": null, 39 | "title": "Plex Data Example", 40 | "tags": [], 41 | "style": "dark", 42 | "timezone": "browser", 43 | "editable": true, 44 | "sharedCrosshair": false, 45 | "hideControls": false, 46 | "time": { 47 | "from": "now-15m", 48 | "to": "now" 49 | }, 50 | "timepicker": { 51 | "refresh_intervals": [ 52 | "5s", 53 | "10s", 54 | "30s", 55 | "1m", 56 | "5m", 57 | "15m", 58 | "30m", 59 | "1h", 60 | "2h", 61 | "1d" 62 | ], 63 | "time_options": [ 64 | "5m", 65 | "15m", 66 | "1h", 67 | "6h", 68 | "12h", 69 | "24h", 70 | "2d", 71 | "7d", 72 | "30d" 73 | ] 74 | }, 75 | "templating": { 76 | "list": [] 77 | }, 78 | "annotations": { 79 | "list": [] 80 | }, 81 | "refresh": "5s", 82 | "schemaVersion": 13, 83 | "version": 3, 84 | "links": [], 85 | "gnetId": null, 86 | "rows": [ 87 | { 88 | "title": "Dashboard Row", 89 | "panels": [ 90 | { 91 | "cacheTimeout": null, 92 | "colorBackground": false, 93 | "colorValue": true, 94 | "colors": [ 95 | "rgba(50, 172, 45, 0.97)", 96 | "rgba(237, 129, 40, 0.89)", 97 | "rgba(12, 176, 38, 0.9)" 98 | ], 99 | "datasource": "${DS_PLEX_DATA}", 100 | "editable": true, 101 | "error": false, 102 | "format": "none", 103 | "gauge": { 104 | "maxValue": 100, 105 | "minValue": 0, 106 | "show": false, 107 | "thresholdLabels": false, 108 | "thresholdMarkers": true 109 | }, 110 | "id": 4, 111 | "interval": null, 112 | "links": [], 113 | "mappingType": 1, 114 | "mappingTypes": [ 115 | { 116 | "name": "value to text", 117 | "value": 1 118 | }, 119 | { 120 | "name": "range to text", 121 | "value": 2 122 | } 123 | ], 124 | "maxDataPoints": 100, 125 | "nullPointMode": "connected", 126 | "nullText": null, 127 | "postfix": "", 128 | "postfixFontSize": "50%", 129 | "prefix": "", 130 | "prefixFontSize": "50%", 131 | "rangeMaps": [ 132 | { 133 | "from": "null", 134 | "text": "N/A", 135 | "to": "null" 136 | } 137 | ], 138 | "span": 2, 139 | "sparkline": { 140 | "fillColor": "rgba(31, 118, 189, 0.18)", 141 | "full": false, 142 | "lineColor": "rgb(31, 120, 193)", 143 | "show": false 144 | }, 145 | "targets": [ 146 | { 147 | "dsType": "influxdb", 148 | "groupBy": [ 149 | { 150 | "params": [ 151 | "$interval" 152 | ], 153 | "type": "time" 154 | }, 155 | { 156 | "params": [ 157 | "null" 158 | ], 159 | "type": "fill" 160 | } 161 | ], 162 | "measurement": "active_streams", 163 | "policy": "default", 164 | "refId": "A", 165 | "resultFormat": "time_series", 166 | "select": [ 167 | [ 168 | { 169 | "params": [ 170 | "active_streams" 171 | ], 172 | "type": "field" 173 | }, 174 | { 175 | "params": [], 176 | "type": "last" 177 | } 178 | ] 179 | ], 180 | "tags": [ 181 | { 182 | "key": "host", 183 | "operator": "=", 184 | "value": "All" 185 | } 186 | ] 187 | } 188 | ], 189 | "thresholds": "", 190 | "title": "Plex Streams", 191 | "type": "singlestat", 192 | "valueFontSize": "170%", 193 | "valueMaps": [ 194 | { 195 | "op": "=", 196 | "text": "N/A", 197 | "value": "null" 198 | } 199 | ], 200 | "valueName": "current" 201 | }, 202 | { 203 | "cacheTimeout": null, 204 | "colorBackground": false, 205 | "colorValue": true, 206 | "colors": [ 207 | "rgba(50, 172, 45, 0.97)", 208 | "rgba(237, 129, 40, 0.89)", 209 | "rgba(12, 176, 38, 0.9)" 210 | ], 211 | "datasource": "${DS_PLEX_DATA}", 212 | "editable": true, 213 | "error": false, 214 | "format": "none", 215 | "gauge": { 216 | "maxValue": 100, 217 | "minValue": 0, 218 | "show": false, 219 | "thresholdLabels": false, 220 | "thresholdMarkers": true 221 | }, 222 | "id": 5, 223 | "interval": null, 224 | "links": [], 225 | "mappingType": 1, 226 | "mappingTypes": [ 227 | { 228 | "name": "value to text", 229 | "value": 1 230 | }, 231 | { 232 | "name": "range to text", 233 | "value": 2 234 | } 235 | ], 236 | "maxDataPoints": 100, 237 | "nullPointMode": "connected", 238 | "nullText": null, 239 | "postfix": "", 240 | "postfixFontSize": "50%", 241 | "prefix": "", 242 | "prefixFontSize": "50%", 243 | "rangeMaps": [ 244 | { 245 | "from": "null", 246 | "text": "N/A", 247 | "to": "null" 248 | } 249 | ], 250 | "span": 2, 251 | "sparkline": { 252 | "fillColor": "rgba(31, 118, 189, 0.18)", 253 | "full": false, 254 | "lineColor": "rgb(31, 120, 193)", 255 | "show": false 256 | }, 257 | "targets": [ 258 | { 259 | "dsType": "influxdb", 260 | "groupBy": [ 261 | { 262 | "params": [ 263 | "$interval" 264 | ], 265 | "type": "time" 266 | }, 267 | { 268 | "params": [ 269 | "null" 270 | ], 271 | "type": "fill" 272 | } 273 | ], 274 | "measurement": "libraries", 275 | "policy": "default", 276 | "refId": "A", 277 | "resultFormat": "time_series", 278 | "select": [ 279 | [ 280 | { 281 | "params": [ 282 | "items" 283 | ], 284 | "type": "field" 285 | }, 286 | { 287 | "params": [], 288 | "type": "last" 289 | } 290 | ] 291 | ], 292 | "tags": [ 293 | { 294 | "key": "lib_name", 295 | "operator": "=", 296 | "value": "TV Shows" 297 | } 298 | ] 299 | } 300 | ], 301 | "thresholds": "", 302 | "title": "TV Shows", 303 | "type": "singlestat", 304 | "valueFontSize": "170%", 305 | "valueMaps": [ 306 | { 307 | "op": "=", 308 | "text": "N/A", 309 | "value": "null" 310 | } 311 | ], 312 | "valueName": "current" 313 | }, 314 | { 315 | "cacheTimeout": null, 316 | "colorBackground": false, 317 | "colorValue": true, 318 | "colors": [ 319 | "rgba(50, 172, 45, 0.97)", 320 | "rgba(237, 129, 40, 0.89)", 321 | "rgba(12, 176, 38, 0.9)" 322 | ], 323 | "datasource": "${DS_PLEX_DATA}", 324 | "editable": true, 325 | "error": false, 326 | "format": "none", 327 | "gauge": { 328 | "maxValue": 100, 329 | "minValue": 0, 330 | "show": false, 331 | "thresholdLabels": false, 332 | "thresholdMarkers": true 333 | }, 334 | "id": 11, 335 | "interval": null, 336 | "links": [], 337 | "mappingType": 1, 338 | "mappingTypes": [ 339 | { 340 | "name": "value to text", 341 | "value": 1 342 | }, 343 | { 344 | "name": "range to text", 345 | "value": 2 346 | } 347 | ], 348 | "maxDataPoints": 100, 349 | "nullPointMode": "connected", 350 | "nullText": null, 351 | "postfix": "", 352 | "postfixFontSize": "50%", 353 | "prefix": "", 354 | "prefixFontSize": "50%", 355 | "rangeMaps": [ 356 | { 357 | "from": "null", 358 | "text": "N/A", 359 | "to": "null" 360 | } 361 | ], 362 | "span": 2, 363 | "sparkline": { 364 | "fillColor": "rgba(31, 118, 189, 0.18)", 365 | "full": false, 366 | "lineColor": "rgb(31, 120, 193)", 367 | "show": false 368 | }, 369 | "targets": [ 370 | { 371 | "dsType": "influxdb", 372 | "groupBy": [ 373 | { 374 | "params": [ 375 | "$interval" 376 | ], 377 | "type": "time" 378 | }, 379 | { 380 | "params": [ 381 | "null" 382 | ], 383 | "type": "fill" 384 | } 385 | ], 386 | "measurement": "libraries", 387 | "policy": "default", 388 | "refId": "A", 389 | "resultFormat": "time_series", 390 | "select": [ 391 | [ 392 | { 393 | "params": [ 394 | "episodes" 395 | ], 396 | "type": "field" 397 | }, 398 | { 399 | "params": [], 400 | "type": "last" 401 | } 402 | ] 403 | ], 404 | "tags": [ 405 | { 406 | "key": "lib_name", 407 | "operator": "=", 408 | "value": "TV Shows" 409 | } 410 | ] 411 | } 412 | ], 413 | "thresholds": "", 414 | "title": "Episodes", 415 | "type": "singlestat", 416 | "valueFontSize": "170%", 417 | "valueMaps": [ 418 | { 419 | "op": "=", 420 | "text": "N/A", 421 | "value": "null" 422 | } 423 | ], 424 | "valueName": "current" 425 | }, 426 | { 427 | "cacheTimeout": null, 428 | "colorBackground": false, 429 | "colorValue": true, 430 | "colors": [ 431 | "rgba(50, 172, 45, 0.97)", 432 | "rgba(237, 129, 40, 0.89)", 433 | "rgba(12, 176, 38, 0.9)" 434 | ], 435 | "datasource": "${DS_PLEX_DATA}", 436 | "editable": true, 437 | "error": false, 438 | "format": "none", 439 | "gauge": { 440 | "maxValue": 100, 441 | "minValue": 0, 442 | "show": false, 443 | "thresholdLabels": false, 444 | "thresholdMarkers": true 445 | }, 446 | "id": 6, 447 | "interval": null, 448 | "links": [], 449 | "mappingType": 1, 450 | "mappingTypes": [ 451 | { 452 | "name": "value to text", 453 | "value": 1 454 | }, 455 | { 456 | "name": "range to text", 457 | "value": 2 458 | } 459 | ], 460 | "maxDataPoints": 100, 461 | "nullPointMode": "connected", 462 | "nullText": null, 463 | "postfix": "", 464 | "postfixFontSize": "50%", 465 | "prefix": "", 466 | "prefixFontSize": "50%", 467 | "rangeMaps": [ 468 | { 469 | "from": "null", 470 | "text": "N/A", 471 | "to": "null" 472 | } 473 | ], 474 | "span": 2, 475 | "sparkline": { 476 | "fillColor": "rgba(31, 118, 189, 0.18)", 477 | "full": false, 478 | "lineColor": "rgb(31, 120, 193)", 479 | "show": false 480 | }, 481 | "targets": [ 482 | { 483 | "dsType": "influxdb", 484 | "groupBy": [ 485 | { 486 | "params": [ 487 | "$interval" 488 | ], 489 | "type": "time" 490 | }, 491 | { 492 | "params": [ 493 | "null" 494 | ], 495 | "type": "fill" 496 | } 497 | ], 498 | "measurement": "libraries", 499 | "policy": "default", 500 | "refId": "A", 501 | "resultFormat": "time_series", 502 | "select": [ 503 | [ 504 | { 505 | "params": [ 506 | "items" 507 | ], 508 | "type": "field" 509 | }, 510 | { 511 | "params": [], 512 | "type": "last" 513 | } 514 | ] 515 | ], 516 | "tags": [ 517 | { 518 | "key": "lib_name", 519 | "operator": "=", 520 | "value": "Movies" 521 | } 522 | ] 523 | } 524 | ], 525 | "thresholds": "", 526 | "title": "Movies", 527 | "type": "singlestat", 528 | "valueFontSize": "170%", 529 | "valueMaps": [ 530 | { 531 | "op": "=", 532 | "text": "N/A", 533 | "value": "null" 534 | } 535 | ], 536 | "valueName": "current" 537 | }, 538 | { 539 | "cacheTimeout": null, 540 | "colorBackground": false, 541 | "colorValue": true, 542 | "colors": [ 543 | "rgba(50, 172, 45, 0.97)", 544 | "rgba(237, 129, 40, 0.89)", 545 | "rgba(12, 176, 38, 0.9)" 546 | ], 547 | "datasource": "${DS_PLEX_DATA}", 548 | "editable": true, 549 | "error": false, 550 | "format": "none", 551 | "gauge": { 552 | "maxValue": 100, 553 | "minValue": 0, 554 | "show": false, 555 | "thresholdLabels": false, 556 | "thresholdMarkers": true 557 | }, 558 | "id": 12, 559 | "interval": null, 560 | "links": [], 561 | "mappingType": 1, 562 | "mappingTypes": [ 563 | { 564 | "name": "value to text", 565 | "value": 1 566 | }, 567 | { 568 | "name": "range to text", 569 | "value": 2 570 | } 571 | ], 572 | "maxDataPoints": 100, 573 | "nullPointMode": "connected", 574 | "nullText": null, 575 | "postfix": "", 576 | "postfixFontSize": "50%", 577 | "prefix": "", 578 | "prefixFontSize": "50%", 579 | "rangeMaps": [ 580 | { 581 | "from": "null", 582 | "text": "N/A", 583 | "to": "null" 584 | } 585 | ], 586 | "span": 2, 587 | "sparkline": { 588 | "fillColor": "rgba(31, 118, 189, 0.18)", 589 | "full": false, 590 | "lineColor": "rgb(31, 120, 193)", 591 | "show": false 592 | }, 593 | "targets": [ 594 | { 595 | "dsType": "influxdb", 596 | "groupBy": [ 597 | { 598 | "params": [ 599 | "$interval" 600 | ], 601 | "type": "time" 602 | }, 603 | { 604 | "params": [ 605 | "null" 606 | ], 607 | "type": "fill" 608 | } 609 | ], 610 | "measurement": "libraries", 611 | "policy": "default", 612 | "refId": "A", 613 | "resultFormat": "time_series", 614 | "select": [ 615 | [ 616 | { 617 | "params": [ 618 | "seasons" 619 | ], 620 | "type": "field" 621 | }, 622 | { 623 | "params": [], 624 | "type": "last" 625 | } 626 | ] 627 | ], 628 | "tags": [ 629 | { 630 | "key": "lib_name", 631 | "operator": "=", 632 | "value": "TV Shows" 633 | } 634 | ] 635 | } 636 | ], 637 | "thresholds": "", 638 | "title": "Seasons", 639 | "type": "singlestat", 640 | "valueFontSize": "170%", 641 | "valueMaps": [ 642 | { 643 | "op": "=", 644 | "text": "N/A", 645 | "value": "null" 646 | } 647 | ], 648 | "valueName": "current" 649 | } 650 | ], 651 | "showTitle": false, 652 | "titleSize": "h6", 653 | "height": 141, 654 | "repeat": null, 655 | "repeatRowId": null, 656 | "repeatIteration": null, 657 | "collapse": false 658 | }, 659 | { 660 | "title": "Row", 661 | "panels": [ 662 | { 663 | "columns": [], 664 | "datasource": "${DS_PLEX_DATA}", 665 | "editable": true, 666 | "error": false, 667 | "fontSize": "80%", 668 | "id": 10, 669 | "interval": "< 5s", 670 | "links": [], 671 | "pageSize": null, 672 | "scroll": true, 673 | "showHeader": true, 674 | "sort": { 675 | "col": 0, 676 | "desc": true 677 | }, 678 | "span": 12, 679 | "styles": [ 680 | { 681 | "dateFormat": "YYYY-MM-DD HH:mm:ss", 682 | "pattern": "Time", 683 | "type": "date" 684 | }, 685 | { 686 | "colorMode": null, 687 | "colors": [ 688 | "rgba(245, 54, 54, 0.9)", 689 | "rgba(237, 129, 40, 0.89)", 690 | "rgba(50, 172, 45, 0.97)" 691 | ], 692 | "decimals": 2, 693 | "pattern": "/.*/", 694 | "sanitize": false, 695 | "thresholds": [], 696 | "type": "string", 697 | "unit": "short" 698 | } 699 | ], 700 | "targets": [ 701 | { 702 | "dsType": "influxdb", 703 | "groupBy": [ 704 | { 705 | "params": [ 706 | "1m" 707 | ], 708 | "type": "time" 709 | } 710 | ], 711 | "hide": false, 712 | "measurement": "now_playing", 713 | "policy": "default", 714 | "query": "SELECT last(\"stream_title\") AS Title, \"media_type\" AS \"Media Type\", \"resolution\" as Quality, \"player\" as Player, \"user\" as \"User\", \"host\" as \"Server\" FROM \"now_playing\" WHERE time > now() - 10s GROUP BY \"session_id\"", 715 | "rawQuery": true, 716 | "refId": "C", 717 | "resultFormat": "table", 718 | "select": [ 719 | [ 720 | { 721 | "params": [ 722 | "stream_title" 723 | ], 724 | "type": "field" 725 | }, 726 | { 727 | "params": [], 728 | "type": "last" 729 | } 730 | ], 731 | [ 732 | { 733 | "params": [ 734 | "player" 735 | ], 736 | "type": "field" 737 | } 738 | ], 739 | [ 740 | { 741 | "params": [ 742 | "user" 743 | ], 744 | "type": "field" 745 | } 746 | ] 747 | ], 748 | "tags": [ 749 | { 750 | "key": "host", 751 | "operator": "=", 752 | "value": "plex1.ho.me" 753 | } 754 | ] 755 | } 756 | ], 757 | "title": "Active Plex Streams", 758 | "transform": "table", 759 | "type": "table" 760 | } 761 | ], 762 | "showTitle": false, 763 | "titleSize": "h6", 764 | "height": 212, 765 | "repeat": null, 766 | "repeatRowId": null, 767 | "repeatIteration": null, 768 | "collapse": false 769 | } 770 | ] 771 | } -------------------------------------------------------------------------------- /plexInfluxdbCollector.py: -------------------------------------------------------------------------------- 1 | from urllib.request import Request, urlopen 2 | import base64 3 | import json 4 | import os 5 | import sys 6 | import xml.etree.ElementTree as ET 7 | import time 8 | from urllib.error import HTTPError, URLError 9 | import configparser 10 | import logging 11 | import argparse 12 | import re 13 | from http.client import RemoteDisconnected 14 | 15 | from influxdb import InfluxDBClient 16 | from influxdb.exceptions import InfluxDBClientError, InfluxDBServerError 17 | from requests.exceptions import ConnectionError 18 | 19 | 20 | #TODO Build word blacklist for logs. 21 | 22 | class plexInfluxdbCollector(): 23 | 24 | def __init__(self, silent, config=None): 25 | 26 | self.config = configManager(silent, config=config) 27 | 28 | self.servers = self.config.plex_servers 29 | self.output = self.config.output 30 | self.token = None 31 | self.logger = None 32 | self.active_streams = {} # Store active streams so we can track duration 33 | self._report_combined_streams = True # TODO Move to config 34 | self.delay = self.config.delay 35 | self.librarydelay = self.config.librarydelay 36 | self.influx_client = InfluxDBClient( 37 | self.config.influx_address, 38 | self.config.influx_port, 39 | database=self.config.influx_database, 40 | ssl=self.config.influx_ssl, 41 | verify_ssl=self.config.influx_verify_ssl, 42 | username=self.config.influx_user, 43 | password=self.config.influx_password 44 | 45 | ) 46 | self._set_logging() 47 | self._get_auth_token(self.config.plex_user, self.config.plex_password) 48 | 49 | def _set_logging(self): 50 | """ 51 | Create the logger object if enabled in the config 52 | :return: None 53 | """ 54 | 55 | if self.config.logging: 56 | if self.output: 57 | print('Logging is enabled. Log output will be sent to {}'.format(self.config.logging_file)) 58 | self.logger = logging.getLogger(__name__) 59 | self.logger.setLevel(self.config.logging_level) 60 | formatter = logging.Formatter('%(asctime)s %(levelname)s: %(message)s') 61 | fhandle = logging.FileHandler(self.config.logging_file) 62 | fhandle.setFormatter(formatter) 63 | self.logger.addHandler(fhandle) 64 | 65 | def send_log(self, msg, level): 66 | """ 67 | Used as a shim to write log messages. Allows us to sanitize input before logging 68 | :param msg: Message to log 69 | :param level: Level to log message at 70 | :return: None 71 | """ 72 | 73 | if not self.logger: 74 | return 75 | 76 | if self.output and self.config.valid_log_levels[level.upper()] >= self.config.logging_print_threshold: 77 | print(msg) 78 | 79 | # Make sure a good level was given 80 | if not hasattr(self.logger, level): 81 | self.logger.error('Invalid log level provided to send_log') 82 | return 83 | 84 | output = self._sanitize_log_message(msg) 85 | 86 | log_method = getattr(self.logger, level) 87 | log_method(output) 88 | 89 | def _sanitize_log_message(self, msg): 90 | """ 91 | Take the incoming log message and clean and sensitive data out 92 | :param msg: incoming message string 93 | :return: cleaned message string 94 | """ 95 | 96 | msg = str(msg) 97 | 98 | if not self.config.logging_censor: 99 | return msg 100 | 101 | msg = msg.replace(self.config.plex_user, '********') 102 | if self.token: 103 | msg = msg.replace(self.token, '********') 104 | 105 | # Remove server addresses 106 | for server in self.servers: 107 | msg = msg.replace(server, '*******') 108 | 109 | # Remove IP addresses 110 | for match in re.findall(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b", msg): 111 | msg = msg.replace(match, '***.***.***.***') 112 | 113 | return msg 114 | 115 | def _get_auth_token(self, username, password): 116 | """ 117 | Make a reqest to plex.tv to get an authentication token for future requests 118 | :param username: Plex Username 119 | :param password: Plex Password 120 | :return: 121 | """ 122 | 123 | self.send_log('Getting Auth Token For User {}'.format(username), 'info') 124 | 125 | auth_string = '{}:{}'.format(username, password) 126 | base_auth = base64.encodebytes(bytes(auth_string, 'utf-8')) 127 | req = Request('https://plex.tv/users/sign_in.json') 128 | req = self._set_default_headers(req) 129 | req.add_header('Authorization', 'Basic {}'.format(base_auth[:-1].decode('utf-8'))) 130 | 131 | try: 132 | result = urlopen(req, data=b'').read() 133 | except HTTPError as e: 134 | print('Failed To Get Authentication Token') 135 | if e.code == 401: 136 | print('This is likely due to a bad username or password') 137 | self.send_log('Failed to get token due to bad username/password', 'error') 138 | else: 139 | print('Maybe this will help:') 140 | print(e) 141 | self.send_log('Failed to get authentication token. No idea why', 'error') 142 | sys.exit(1) 143 | 144 | output = json.loads(result.decode('utf-8')) 145 | 146 | # Make sure we actually got a token back 147 | if 'authToken' in output['user']: 148 | self.token = output['user']['authToken'] 149 | else: 150 | print('Something Broke \n We got a valid response but for some reason there\'s no auth token') 151 | sys.exit(1) 152 | 153 | self.send_log('Successfully Retrieved Auth Token Of: {}'.format(self.token), 'info') 154 | 155 | def _set_default_headers(self, req): 156 | """ 157 | Sets the default headers need for a request 158 | :param req: 159 | :return: 160 | """ 161 | 162 | self.send_log('Adding Request Headers', 'debug') 163 | 164 | headers = { 165 | 'X-Plex-Client-Identifier': 'Plex InfluxDB Collector', 166 | 'X-Plex-Product': 'Plex InfluxDB Collector', 167 | 'X-Plex-Version': '1', 168 | 'X-Plex-Token': self.token 169 | } 170 | 171 | for k, v in headers.items(): 172 | if k == 'X-Plex-Token' and not self.token: # Don't add token if we don't have it yet 173 | continue 174 | 175 | req.add_header(k, v) 176 | 177 | return req 178 | 179 | def get_active_streams(self): 180 | """ 181 | Processes the Plex session list 182 | :return: 183 | """ 184 | self.send_log('Getting active streams', 'debug') 185 | active_streams = {} 186 | 187 | for server in self.servers: 188 | req_uri = 'http://{}:32400/status/sessions'.format(server) 189 | 190 | self.send_log('Attempting to get all libraries with URL: {}'.format(req_uri), 'info') 191 | 192 | req = Request(req_uri) 193 | self._set_default_headers(req) 194 | 195 | try: 196 | result = urlopen(req).read().decode('utf-8') 197 | except URLError as e: 198 | self.send_log('Failed To Get Current Sessions', 'error') 199 | return 200 | 201 | streams = ET.fromstring(result) 202 | 203 | active_streams[server] = streams 204 | 205 | self._process_active_streams(active_streams) 206 | 207 | def _get_session_id(self, stream): 208 | """ 209 | Find a unique key to identify the stream. In most cases it will be the sessionKey. If this does not exist, 210 | fall back to the TranscodeSession key. 211 | :param stream: XML object of the stream 212 | :return: 213 | """ 214 | session = stream.find('Session') 215 | 216 | if 'sessionKey' in stream.attrib: 217 | return stream.attrib['sessionKey'] 218 | 219 | if session: 220 | return session.attrib['id'] 221 | 222 | transcodeSession = stream.find('TranscodeSession') 223 | 224 | if transcodeSession: 225 | return transcodeSession.attrib['id'] 226 | 227 | return 'N/A' 228 | 229 | def _process_active_streams(self, stream_data): 230 | """ 231 | Take an object of stream data and create Influx JSON data 232 | :param stream_data: 233 | :return: 234 | """ 235 | # TODO: Add width x height 236 | 237 | self.send_log('Processing Active Streams', 'info') 238 | 239 | combined_streams = 0 240 | 241 | session_ids = [] # Active Session IDs for this run 242 | 243 | for host, streams in stream_data.items(): 244 | 245 | combined_streams += len(streams) 246 | combined_video_transcodes = 0 247 | combined_audio_transcodes = 0 248 | 249 | for stream in streams: 250 | 251 | # streamxml=ET.tostring(stream, encoding='utf8', method='xml') 252 | # self.send_log(streamxml, 'debug') 253 | session_id = self._get_session_id(stream) 254 | session_ids.append(session_id) 255 | 256 | #Initialize vars 257 | full_title = "Unknown" 258 | if stream.find('Player') is not None: 259 | player_title = stream.find('Player').attrib['title'] 260 | user_title = stream.find('User').attrib['title'] 261 | else: 262 | player_title = "Unknown" 263 | user_title = "Unknown" 264 | resolution = "" 265 | container = "" 266 | video_codec = "" 267 | audio_codec = "" 268 | length_ms = 0 269 | grandparent_title = "" 270 | parent_title = "" 271 | parent_index = "" 272 | title = "" 273 | index = "" 274 | year = "" 275 | player_state = "" 276 | platform = "" 277 | position = 0 278 | pos_percent = 0.0 279 | transcode_video = "" 280 | transcode_audio = "" 281 | transcode_summary = "" 282 | video_codec = "" 283 | video_framerate = "" 284 | 285 | if session_id in self.active_streams: 286 | start_time = self.active_streams[session_id]['start_time'] 287 | else: 288 | start_time = time.time() 289 | self.active_streams[session_id] = {} 290 | self.active_streams[session_id]['start_time'] = start_time 291 | 292 | if stream.attrib['type'] == 'movie': 293 | media_type = 'Movie' 294 | elif stream.attrib['type'] == 'episode': 295 | media_type = 'TV Show' 296 | elif stream.attrib['type'] == 'track': 297 | media_type = 'Music' 298 | else: 299 | media_type = 'Unknown' 300 | 301 | # Don't error out on unknown stream types 302 | if media_type != 'Unknown': 303 | # Build the title. TV and Music Have a root title plus episode/track name. Movies don't 304 | if 'grandparentTitle' in stream.attrib: 305 | full_title = stream.attrib['grandparentTitle'] + ' - ' + stream.attrib['title'] 306 | grandparent_title = stream.attrib['grandparentTitle'] 307 | else: 308 | full_title = stream.attrib['title'] 309 | grandparent_title = "" 310 | 311 | if media_type == 'TV Show' or media_type == 'Music': 312 | if 'parentTitle' in stream.attrib: 313 | parent_title = stream.attrib['parentTitle'] 314 | else: 315 | parent_title = "" 316 | if 'parentIndex' in stream.attrib and media_type == 'TV Show': 317 | parent_index = stream.attrib['parentIndex'] 318 | else: 319 | parent_index = "" 320 | else: 321 | parent_index = "" 322 | parent_title = "" 323 | 324 | if media_type != 'Music': 325 | resolution = stream.find('Media').attrib['videoResolution'] 326 | if 'year' in stream.attrib: 327 | year = stream.attrib['year'] 328 | transcode_summary = "V: No " 329 | if stream.find('TranscodeSession') is not None: 330 | if stream.find('TranscodeSession').attrib['videoDecision'] == 'transcode': 331 | transcode_video = "Transcoding" 332 | combined_video_transcodes += 1 333 | transcode_summary = "V: Yes " 334 | else: 335 | transcode_video = "DirectStream" 336 | else: 337 | transcode_video = "DirectPlay" 338 | video_framerate = stream.find('Media').attrib['videoFrameRate'] 339 | video_codec = stream.find('Media').attrib['videoCodec'] 340 | else: 341 | resolution = stream.find('Media').attrib['bitrate'] + ' Kbps' 342 | 343 | # Common fields 344 | audio_codec = stream.find('Media').attrib['audioCodec'] 345 | if stream.find('TranscodeSession') is not None: 346 | if stream.find('TranscodeSession').attrib['audioDecision'] == 'transcode': 347 | transcode_audio = "Transcoding" 348 | combined_audio_transcodes += 1 349 | transcode_summary += "A: Yes" 350 | else: 351 | transcode_audio = "DirectStream" 352 | transcode_summary += "A: No" 353 | else: 354 | transcode_audio = "DirectPlay" 355 | transcode_summary += "A: No" 356 | 357 | container = stream.find('Media').attrib['container'] 358 | length_ms = stream.find('Media').attrib['duration'] 359 | position = stream.attrib['viewOffset'] 360 | platform = stream.find('Player').attrib['platform'] 361 | title = stream.attrib['title'] 362 | 363 | # Calculate percent of total length played 364 | if int(position) > 0 and int(length_ms) >0: 365 | pos_percent = round(int(position) / int(length_ms), 4) 366 | 367 | # index is typically the episode number for TV, or track number for music 368 | if 'index' in stream.attrib: 369 | index = stream.attrib['index'] 370 | else: 371 | index = "" 372 | 373 | # playing, paused, buffering 374 | if 'state' in stream.find('Player').attrib: 375 | player_state = stream.find('Player').attrib['state'] 376 | else: 377 | player_state = "Unavailable" 378 | 379 | self.send_log('Title: {}'.format(full_title), 'debug') 380 | self.send_log('Media Type: {}'.format(media_type), 'debug') 381 | self.send_log('Session ID: {}'.format(session_id), 'debug') 382 | self.send_log('Resolution: {}'.format(resolution), 'debug') 383 | self.send_log('Duration: {}'.format(str(time.time() - start_time)), 'debug') 384 | self.send_log('Transcode Video: {}'.format(transcode_video), 'debug') 385 | self.send_log('Transcode Audio: {}'.format(transcode_audio), 'debug') 386 | self.send_log('Container: {}'.format(container), 'debug') 387 | self.send_log('Video Codec: {}'.format(video_codec), 'debug') 388 | self.send_log('Audio Codec: {}'.format(audio_codec), 'debug') 389 | self.send_log('Length ms: {}'.format(length_ms), 'debug') 390 | self.send_log('Position: {}'.format(position), 'debug') 391 | self.send_log('Pos Percent: {}'.format(pos_percent), 'debug') 392 | 393 | playing_points = [ 394 | { 395 | 'measurement': 'now_playing', 396 | 'fields': { 397 | 'stream_title': full_title, 398 | 'player': player_title, 399 | 'user': user_title, 400 | 'resolution': resolution, 401 | 'media_type': media_type, 402 | 'duration': time.time() - start_time, 403 | 'start_time': start_time, 404 | 'transcode_video': transcode_video, 405 | 'transcode_audio': transcode_audio, 406 | 'transcode_summary': transcode_summary, 407 | 'container': container, 408 | 'video_codec': video_codec, 409 | 'audio_codec': audio_codec, 410 | 'length_ms': int(length_ms), 411 | 'grandparent_title': grandparent_title, 412 | 'parent_title': parent_title, 413 | 'parent_index': parent_index, 414 | 'title': title, 415 | 'index': index, 416 | 'year': year, 417 | 'status': player_state, 418 | 'platform': platform, 419 | 'position_ms': position, 420 | 'pos_percent': pos_percent, 421 | 'video_framerate': video_framerate 422 | }, 423 | 'tags': { 424 | 'host': host, 425 | 'player_address': stream.find('Player').attrib['address'], 426 | 'session_id': session_id 427 | } 428 | } 429 | ] 430 | 431 | self.write_influx_data(playing_points) 432 | 433 | # Record total streams for this host 434 | total_stream_points = [ 435 | { 436 | 'measurement': 'active_streams', 437 | 'fields': { 438 | 'active_streams': len(streams), 439 | 'video_transcodes': combined_video_transcodes, 440 | 'audio_transcodes': combined_audio_transcodes 441 | }, 442 | 'tags': { 443 | 'host': host 444 | } 445 | } 446 | ] 447 | self.write_influx_data(total_stream_points) 448 | 449 | 450 | 451 | # Report total streams across all hosts 452 | if self._report_combined_streams: 453 | combined_stream_points = [ 454 | { 455 | 'measurement': 'active_streams', 456 | 'fields': { 457 | 'active_streams': combined_streams 458 | }, 459 | 'tags': { 460 | 'host': 'All' 461 | } 462 | } 463 | ] 464 | 465 | self.write_influx_data(combined_stream_points) 466 | self._remove_dead_streams(session_ids) 467 | 468 | def _remove_dead_streams(self, current_streams): 469 | """ 470 | Go through the stored list of active streams and remove any that are no longer active 471 | :param current_streams: List of currently active streams from last API call 472 | :return: 473 | """ 474 | remove_keys = [] 475 | for id, data in self.active_streams.items(): 476 | if id not in current_streams: 477 | remove_keys.append(id) 478 | for key in remove_keys: 479 | self.active_streams.pop(key) 480 | 481 | def get_library_data(self): 482 | """ 483 | Get all library data for each provided server. 484 | """ 485 | # TODO This might take ages in large libraries. Add a seperate delay for this check 486 | lib_data = {} 487 | 488 | for server in self.servers: 489 | req_uri = 'http://{}:32400/library/sections'.format(server) 490 | self.send_log('Attempting to get all libraries with URL: {}'.format(req_uri), 'info') 491 | req = Request(req_uri) 492 | req = self._set_default_headers(req) 493 | 494 | try: 495 | result = urlopen(req).read().decode('utf-8') 496 | except (URLError, RemoteDisconnected) as e: 497 | self.send_log('ERROR: Failed To Get Library Data From {}'.format(req_uri), 'error') 498 | return 499 | 500 | libs = ET.fromstring(result) 501 | 502 | self.send_log('We found {} libraries for server {}'.format(str(len(libs)), server), 'info') 503 | 504 | host_libs = [] 505 | if len(libs) > 0: 506 | """ 507 | lib_keys = [lib.attrib['key'] for lib in libs] # TODO probably should catch exception here 508 | self.send_log('Scanning libraries on server {} with keys {}'.format(server, ','.join(lib_keys)), 'info') 509 | """ 510 | for lib in libs: 511 | libkey = lib.attrib['key'] 512 | libtype = lib.attrib['type'] 513 | req_uri = 'http://{}:32400/library/sections/{}/all'.format(server, libkey) 514 | self.send_log('Attempting to get library {} of type {} with URL: {}'.format(libkey, libtype, req_uri), 'info') 515 | req = Request(req_uri) 516 | req = self._set_default_headers(req) 517 | 518 | try: 519 | result = urlopen(req).read().decode('utf-8') 520 | except URLError as e: 521 | self.send_log('Failed to get library {}. {}'.format(libkey, e), 'error') 522 | continue 523 | 524 | # self.send_log(result, 'debug') 525 | 526 | lib_root = ET.fromstring(result) 527 | host_lib = { 528 | 'name': lib_root.attrib['librarySectionTitle'], 529 | 'items': len(lib_root) 530 | } 531 | if libtype == "show": 532 | host_lib['episodes'] = 0 533 | host_lib['seasons'] = 0 534 | for show in lib_root: 535 | host_lib['episodes'] += int(show.attrib['leafCount']) 536 | host_lib['seasons'] += int(show.attrib['childCount']) 537 | host_libs.append(host_lib) 538 | lib_data[server] = host_libs 539 | 540 | self._process_library_data(lib_data) 541 | 542 | def _process_library_data(self, lib_data): 543 | """ 544 | Breakdown the provided library data and format for InfluxDB 545 | """ 546 | 547 | self.send_log('Processing Library Data', 'info') 548 | 549 | for host, data in lib_data.items(): 550 | for lib in data: 551 | fields = { 552 | 'items': lib['items'], 553 | } 554 | for c in ['episodes', 'seasons']: 555 | if c in lib: 556 | fields[c] = lib[c] 557 | lib_points = [ 558 | { 559 | 'measurement': 'libraries', 560 | 'fields': fields, 561 | 'tags': { 562 | 'lib_name': lib['name'], 563 | 'host': host 564 | } 565 | } 566 | ] 567 | self.write_influx_data(lib_points) 568 | 569 | def write_influx_data(self, json_data): 570 | """ 571 | Writes the provided JSON to the database 572 | :param json_data: 573 | :return: 574 | """ 575 | self.send_log(json_data, 'debug') 576 | 577 | try: 578 | self.influx_client.write_points(json_data) 579 | except (InfluxDBClientError, ConnectionError, InfluxDBServerError) as e: 580 | if hasattr(e, 'code') and e.code == 404: 581 | 582 | self.send_log('Database {} Does Not Exist. Attempting To Create', 'error') 583 | 584 | # TODO Grab exception here 585 | self.influx_client.create_database(self.config.influx_database) 586 | self.influx_client.write_points(json_data) 587 | 588 | return 589 | 590 | self.send_log('Failed to write data to InfluxDB', 'error') 591 | 592 | self.send_log('Written To Influx: {}'.format(json_data), 'debug') 593 | 594 | def run(self): 595 | 596 | self.send_log('Starting Monitoring Loop with delay {} and librarydelay {}'.format(self.delay, self.librarydelay), 'info') 597 | sleeptime = self.librarydelay 598 | while True: 599 | if sleeptime >= self.librarydelay: 600 | self.get_library_data() 601 | sleeptime = 0 602 | self.get_active_streams() 603 | time.sleep(self.delay) 604 | sleeptime += self.delay 605 | 606 | 607 | class configManager(): 608 | 609 | def __init__(self, silent, config): 610 | 611 | self.valid_log_levels = { 612 | 'DEBUG': 0, 613 | 'INFO': 1, 614 | 'WARNING': 2, 615 | 'ERROR': 3, 616 | 'CRITICAL': 4 617 | } 618 | self.silent = silent 619 | 620 | if not self.silent: 621 | print('Loading Configuration File {}'.format(config)) 622 | 623 | config_file = os.path.join(os.getcwd(), config) 624 | if os.path.isfile(config_file): 625 | self.config = configparser.ConfigParser() 626 | self.config.read(config_file) 627 | else: 628 | print('ERROR: Unable To Load Config File: {}'.format(config_file)) 629 | sys.exit(1) 630 | 631 | self._load_config_values() 632 | self._validate_plex_servers() 633 | self._validate_logging_level() 634 | if not self.silent: 635 | print('Configuration Successfully Loaded') 636 | 637 | def _load_config_values(self): 638 | 639 | # General 640 | self.delay = self.config['GENERAL'].getint('Delay', fallback=2) 641 | self.librarydelay = self.config['GENERAL'].getint('LibraryDelay', fallback=1800) 642 | if not self.silent: 643 | self.output = self.config['GENERAL'].getboolean('Output', fallback=True) 644 | else: 645 | self.output = None 646 | 647 | # InfluxDB 648 | self.influx_address = self.config['INFLUXDB']['Address'] 649 | self.influx_port = self.config['INFLUXDB'].getint('Port', fallback=8086) 650 | self.influx_database = self.config['INFLUXDB'].get('Database', fallback='plex_data') 651 | self.influx_ssl = self.config['INFLUXDB'].getboolean('SSL', fallback=False) 652 | self.influx_verify_ssl = self.config['INFLUXDB'].getboolean('Verify_SSL', fallback=True) 653 | self.influx_user = self.config['INFLUXDB'].get('Username', fallback='') 654 | self.influx_password = self.config['INFLUXDB'].get('Password', fallback='', raw=True) 655 | 656 | # Plex 657 | self.plex_user = self.config['PLEX']['Username'] 658 | self.plex_password = self.config['PLEX'].get('Password', raw=True) 659 | servers = len(self.config['PLEX']['Servers']) 660 | 661 | #Logging 662 | self.logging = self.config['LOGGING'].getboolean('Enable', fallback=False) 663 | self.logging_level = self.config['LOGGING']['Level'].upper() 664 | self.logging_file = self.config['LOGGING']['LogFile'] 665 | self.logging_censor = self.config['LOGGING'].getboolean('CensorLogs', fallback=True) 666 | self.logging_print_threshold = self.config['LOGGING'].getint('PrintThreshold', fallback=2) 667 | 668 | if servers: 669 | self.plex_servers = self.config['PLEX']['Servers'].replace(' ', '').split(',') 670 | else: 671 | print('ERROR: No Plex Servers Provided. Aborting') 672 | sys.exit(1) 673 | 674 | def _validate_plex_servers(self): 675 | """ 676 | Make sure the servers provided in the config can be resolved. Abort if they can't 677 | :return: 678 | """ 679 | failed_servers = [] 680 | for server in self.plex_servers: 681 | server_url = 'http://{}:32400'.format(server) 682 | try: 683 | urlopen(server_url) 684 | except URLError as e: 685 | # If it's 401 it's a valid server but we're not authorized yet 686 | if hasattr(e, 'code') and e.code == 401: 687 | continue 688 | if not self.silent: 689 | print('ERROR: Failed To Connect To Plex Server At: ' + server_url) 690 | failed_servers.append(server) 691 | 692 | # Do we have any valid servers left? 693 | # TODO This check is failing even with no bad servers 694 | if len(self.plex_servers) != len(failed_servers): 695 | if not self.silent: 696 | print('INFO: Found {} Bad Server(s). Removing Them From List'.format(str(len(failed_servers)))) 697 | for server in failed_servers: 698 | self.plex_servers.remove(server) 699 | else: 700 | print('ERROR: No Valid Servers Provided. Check Server Addresses And Try Again') 701 | sys.exit(1) 702 | 703 | def _validate_logging_level(self): 704 | """ 705 | Make sure we get a valid logging level 706 | :return: 707 | """ 708 | 709 | if self.logging_level in self.valid_log_levels: 710 | self.logging_level = self.logging_level.upper() 711 | return 712 | else: 713 | if not self.silent: 714 | print('Invalid logging level provided. {}'.format(self.logging_level)) 715 | print('Logging will be disabled') 716 | self.logging = None 717 | 718 | 719 | def main(): 720 | 721 | parser = argparse.ArgumentParser(description="A tool to send Plex statistics to InfluxDB") 722 | parser.add_argument('--config', default='config.ini', dest='config', help='Specify a custom location for the config file') 723 | parser.add_argument('--silent', action='store_true', help='Surpress All Output, regardless of config settings') 724 | args = parser.parse_args() 725 | collector = plexInfluxdbCollector(args.silent, config=args.config) 726 | collector.run() 727 | 728 | 729 | if __name__ == '__main__': 730 | main() 731 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | influxdb 2 | request 3 | --------------------------------------------------------------------------------