From 05426f94a363eee7468eb078fcdefbd8ed289e75 Mon Sep 17 00:00:00 2001 From: David Newhall Date: Sun, 24 Oct 2021 12:41:29 -0700 Subject: [PATCH] Client Route Update (#115) * Add gaps and cron timers * fix lint * use terminal-notifier for macOS toasts * update deps * udpate go ver * add more ui notifications * update client route * take more settings from the website * general cleanup * add max body for debug messages * changes since we last spoke * fix services logger, move menus * minor fixes * a bunch more changes * re-do host uid * do not need machineid anymore * fix services log and docker platform * updates * missing file * update modules * fix plex type * more fixes * fix lint * add session tracker * use seconds instead of duration * plex sessions tweaks * add radarr import lists * update plex version to 1.24.4.5081 * update vendors * Add Tautulli integration * fix lint * readme updates. * add sabnzbd support * fix reload, fix more plex, allow username map in tautulli * continue not return * minor fixes * updated docker builds * add next/latest to deluge/qbit * support getting all (non-local) disks for snapshot. * fix build --- Makefile | 2 + README.md | 148 ++++-- examples/notifiarr.conf.example | 84 +-- go.mod | 34 +- go.sum | 25 +- init/bsd/freebsd.rc.d | 2 +- pkg/apps/apps.go | 8 + pkg/apps/downloaders.go | 8 + pkg/apps/radarr.go | 43 +- pkg/apps/sabnzbd.go | 317 +++++++++++ pkg/apps/sonarr.go | 1 - pkg/apps/tautulli.go | 102 ++++ pkg/bindata/bindata.go | 8 +- pkg/client/cli.go | 1 + pkg/client/client_other.go | 1 + pkg/client/handlers.go | 27 +- pkg/client/init.go | 23 +- pkg/client/start.go | 201 ++++--- pkg/client/tray.go | 266 ++++++---- pkg/client/tray_commands.go | 108 +--- pkg/client/webserver.go | 22 - pkg/configfile/config.go | 86 +-- pkg/configfile/{allowedips.go => helper.go} | 29 +- pkg/configfile/template.go | 176 +++--- pkg/logs/logfiles.go | 5 + pkg/logs/logs.go | 4 +- pkg/logs/logs_linux.go | 4 +- pkg/logs/logs_others.go | 3 +- pkg/mnd/constants.go | 5 +- pkg/notifiarr/cfsync.go | 213 ++++---- pkg/notifiarr/clientinfo.go | 315 ++++++++--- pkg/notifiarr/{state.go => dashboard.go} | 231 ++++++-- pkg/notifiarr/gaps.go | 66 +-- pkg/notifiarr/handlers.go | 107 ++-- pkg/notifiarr/notifiarr.go | 499 +++++++----------- pkg/notifiarr/plexcron.go | 117 ++-- pkg/notifiarr/snapcron.go | 25 +- pkg/notifiarr/stuckitems.go | 123 +++-- pkg/notifiarr/timers.go | 278 ++++++---- pkg/notifiarr/website.go | 366 +++++++++++++ pkg/plex/handlers.go | 4 +- pkg/plex/plex.go | 36 +- pkg/plex/sections.go | 64 +-- pkg/plex/sessions.go | 83 +-- pkg/plex/types.go | 186 ++++--- pkg/services/apps.go | 68 ++- pkg/services/checks.go | 7 +- pkg/services/config.go | 23 +- pkg/services/interface.go | 24 +- pkg/services/services.go | 171 +++--- pkg/snapshot/diskusage.go | 9 +- pkg/snapshot/memory_linux.go | 4 +- pkg/snapshot/snapshot.go | 48 +- pkg/snapshot/synology.go | 30 +- pkg/snapshot/temperature_bsd.go | 53 -- .../{temperature_other.go => temperatures.go} | 23 +- settings.sh | 9 + 57 files changed, 3053 insertions(+), 1872 deletions(-) create mode 100644 pkg/apps/sabnzbd.go create mode 100644 pkg/apps/tautulli.go rename pkg/configfile/{allowedips.go => helper.go} (57%) rename pkg/notifiarr/{state.go => dashboard.go} (74%) create mode 100644 pkg/notifiarr/website.go delete mode 100644 pkg/snapshot/temperature_bsd.go rename pkg/snapshot/{temperature_other.go => temperatures.go} (58%) diff --git a/Makefile b/Makefile index a8a72075d..1a491b76b 100644 --- a/Makefile +++ b/Makefile @@ -352,6 +352,7 @@ test: lint go test -race -covermode=atomic ./... lint: # Checking lint. + $(shell go env GOPATH)/bin/golangci-lint version GOOS=linux $(shell go env GOPATH)/bin/golangci-lint run $(GOLANGCI_LINT_ARGS) GOOS=freebsd $(shell go env GOPATH)/bin/golangci-lint run $(GOLANGCI_LINT_ARGS) GOOS=windows $(shell go env GOPATH)/bin/golangci-lint run $(GOLANGCI_LINT_ARGS) @@ -376,6 +377,7 @@ generate: mockgen bindata docker: docker build -f init/docker/Dockerfile \ + --no-cache --pull \ --build-arg "BUILD_DATE=$(DATE)" \ --build-arg "COMMIT=$(COMMIT)" \ --build-arg "VERSION=$(VERSION)-$(ITERATION)" \ diff --git a/README.md b/README.md index ff907ab61..ebec0d991 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ docker logs #### Docker Environment Variables See below for more information about which environment variables are available. -You must set `--privileged` when `monitor_drives=true`. +You must set `--privileged` when `monitor drives` is enabled on the website. ```shell docker pull golift/notifiarr @@ -108,7 +108,6 @@ docker run -d --privileged \ -e "DN_API_KEY=abcdef-12345-bcfead-43312-bbbaaa-123" \ -e "DN_SONARR_0_URL=http://localhost:8989" \ -e "DN_SONARR_0_API_KEY=kjsdkasjdaksdj" \ - -e "DN_SNAPSHOT_MONITOR_DRIVES=true" \ golift/notifiarr docker logs ``` @@ -126,55 +125,104 @@ docker logs |Config Name|Variable Name|Default / Note| |---|---|---| -api_key|`DN_API_KEY`|**Required** / API Key from Notifiarr.com| -bind_addr|`DN_BIND_ADDR`|`0.0.0.0:5454` / The IP and port to listen on| -quiet|`DN_QUIET`|`false` / Turns off output. Set a log_file if this is true| -urlbase|`DN_URLBASE`|default: `/` Change the web root with this setting| -upstreams|`DN_UPSTREAMS_0`|List of upstream networks that can set X-Forwarded-For| -ssl_key_file|`DN_SSL_KEY_FILE`|Providing SSL files turns on the SSL listener| -ssl_cert_file|`DN_SSL_CERT_FILE`|Providing SSL files turns on the SSL listener| -log_file|`DN_LOG_FILE`|None by default. Optionally provide a file path to save app logs| -http_log|`DN_HTTP_LOG`|None by default. Provide a file path to save HTTP request logs| -log_file_mb|`DN_LOG_FILE_MB`|`100` / Max size of log files in megabytes| -log_files|`DN_LOG_FILES`|`10` / Log files to keep after rotating. `0` disables rotation| -send_dash|`DN_SEND_DASH`|`0` / How often to send dashboard state. `0` disables, `30m` = minimum| -timeout|`DN_TIMEOUT`|`60s` / Global API Timeouts (all apps default)| - -#### Lidarr +|api_key|`DN_API_KEY`|**Required** / API Key from Notifiarr.com| +|auto_update|`DN_AUTO_UPDATE`|`off` / Set to `daily` to turn on automatic updates (windows only)| +|bind_addr|`DN_BIND_ADDR`|`0.0.0.0:5454` / The IP and port to listen on| +|quiet|`DN_QUIET`|`false` / Turns off output. Set a log_file if this is true| +|urlbase|`DN_URLBASE`|default: `/` Change the web root with this setting| +|upstreams|`DN_UPSTREAMS_0`|List of upstream networks that can set X-Forwarded-For| +|ssl_key_file|`DN_SSL_KEY_FILE`|Providing SSL files turns on the SSL listener| +|ssl_cert_file|`DN_SSL_CERT_FILE`|Providing SSL files turns on the SSL listener| +|log_file|`DN_LOG_FILE`|None by default. Optionally provide a file path to save app logs| +|http_log|`DN_HTTP_LOG`|None by default. Provide a file path to save HTTP request logs| +|log_file_mb|`DN_LOG_FILE_MB`|`100` / Max size of log files in megabytes| +|log_files|`DN_LOG_FILES`|`10` / Log files to keep after rotating. `0` disables rotation| +|file_mode|`DN_FILE_MODE`|`"0600"` / Unix octal filemode for new log files| +|timeout|`DN_TIMEOUT`|`60s` / Global API Timeouts (all apps default)| + +All applications below (starr, downloaders, tautulli, plex) have a `timeout` setting. +If the configuration for an application is missing the timeout, the global timeout (above) is used. + +### Secret Settings + +Recommend not messing with these unless instructed to do so. + +|Config Name|Variable Name|Default / Note| +|---|---|---| +|mode|`DN_MODE`|`production` / Change application mode: `development` or `production`| +|debug|`DN_DEBUG`|`false` / Adds payloads and other stuff to the log output; very verbose/noisy| +|debug_log|`DN_DEBUG_LOG`|`""` / Set a file system path to write debug logs to a dedicated file| +|max_body|`DN_MAX_BODY`|Unlimited, `0` / Maximum debug-log body size (integer) for payloads to and from notifiarr.com| + +All Starr apps (below) also allow a `max_body` parameter. This parameter only controls debug output. +Debug-log payload sizes from each app can be controlled individually. + +_Note: You may disable the GUI (menu item) on Windows by setting the env variable `USEGUI` to `false`._ + +### Lidarr |Config Name|Variable Name|Note| |---|---|---| -lidarr.name|`DN_LIDARR_0_NAME`|No Default. Setting a name enabled service checks.| +lidarr.name|`DN_LIDARR_0_NAME`|No Default. Setting a name enables service checks| lidarr.url|`DN_LIDARR_0_URL`|No Default. Something like: `http://lidarr:8686`| lidarr.api_key|`DN_LIDARR_0_API_KEY`|No Default. Provide URL and API key if you use Readarr| -#### Radarr +### Radarr |Config Name|Variable Name|Note| |---|---|---| -radarr.name|`DN_RADARR_0_NAME`|No Default. Setting a name enabled service checks.| +radarr.name|`DN_RADARR_0_NAME`|No Default. Setting a name enables service checks.| radarr.url|`DN_RADARR_0_URL`|No Default. Something like: `http://localhost:7878`| radarr.api_key|`DN_RADARR_0_API_KEY`|No Default. Provide URL and API key if you use Radarr| -radarr.disable_cf|`DN_RADARR_0_DISABLE_CF`|`false` / Setting true disables custom format sync.| -#### Readarr +### Readarr |Config Name|Variable Name|Note| |---|---|---| -readarr.name|`DN_READARR_0_NAME`|No Default. Setting a name enabled service checks.| +readarr.name|`DN_READARR_0_NAME`|No Default. Setting a name enables service checks| readarr.url|`DN_READARR_0_URL`|No Default. Something like: `http://localhost:8787`| readarr.api_key|`DN_READARR_0_API_KEY`|No Default. Provide URL and API key if you use Readarr| -#### Sonarr +### Sonarr |Config Name|Variable Name|Note| |---|---|---| -sonarr.name|`DN_SONARR_0_NAME`|No Default. Setting a name enabled service checks.| +sonarr.name|`DN_SONARR_0_NAME`|No Default. Setting a name enables service checks| sonarr.url|`DN_SONARR_0_URL`|No Default. Something like: `http://localhost:8989`| sonarr.api_key|`DN_SONARR_0_API_KEY`|No Default. Provide URL and API key if you use Sonarr| -sonarr.disable_cf|`DN_SONARR_0_DISABLE_CF`|`false` / Setting true disables release profile sync.| -#### Plex +### Downloaders + +You can add supported downloaders so they show up on the dashboard integration. +You may easily add service checks to these downloaders by adding a name. +Any number of downloaders of any type may be configured. + +#### QbitTorrent + +|Config Name|Variable Name|Note| +|---|---|---| +qbit.name|`DN_QBIT_0_NAME`|No Default. Setting a name enables service checks| +qbit.url|`DN_QBIT_0_URL`|No Default. Something like: `http://localhost:8080`| +qbit.user|`DN_QBIT_0_USER`|No Default. Provide URL, user and pass if you use Qbit| +qbit.pass|`DN_QBIT_0_PASS`|No Default. Provide URL, user and pass if you use Qbit| + +#### SABnzbd + +|Config Name|Variable Name|Note| +|---|---|---| +sabnzbd.name|`DN_SABNZBD_0_NAME`|No Default. Setting a name enables service checks| +sabnzbd.url|`DN_SABNZBD_0_URL`|No Default. Something like: `http://localhost:8080/sabnzbd`| +sabnzbd.api_key|`DN_SABNZBD_0_API_KEY`|No Default. Provide URL and API key if you use SABnzbd| + +#### Deluge + +|Config Name|Variable Name|Note| +|---|---|---| +deluge.name|`DN_DELUGE_0_NAME`|No Default. Setting a name enables service checks| +deluge.url|`DN_DELUGE_0_URL`|No Default. Something like: `http://localhost:8080`| +deluge.password|`DN_DELUGE_0_PASSWORD`|No Default. Provide URL and password key if you use Deluge| + +### Plex This application can also send Plex sessions to Notfiarr so you can receive notifications when users interact with your server. This has three different features: @@ -184,9 +232,7 @@ notifications when users interact with your server. This has three different fea - Notify on session change (Plex Webhook) ie. pause/resume. You [must provide Plex Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) -for this to work. Setting `movies_percent_complete` or `series_percent_complete` to a number above 0 will cause this -application to poll Plex once per minute looking for sessions nearing completion. If Plex goes down -this will cause a lot of log spam. You may also need to add a webhook to Plex so it sends notices to this application. +for this to work. You may also need to add a webhook to Plex so it sends notices to this application. - In Plex Media Server, add this URL to webhooks: - `http://localhost:5454/plex?token=plex-token-here` @@ -198,13 +244,19 @@ this will cause a lot of log spam. You may also need to add a webhook to Plex so |---|---|---| plex.url|`DN_PLEX_URL`|`http://localhost:32400` / local URL to your plex server| plex.token|`DN_PLEX_TOKEN`|Required. [Must provide Plex Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) for this to work.| -plex.interval|`DN_PLEX_INTERVAL`|`30m`, How often to notify on all session data (cron)| -plex.cooldown|`DN_PLEX_COOLDOWN`|`10s`, Maximum rate of notifications is 1 every cooldown interval| -plex.account_map|`DN_PLEX_ACCOUNT_MAP`|map an email to a name, ex: `"som@ema.il,Name|some@ther.mail,name"`| -plex.movies_percent_complete|`DN_PLEX_MOVIES_PERCENT_COMPLETE`|Send complete notice when a movie reaches this percent.| -plex.series_percent_complete|`DN_PLEX_SERIES_PERCENT_COMPLETE`|Send complete notice when a show reaches this percent.| -#### System Snapshot +### Tautulli + +Only 1 Tautulli instance may be configured per client. Providing Tautulli allows Notifiarr +to use the "Friendly Name" for your Plex users and it allows you to easily enable a service check. + +|Config Name|Variable Name|Note| +|---|---|---| +tautulli.name|`DN_TAUTULLI_NAME`|No Default. Setting a name enables service checks of Tautulli| +tautulli.url|`DN_TAUTULLI_URL`|No Default. Something like: `http://localhost:8181`| +tautulli.api_key|`DN_TAUTULLI_API_KEY`|No Default. Provide URL and API key if you want name maps from Tautulli| + +### System Snapshot This application can also take a snapshot of your system at an interval and send you a notification. Snapshot means system health like cpu, memory, disk, raid, users, etc. @@ -219,7 +271,7 @@ notifiarr ALL=(root) NOPASSWD:/usr/sbin/smartctl * notifiarr ALL=(root) NOPASSWD:/usr/sbin/MegaCli64 -LDInfo -Lall -aALL ``` -###### Snapshot Packages +#### Snapshot Packages - **Windows**: `smartmontools` - get it here https://sourceforge.net/projects/smartmontools/ - **Linux**: Debian/Ubuntu: `apt install smartmontools`, RedHat/CentOS: `yum install smartmontools` @@ -228,24 +280,11 @@ notifiarr ALL=(root) NOPASSWD:/usr/sbin/MegaCli64 -LDInfo -Lall -aALL - Entware (synology): https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS - Entware Package List: https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS -###### Snapshot Configuration - -|Config Name|Variable Name|Note| -|---|---|---| -snapshot.interval|`DN_SNAPSHOT_INTERVAL`|`30m`, How often to send a snapshot (cron)| -snapshot.timeout|`DN_SNAPSHOT_TIMEOUT`|`10s`, How long to wait for a reply from Notifiarr.com| -snapshot.monitor_raid|`DN_SNAPSHOT_MONITOR_RAID`|Set `true` to report `mdadm` and `megacli`| -snapshot.monitor_drives|`DN_SNAPSHOT_MONITOR_DRIVES`|Set `true` to report SMART on drives| -snapshot.monitor_space|`DN_SNAPSHOT_MONITOR_SPACE`|Set `true` to report drive volume usage| -snapshot.monitor_uptime|`DN_SNAPSHOT_MONITOR_UPTIME`|Set `true` to report Local Host Information| -snapshot.monitor_cpuMemory|`DN_SNAPSHOT_MONITOR_CPUMEMORY`|Set `true` to report CPU and Memory usage| -snapshot.monitor_cpuTemp|`DN_SNAPSHOT_MONITOR_CPUTEMP`|Set `true` to report CPU temperatures| -snapshot.zfs_pools|`DN_SNAPSHOT_ZFS_POOL_0`|Provide a list of zfs pools to monitor, ie. `data`| -snapshot.use_sudo|`DN_SNAPSHOT_USE_SUDO`|Set `true` if `monitor_drives=true` or you use `megacli` on Linux| +#### Snapshot Configuration -- _Notes: Not all systems can report CPU temperatures._ +Snapshot configuration is now found on the [website](https://notifiarr.com). - 9/14/2021 -#### Service Checks +### Service Checks The Notifiarr client can also check URLs for health. If you set names on your Starr apps they will be automatically checked and reports sent to Notifiarr. @@ -255,7 +294,7 @@ to the app log nor to console stdout. |Config Name|Variable Name|Note| |---|---|---| services.log_file|`DN_SERVICES_LOG_FILE`|If a file path is provided, service check logs write there| -services.interval|`DN_SERVICES_INTERVAL`|`10m`, How often to check service health; minimum: `5m`| +services.interval|`DN_SERVICES_INTERVAL`|`10m`, How often to send service states to Notifiarr; minimum: `5m`| services.parallel|`DN_SERVICES_PARALLE`|`1`, How many services can be checked at once; 1 is plenty| You can also create ad-hoc service checks for things like Bazarr. @@ -267,6 +306,7 @@ service.type|`DN_SERVICE_0_TYPE`|Type must be one of `http`, `tcp`| service.check|`DN_SERVICE_0_CHECK`|The `URL`, or `host/ip:port` to check| service.expect|`DN_SERVICE_0_EXPECT`|`200`, For HTTP, the return code to expect| service.timeout|`DN_SERVICE_0_TIMEOUT`|`15s`, How long to wait for service response| +service.interval|`DN_SERVICE_0_INTERVAL`|`5m`, How often to check the service| ## Reverse Proxy diff --git a/examples/notifiarr.conf.example b/examples/notifiarr.conf.example index 441fc24b9..dd9809fbe 100644 --- a/examples/notifiarr.conf.example +++ b/examples/notifiarr.conf.example @@ -1,6 +1,6 @@ ############################################### # Notifiarr Client Example Configuration File # -# Created by Notifiarr v0.1.15 @ 213108T0935 # +# Created by Notifiarr v0.2.0 @ 211810T0106 # ############################################### # This API key must be copied from your notifiarr.com account. @@ -54,13 +54,9 @@ log_file_mb = 100 log_files = 0 ## ## Unix file mode for new log files. Umask also affects this. -## Missing or 0 uses default of 0600. Permissive is 0644. Ignored by Windows. +## Missing, blank or 0 uses default of 0600. Permissive is 0644. Ignored by Windows. file_mode = "0600" -## How often to send current application states for the dashboard. -## -send_dash = "0s" - ## Web server and application timeouts. ## timeout = "1m0s" @@ -77,30 +73,24 @@ timeout = "1m0s" ## ## Examples follow. UNCOMMENT (REMOVE #), AT MINIMUM: [[header]], url, api_key #[[lidarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://lidarr:8989/" -#api_key = "" -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc. +#name = "" # Set a name to enable checks of your service. +#url = "http://lidarr:8989/" +#api_key = "". #[[radarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://127.0.0.1:7878/radarr" -#api_key = "" -#disable_cf = true # Disable custom format sync. -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc. +#name = "" # Set a name to enable checks of your service. +#url = "http://127.0.0.1:7878/radarr" +#api_key = "" #[[readarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://127.0.0.1:8787/readarr" -#api_key = "" -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc. +#name = "" # Set a name to enable checks of your service. +#url = "http://127.0.0.1:8787/readarr" +#api_key = "" #[[sonarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://sonarr:8989/" -#api_key = "" -#disable_cf = true # Disable release profile sync. -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc. +#name = "" # Set a name to enable checks of your service. +#url = "http://sonarr:8989/" +#api_key = "" # Download Client Configs (below) are used for dashboard state and service checks. @@ -118,6 +108,12 @@ timeout = "1m0s" #pass = "" +#[[sabnzbd]] +#name = "" # Set a name to enable checks of this application. +#url = "http://sabnzbd:8080/" +#api_key = "" + + ################# # Plex Settings # ################# @@ -125,42 +121,20 @@ timeout = "1m0s" ## Find your token: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ ## [plex] - url = "http://localhost:32400" # Your plex URL - token = "" # your plex token; get this from a web inspector - interval = "30m0s" # how often to send session data, 0 = off - cooldown = "15s" # how often plex webhooks may trigger session hooks - account_map = "" # shared plex servers: map an email to a name, ex: "som@ema.il,Name|some@ther.mail,name" - movies_percent_complete = 0 # 0, 70-99, send notifications when a movie session is this % complete. - series_percent_complete = 0 # 0, 70-99, send notifications when an episode session is this % complete. - + url = "http://localhost:32400" # Your plex URL + token = "" # your plex token; get this from a web inspector ##################### -# Snapshot Settings # +# Tautulli Settings # ##################### -## Install package(s) -## - Windows: smartmontools - https://sourceforge.net/projects/smartmontools/ -## - Linux: apt install smartmontools || yum install smartmontools -## - Docker: Already Included. Run in --privileged mode. -## - Synology: opkg install smartmontools -## - Entware: https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS -## - Entware Package List: https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS -## -[snapshot] - interval = "30m0s" # how often to send a snapshot, 0 = off, 30m - 2h recommended - timeout = "30s" # how long a snapshot may take - monitor_raid = false # mdadm / megacli - monitor_drives = false # smartctl: age, temp, health - monitor_space = false # disk usage for all partitions - monitor_uptime = false # system data, users, hostname, uptime, os, build - monitor_cpuMemory = false # literally cpu usage, load averages, and memory - monitor_cpuTemp = false # cpu temperatures, not available on all platforms - zfs_pools = [] # list of zfs pools, ex: zfs_pools=["data", "data2"] - use_sudo = false # sudo is needed on unix when monitor_drives=true or for megacli. -## Example sudoers entries follow; these go in /etc/sudoers.d. Fix the paths to smartctl and MegaCli. -## notifiarr ALL=(root) NOPASSWD:/usr/sbin/smartctl * -## notifiarr ALL=(root) NOPASSWD:/usr/sbin/MegaCli64 -LDInfo -Lall -aALL +# Enables email=>username map. Set a name to enable service checks. +# Must uncomment [tautulli], 'api_key' and 'url' at a minimum. +#[tautulli] +# name = "" # only set a name if you want to enable service checks. +# url = "http://localhost:8181" # Your Tautulli URL +# api_key = "" # your tautulli api key; get this from settings ################## # Service Checks # diff --git a/go.mod b/go.mod index 7466cac42..1198367c9 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,18 @@ module github.com/Notifiarr/notifiarr go 1.17 +// home grown goodness. +require ( + golift.io/cnfg v0.0.8 + golift.io/deluge v0.9.2 + golift.io/qbit v0.0.0-20210717220751-d545c7a8f721 + golift.io/rotatorr v0.0.0-20210307012029-65b11a8ea8f9 + golift.io/starr v0.11.11 + golift.io/version v0.0.2 +) + +// ui require ( - github.com/BurntSushi/toml v0.4.1 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 github.com/gen2brain/dlgs v0.0.0-20210911090025-cbd38e821b98 @@ -14,19 +24,24 @@ require ( github.com/getlantern/hidden v0.0.0-20201229170000-e66e7f878730 // indirect github.com/getlantern/ops v0.0.0-20200403153110-8476b16edcd6 // indirect github.com/getlantern/systray v1.1.0 - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-ole/go-ole v1.2.5 // indirect - github.com/go-stack/stack v1.8.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect github.com/godbus/dbus/v5 v5.0.5 // indirect github.com/gonutz/w32 v1.0.0 + github.com/iamacarpet/go-win64api v0.0.0-20210719094426-0fa5b6bd91f5 + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect +) + +require ( + github.com/BurntSushi/toml v0.4.1 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-stack/stack v1.8.1 // indirect github.com/google/cabbie v1.0.2 // indirect - github.com/google/glazier v0.0.0-20211004192146-897b4e78b5fb // indirect + github.com/google/glazier v0.0.0-20211007155120-e60a0495f2ea // indirect github.com/gopherjs/gopherjs v0.0.0-20210825203111-a709d8e111b3 // indirect github.com/gopherjs/gopherwasm v1.1.0 // indirect github.com/gorilla/mux v1.8.0 github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b - github.com/iamacarpet/go-win64api v0.0.0-20210719094426-0fa5b6bd91f5 github.com/jaypipes/ghw v0.8.0 github.com/jaypipes/pcidb v0.6.0 // indirect github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 @@ -39,18 +54,11 @@ require ( github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect github.com/shirou/gopsutil/v3 v3.21.9 github.com/spf13/pflag v1.0.6-0.20201009195203-85dd5c8bc61c - github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/tklauser/go-sysconf v0.3.9 // indirect github.com/tklauser/numcpus v0.3.0 // indirect golang.org/x/mod v0.5.0 golang.org/x/net v0.0.0-20210825183410-e898025ed96a // indirect golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e - golift.io/cnfg v0.0.8 - golift.io/deluge v0.9.2 - golift.io/qbit v0.0.0-20210717220751-d545c7a8f721 - golift.io/rotatorr v0.0.0-20210307012029-65b11a8ea8f9 - golift.io/starr v0.10.10 - golift.io/version v0.0.2 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect diff --git a/go.sum b/go.sum index 3c3102f01..301181717 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,6 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1 h1:Xh9mvwEmhbdXlRSsgn+N0zj/NqnKvpeqL08oKDHln2s= github.com/gen2brain/beeep v0.0.0-20210529141713-5586760f0cc1/go.mod h1:ElSskYZe3oM8kThaHGJ+kiN2yyUMVXMZ7WxF9QqLDS8= -github.com/gen2brain/dlgs v0.0.0-20210609125024-bf6c92aaa984 h1:nyWVnisZPgKmLuj8UiictFdpigdTMn7RB35TNmkl8ww= -github.com/gen2brain/dlgs v0.0.0-20210609125024-bf6c92aaa984/go.mod h1:/eFcjDXaU2THSOOqLxOPETIbHETnamk8FA/hMjhg/gU= github.com/gen2brain/dlgs v0.0.0-20210911090025-cbd38e821b98 h1:wkHRSagNSNKP54v6Pf/Tebhe8bQLLkg6FQaM4/y8v2g= github.com/gen2brain/dlgs v0.0.0-20210911090025-cbd38e821b98/go.mod h1:/eFcjDXaU2THSOOqLxOPETIbHETnamk8FA/hMjhg/gU= github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= @@ -81,8 +79,9 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= -github.com/go-ole/go-ole v1.2.5 h1:t4MGB5xEDZvXI+0rMjjsfBsD7yAgp/s9ZDkL1JndXwY= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= @@ -91,8 +90,6 @@ github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4Uda github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.5 h1:9Eg0XUhQxtkV8ykTMKtMMYY72g4NgxtRq4jgh4Ih5YM= github.com/godbus/dbus/v5 v5.0.5/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -123,10 +120,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/cabbie v1.0.2 h1:UtB+Nn6fPB43wGg5xs4tgU+P3hTZ6KsulgtaHtqZZfs= github.com/google/cabbie v1.0.2/go.mod h1:6MmHaUrgfabehCHAIaxdrbmvHSxUVXj3Abs08FMABSo= github.com/google/glazier v0.0.0-20210617205946-bf91b619f5d4/go.mod h1:g7oyIhindbeebnBh0hbFua5rv6XUt/nweDwIWdvxirg= -github.com/google/glazier v0.0.0-20210831085901-f44389497a84 h1:vhqqs8fR/R7rur9puPmnULBxza3jWneYsr00K7rtDiw= -github.com/google/glazier v0.0.0-20210831085901-f44389497a84/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg= -github.com/google/glazier v0.0.0-20211004192146-897b4e78b5fb h1:aKKoI+bBzHIMMCoSayaS6BCNqU58Q+owVauyjsEqlwQ= -github.com/google/glazier v0.0.0-20211004192146-897b4e78b5fb/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg= +github.com/google/glazier v0.0.0-20211007155120-e60a0495f2ea h1:BMyoCnGfkRNgcnoFUI0Qj996HN8kqWIv9eyytljlAyc= +github.com/google/glazier v0.0.0-20211007155120-e60a0495f2ea/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -270,8 +265,6 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4= github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shirou/gopsutil/v3 v3.21.8 h1:nKct+uP0TV8DjjNiHanKf8SAuub+GNsbrOtM9Nl9biA= -github.com/shirou/gopsutil/v3 v3.21.8/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ= github.com/shirou/gopsutil/v3 v3.21.9 h1:Vn4MUz2uXhqLSiCbGFRc0DILbMVLAY92DSkT8bsYrHg= github.com/shirou/gopsutil/v3 v3.21.9/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= @@ -450,8 +443,6 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golift.io/cnfg v0.0.8-0.20201101095209-9c9085f1bf93 h1:6zgmt5vpnqltfJ7Wfj16NT+rgMROC/Y+SvJrnKnoDfc= -golift.io/cnfg v0.0.8-0.20201101095209-9c9085f1bf93/go.mod h1:AsB0DJe7nv0bizKaoy3e3MjjOF7upTpMOMvsfv4CNNk= golift.io/cnfg v0.0.8 h1:CmEgOoXuq4iXy3EaZPL+MKgyKmBEoxZ1D02vg9dyUWM= golift.io/cnfg v0.0.8/go.mod h1:AsB0DJe7nv0bizKaoy3e3MjjOF7upTpMOMvsfv4CNNk= golift.io/deluge v0.9.2 h1:G1NitZaOikjlqqvO0BZmSnmPgDY1sRzXv615KaCu38A= @@ -460,12 +451,8 @@ golift.io/qbit v0.0.0-20210717220751-d545c7a8f721 h1:hVHvpJ9X9fP3U7XBy1ohCeVLIf/ golift.io/qbit v0.0.0-20210717220751-d545c7a8f721/go.mod h1:dUZrJv4PE34QTxUSFXPSWw6P8kd0LFLR9CBOxD3HxYw= golift.io/rotatorr v0.0.0-20210307012029-65b11a8ea8f9 h1:j/WeLF6Ew1lc/m8/bh5qleZ66aSeJ72B1fUWigF69OE= golift.io/rotatorr v0.0.0-20210307012029-65b11a8ea8f9/go.mod h1:EZevRvIGRh8jDMwuYL0/tlPns0KynquPZzb0SerIC1s= -golift.io/starr v0.10.8 h1:O/gxXzeq16W8Ou+rLk+I4AARjyW8K9yjIqZjIL/8R9Y= -golift.io/starr v0.10.8/go.mod h1:2ZSbBMYCBGkgA18ihFRL6R4DelptIkAJrtW8wyxU9ew= -golift.io/starr v0.10.9 h1:mTQYWxOcujTEjJxALlU19LsgPuea1L/Bq4xSkXky4F8= -golift.io/starr v0.10.9/go.mod h1:2ZSbBMYCBGkgA18ihFRL6R4DelptIkAJrtW8wyxU9ew= -golift.io/starr v0.10.10 h1:c+CmsF8dr8bq5YKh1x8WtesZZlH+l6w2TiR6ZpLwboM= -golift.io/starr v0.10.10/go.mod h1:2ZSbBMYCBGkgA18ihFRL6R4DelptIkAJrtW8wyxU9ew= +golift.io/starr v0.11.11 h1:jw50mPlMuTDKUu9JnZSutzdsQ4r4Ga0BPUPBF78vLcA= +golift.io/starr v0.11.11/go.mod h1:2ZSbBMYCBGkgA18ihFRL6R4DelptIkAJrtW8wyxU9ew= golift.io/version v0.0.2 h1:i0gXRuSDHKs4O0sVDUg4+vNIuOxYoXhaxspftu2FRTE= golift.io/version v0.0.2/go.mod h1:76aHNz8/Pm7CbuxIsDi97jABL5Zui3f2uZxDm4vB6hU= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= diff --git a/init/bsd/freebsd.rc.d b/init/bsd/freebsd.rc.d index 13eda4af6..513086550 100644 --- a/init/bsd/freebsd.rc.d +++ b/init/bsd/freebsd.rc.d @@ -28,7 +28,7 @@ mkdir -p $(dirname ${pidfile}) chown -R ${{BINARYU}}_user $(dirname ${pidfile}) # Suck in optional exported override variables. -# ie. add something like the following to this file: export UP_POLLER_DEBUG=true +# ie. add something like the following to this file: export DN_DEBUG=true [ -f "/usr/local/etc/defaults/${real_name}" ] && . "/usr/local/etc/defaults/${real_name}" # Go! diff --git a/pkg/apps/apps.go b/pkg/apps/apps.go index fc66b2e0e..ba3228d21 100644 --- a/pkg/apps/apps.go +++ b/pkg/apps/apps.go @@ -36,6 +36,8 @@ type Apps struct { Readarr []*ReadarrConfig `json:"readarr,omitempty" toml:"readarr" xml:"readarr" yaml:"readarr,omitempty"` Deluge []*DelugeConfig `json:"deluge,omitempty" toml:"deluge" xml:"deluge" yaml:"deluge,omitempty"` Qbit []*QbitConfig `json:"qbit,omitempty" toml:"qbit" xml:"qbit" yaml:"qbit,omitempty"` + SabNZB []*SabNZBConfig `json:"sabnzbd,omitempty" toml:"sabnzbd" xml:"sabnzbd" yaml:"sabnzbd,omitempty"` + Tautulli *TautulliConfig `json:"tautulli,omitempty" toml:"tautulli" xml:"tautulli" yaml:"tautulli,omitempty"` Router *mux.Router `json:"-" toml:"-" xml:"-" yaml:"-"` ErrorLog *log.Logger `json:"-" toml:"-" xml:"-" yaml:"-"` DebugLog *log.Logger `json:"-" toml:"-" xml:"-" yaml:"-"` @@ -189,6 +191,10 @@ func (a *Apps) Setup(timeout time.Duration) error { a.Lidarr[i].setup(timeout) } + for i := range a.SabNZB { + a.SabNZB[i].setup(timeout) + } + for i := range a.Deluge { // a.Deluge[i].Debugf = a.DebugLog.Printf if err := a.Deluge[i].setup(timeout); err != nil { @@ -203,6 +209,8 @@ func (a *Apps) Setup(timeout time.Duration) error { } } + a.Tautulli.setup(timeout) + return nil } diff --git a/pkg/apps/downloaders.go b/pkg/apps/downloaders.go index adfcd610f..5bc91c8a3 100644 --- a/pkg/apps/downloaders.go +++ b/pkg/apps/downloaders.go @@ -25,6 +25,14 @@ type QbitConfig struct { *qbit.Qbit } +type TautulliConfig struct { + Name string `toml:"name"` + Interval cnfg.Duration `toml:"interval"` + Timeout cnfg.Duration `toml:"timeout"` + URL string `toml:"url"` + APIKey string `toml:"api_key"` +} + func (d *DelugeConfig) setup(timeout time.Duration) (err error) { d.Deluge, err = deluge.New(*d.Config) if err != nil { diff --git a/pkg/apps/radarr.go b/pkg/apps/radarr.go index 0e2517dc1..e2fc4e7e7 100644 --- a/pkg/apps/radarr.go +++ b/pkg/apps/radarr.go @@ -37,6 +37,9 @@ func (a *Apps) radarrHandlers() { a.HandleAPIpath(starr.Radarr, "/customformats", radarrGetCustomFormats, "GET") a.HandleAPIpath(starr.Radarr, "/customformats", radarrAddCustomFormat, "POST") a.HandleAPIpath(starr.Radarr, "/customformats/{cfid:[0-9]+}", radarrUpdateCustomFormat, "PUT") + a.HandleAPIpath(starr.Radarr, "/importlist", radarrGetImportLists, "GET") + a.HandleAPIpath(starr.Radarr, "/importlist", radarrAddImportList, "POST") + a.HandleAPIpath(starr.Radarr, "/importlist/{ilid:[0-9]+}", radarrUpdateImportList, "PUT") a.HandleAPIpath(starr.Radarr, "/command/search/{movieid:[0-9]+}", radarrTriggerSearchMovie, "GET") } @@ -44,7 +47,6 @@ func (a *Apps) radarrHandlers() { type RadarrConfig struct { Name string `toml:"name" xml:"name"` Interval cnfg.Duration `toml:"interval" xml:"interval"` - DisableCF bool `toml:"disable_cf" xml:"disable_cf"` StuckItem bool `toml:"stuck_items" xml:"stuck_items"` CheckQ *uint `toml:"check_q" xml:"check_q"` *starr.Config @@ -427,3 +429,42 @@ func radarrUpdateCustomFormat(r *http.Request) (int, interface{}) { return http.StatusOK, output } + +func radarrGetImportLists(r *http.Request) (int, interface{}) { + il, err := getRadarr(r).GetImportLists() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("getting import lists: %w", err) + } + + return http.StatusOK, il +} + +func radarrUpdateImportList(r *http.Request) (int, interface{}) { + var il radarr.ImportList + if err := json.NewDecoder(r.Body).Decode(&il); err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + il.ID, _ = strconv.ParseInt(mux.Vars(r)["ilid"], mnd.Base10, mnd.Bits64) + + output, err := getRadarr(r).UpdateImportList(&il) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("updating import list: %w", err) + } + + return http.StatusOK, output +} + +func radarrAddImportList(r *http.Request) (int, interface{}) { + var il radarr.ImportList + if err := json.NewDecoder(r.Body).Decode(&il); err != nil { + return http.StatusBadRequest, fmt.Errorf("decoding payload: %w", err) + } + + output, err := getRadarr(r).CreateImportList(&il) + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("creating import list: %w", err) + } + + return http.StatusOK, output +} diff --git a/pkg/apps/sabnzbd.go b/pkg/apps/sabnzbd.go new file mode 100644 index 000000000..091c2bb46 --- /dev/null +++ b/pkg/apps/sabnzbd.go @@ -0,0 +1,317 @@ +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Notifiarr/notifiarr/pkg/mnd" + "golift.io/cnfg" +) + +var ErrUnknownByteType = fmt.Errorf("unknown byte type") + +type SabNZBConfig struct { + Name string `toml:"name"` + Interval cnfg.Duration `toml:"interval"` + Timeout cnfg.Duration `toml:"timeout"` + URL string `toml:"url"` + APIKey string `toml:"api_key"` +} + +type StageLog struct { + Name string `json:"name"` + Actions []string `json:"actions"` +} + +// QueueSlots has the following data structure. +/* +{ + "index": 1, + "nzo_id": "SABnzbd_nzo_xnfbbdbh", + "unpackopts": "3", + "priority": "Normal", + "script": "wtfnzb-renamer.py", + "filename": "Rick Astley - Never Gonna Give You Up (1987)(24bit flac vinyl)", + "labels": [], + "password": "", + "cat": "prowlarr", + "mbleft": "593.67", + "mb": "701.34", + "size": "701.3 MB", + "sizeleft": "593.7 MB", + "percentage": "15", + "mbmissing": "0.00", + "direct_unpack": 0, + "status": "Downloading", + "timeleft": "0:03:57", + "eta": "13:42 Sun 17 Oct", + "avg_age": "2537d", + "has_rating": false +} +Payload for this structure. */ +type QueueSlots struct { + Status string `json:"status"` + Index int `json:"index"` + Password string `json:"password"` + AvgAge string `json:"avg_age"` + Script string `json:"script"` + HasRating bool `json:"has_rating"` + Mb string `json:"mb"` + Mbleft float64 `json:"mbleft,string"` + Mbmissing float64 `json:"mbmissing,string"` + Size SabNZBSize `json:"size"` + Sizeleft SabNZBSize `json:"sizeleft"` + Filename string `json:"filename"` + Labels []string `json:"labels"` + Priority string `json:"priority"` + Cat string `json:"cat"` + Eta SabNZBDate `json:"eta"` + Timeleft string `json:"timeleft"` + Percentage int `json:"percentage,string"` + NzoID string `json:"nzo_id"` + Unpackopts string `json:"unpackopts"` +} + +type HistorySlots struct { + ID int64 `json:"id"` + Completed int64 `json:"completed"` + Name string `json:"name"` + NzbName string `json:"nzb_name"` + Category string `json:"category"` + Pp string `json:"pp"` + Script string `json:"script"` + Report string `json:"report"` + URL string `json:"url"` + Status string `json:"status"` + NzoID string `json:"nzo_id"` + Storage string `json:"storage"` + Path string `json:"path"` + ScriptLog string `json:"script_log"` + ScriptLine string `json:"script_line"` + DownloadTime int64 `json:"download_time"` + PostprocTime int64 `json:"postproc_time"` + StageLog []*StageLog `json:"stage_log"` + Downloaded int64 `json:"downloaded"` + Completeness interface{} `json:"completeness"` + FailMessage string `json:"fail_message"` + URLInfo string `json:"url_info"` + Bytes int64 `json:"bytes"` + Meta interface{} `json:"meta"` + Series string `json:"series"` + Md5Sum string `json:"md5sum"` + Password string `json:"password"` + ActionLine string `json:"action_line"` + Size string `json:"size"` + Loaded bool `json:"loaded"` + Retry int `json:"retry"` +} + +type History struct { + TotalSize SabNZBSize `json:"total_size"` + MonthSize SabNZBSize `json:"month_size"` + WeekSize SabNZBSize `json:"week_size"` + DaySize SabNZBSize `json:"day_size"` + Slots []*HistorySlots `json:"slots"` + Noofslots int `json:"noofslots"` + LastHistoryUpdate int64 `json:"last_history_update"` + Version string `json:"version"` +} + +type Queue struct { + Version string `json:"version"` + Paused bool `json:"paused"` + PauseInt string `json:"pause_int"` + PausedAll bool `json:"paused_all"` + Diskspace1 float64 `json:"diskspace1,string"` + Diskspace2 float64 `json:"diskspace2,string"` + Diskspace1Norm SabNZBSize `json:"diskspace1_norm"` + Diskspace2Norm SabNZBSize `json:"diskspace2_norm"` + Diskspacetotal1 float64 `json:"diskspacetotal1,string"` + Diskspacetotal2 float64 `json:"diskspacetotal2,string"` + Loadavg string `json:"loadavg"` + Speedlimit int `json:"speedlimit,string"` + SpeedlimitAbs string `json:"speedlimit_abs"` + HaveWarnings string `json:"have_warnings"` + Finishaction interface{} `json:"finishaction"` + Quota string `json:"quota"` + HaveQuota bool `json:"have_quota"` + LeftQuota string `json:"left_quota"` + CacheArt string `json:"cache_art"` + CacheSize SabNZBSize `json:"cache_size"` + CacheMax int64 `json:"cache_max,string"` + Kbpersec float64 `json:"kbpersec,string"` + Speed SabNZBSize `json:"speed"` + Mbleft float64 `json:"mbleft,string"` + Mb float64 `json:"mb,string"` + Sizeleft SabNZBSize `json:"sizeleft"` + Size SabNZBSize `json:"size"` + NoofslotsTotal int `json:"noofslots_total"` + Status string `json:"status"` + Timeleft string `json:"timeleft"` + Eta string `json:"eta"` + RefreshRate string `json:"refresh_rate"` + InterfaceSettings string `json:"interface_settings"` + Scripts []string `json:"scripts"` + Categories []string `json:"categories"` + RatingEnable bool `json:"rating_enable"` + Noofslots int `json:"noofslots"` + Start int64 `json:"start"` + Limit int64 `json:"limit"` + Finish int64 `json:"finish"` + Slots []*QueueSlots `json:"slots"` +} + +func (s *SabNZBConfig) setup(timeout time.Duration) { + if s == nil { + return + } + + if s.Timeout.Duration == 0 { + s.Timeout.Duration = timeout + } + + s.URL = strings.TrimRight(s.URL, "/") +} + +func (s *SabNZBConfig) GetHistory() (*History, error) { + if s == nil || s.URL == "" { + return nil, nil + } + + params := url.Values{} + params.Add("output", "json") + params.Add("mode", "history") + params.Add("apikey", s.APIKey) + + var h struct { + History *History `json:"history"` + } + + err := GetURLInto(s.Timeout.Duration, s.URL+"/api", params, &h) + if err != nil { + return nil, err + } + + return h.History, nil +} + +func (s *SabNZBConfig) GetQueue() (*Queue, error) { + if s == nil || s.URL == "" { + return nil, nil + } + + params := url.Values{} + params.Add("output", "json") + params.Add("mode", "queue") + params.Add("apikey", s.APIKey) + + var q struct { + Queue *Queue `json:"queue"` + } + + err := GetURLInto(s.Timeout.Duration, s.URL+"/api", params, &q) + if err != nil { + return nil, err + } + + return q.Queue, nil +} + +// GetURLInto gets a url and unmarshals the contents into the provided interface pointer. +func GetURLInto(timeout time.Duration, url string, params url.Values, v interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.URL.RawQuery = params.Encode() + + resp, err := (&http.Client{Timeout: timeout}).Do(req) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response (%s): %w: %s", resp.Status, err, string(b)) + } + + if err := json.Unmarshal(b, v); err != nil { + return fmt.Errorf("decoding response (%s): %w: %s", resp.Status, err, string(b)) + } + + return nil +} + +// SabNZBSize deals with bytes encoded as strings. +type SabNZBSize struct { + Bytes int64 + String string +} + +// SabNZBDate is used to parse a custm date format from the json api. +type SabNZBDate struct { + String string + time.Time +} + +// UnmarshalJSON exists because weird date formats and "unknown" seem sane in json output. +func (s *SabNZBDate) UnmarshalJSON(b []byte) (err error) { + s.String = strings.Trim(string(b), `"`) + + if s.String == "unknown" { + s.Time = time.Now().Add(time.Hour * 24 * 366) //nolint:gomnd + return nil + } + + s.Time, err = time.Parse("15:04 Mon 02 Oct 2006", s.String+" "+strconv.Itoa(time.Now().Year())) + if err != nil { + return fmt.Errorf("invalid time: %w", err) + } + + return nil +} + +// UnmarshalJSON exists because someone decided that bytes should be strings with letters. +func (s *SabNZBSize) UnmarshalJSON(b []byte) (err error) { + s.String = strings.Trim(string(b), `"`) + split := strings.Split(s.String, " ") + + bytes, err := strconv.ParseFloat(split[0], mnd.Bits64) + if err != nil { + return fmt.Errorf("could not convert to number: %s: %w", split[0], err) + } + + if len(split) < 2 { //nolint:gomnd + s.Bytes = int64(bytes) + return nil + } + + switch split[1] { + case "B", "b", "": + s.Bytes = int64(bytes) + case "K", "k", "kb", "KB": + s.Bytes = int64(bytes * mnd.Kilobyte) + case "M", "m", "mb", "MB": + s.Bytes = int64(bytes * mnd.Megabyte) + case "G", "g", "gb", "GB": + s.Bytes = int64(bytes * mnd.Megabyte * mnd.Kilobyte) + case "T", "t", "tb", "TB": + s.Bytes = int64(bytes * mnd.Megabyte * mnd.Megabyte) + default: + return fmt.Errorf("%w: %s", ErrUnknownByteType, split[1]) + } + + return nil +} diff --git a/pkg/apps/sonarr.go b/pkg/apps/sonarr.go index f93bae9a2..f165268f6 100644 --- a/pkg/apps/sonarr.go +++ b/pkg/apps/sonarr.go @@ -43,7 +43,6 @@ func (a *Apps) sonarrHandlers() { type SonarrConfig struct { Name string `toml:"name" xml:"name"` Interval cnfg.Duration `toml:"interval" xml:"interval"` - DisableCF bool `toml:"disable_cf" xml:"disable_cf"` StuckItem bool `toml:"stuck_items" xml:"stuck_items"` CheckQ *uint `toml:"check_q" xml:"check_q"` *starr.Config diff --git a/pkg/apps/tautulli.go b/pkg/apps/tautulli.go new file mode 100644 index 000000000..8a493079f --- /dev/null +++ b/pkg/apps/tautulli.go @@ -0,0 +1,102 @@ +package apps + +import ( + "net/url" + "strings" + "time" +) + +func (t *TautulliConfig) setup(timeout time.Duration) { + if t == nil { + return + } + + if t.Timeout.Duration == 0 { + t.Timeout.Duration = timeout + } + + t.URL = strings.TrimRight(t.URL, "/") +} + +// GetUsers returns the Tautulli users. +func (t *TautulliConfig) GetUsers() (*TautulliUsers, error) { + if t == nil || t.URL == "" { + return nil, nil + } + + params := url.Values{} + params.Add("cmd", "get_users") + params.Add("apikey", t.APIKey) + + var users TautulliUsers + + err := GetURLInto(t.Timeout.Duration, t.URL+"/api/v2", params, &users) + if err != nil { + return nil, err + } + + return &users, nil +} + +// TautulliUsers is the entire get_users API response. +type TautulliUsers struct { + Response struct { + Result string `json:"result"` // success, error + Message string `json:"message"` // error msg + Data []TautulliUser `json:"data"` + } `json:"response"` +} + +// TautulliUser is the user data from the get_users API call. +type TautulliUser struct { + RowID int64 `json:"row_id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + FriendlyName string `json:"friendly_name"` + Thumb string `json:"thumb"` + Email string `json:"email"` + ServerToken string `json:"server_token"` + SharedLibraries string `json:"shared_libraries"` + FilterAll string `json:"filter_all"` + FilterMovies string `json:"filter_movies"` + FilterTv string `json:"filter_tv"` + FilterMusic string `json:"filter_music"` + FilterPhotos string `json:"filter_photos"` + IsActive int `json:"is_active"` // 1,0 (bool) + IsAdmin int `json:"is_admin"` // 1,0 (bool) + IsHomeUser int `json:"is_home_user"` // 1,0 (bool) + IsAllowSync int `json:"is_allow_sync"` // 1,0 (bool) + IsRestricted int `json:"is_restricted"` // 1,0 (bool) + DoNotify int `json:"do_notify"` // 1,0 (bool) + KeepHistory int `json:"keep_history"` // 1,0 (bool) + AllowGuest int `json:"allow_guest"` // 1,0 (bool) +} + +// MapEmailName returns a map of email => name for Tautulli users. +func (t *TautulliUsers) MapEmailName() map[string]string { + if t == nil { + return nil + } + + m := map[string]string{} + + for _, user := range t.Response.Data { + // user.FriendlyName always seems to be set, so this first if-block is safety only. + if user.FriendlyName == "" && user.Email != "" && user.Username != "" { + m[user.Email] = user.Username + continue + } else if user.FriendlyName == "" { + // This user has no mapability. + continue + } + + // We only need username or email, not both, but in the order username then email. + if user.Username != "" { + m[user.Username] = user.FriendlyName + } else if user.Email != "" { + m[user.Email] = user.FriendlyName + } + } + + return m +} diff --git a/pkg/bindata/bindata.go b/pkg/bindata/bindata.go index 2fac1fb27..262a5c564 100644 --- a/pkg/bindata/bindata.go +++ b/pkg/bindata/bindata.go @@ -1,7 +1,7 @@ // Code generated by go-bindata. DO NOT EDIT. // sources: // files/favicon.ico (12.206kB) -// files/favicon.png (12.881kB) +// files/favicon.png (12.625kB) // files/macos.png (868B) package bindata @@ -91,7 +91,7 @@ func filesFaviconIco() (*asset, error) { return a, nil } -var _filesFaviconPng = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x14\xd4\x87\x3f\x94\x0f\x03\x00\xf0\xe7\xb9\x85\xb3\xce\x9e\x71\xc9\x5e\xd9\x2b\xe3\x44\xb2\x57\x08\xfd\xb2\x4a\x56\x21\x14\x65\xdd\x91\x51\xf6\x28\x33\x7b\x25\xa1\x90\x64\x1d\x11\xd9\x33\x64\x9d\x11\xb2\xb7\xc3\x39\xef\xe7\xfd\x27\xbe\xaf\x4d\x8d\x6f\xd3\x22\x39\x91\x00\x00\xd0\xea\xe9\x6a\x9b\x03\x00\x08\x00\x00\x50\x4f\x09\x05\x00\xa0\x87\xd9\x5b\x02\x00\x40\x7f\x53\x43\x8b\x5b\x00\x00\x40\xa1\x50\x08\x04\x82\x46\xa3\x29\x28\x28\x20\x10\x08\x0b\x0b\x0b\x1c\x0e\x47\x20\x10\x2f\x5f\xbe\x1c\x1e\x1e\x66\x60\x60\x48\x4f\x4f\xef\xe8\xe8\x68\x6a\x6a\x72\x73\x73\xab\xad\xad\x1d\x1f\x1f\xff\xfa\xf5\xab\x91\x91\x51\x53\x53\xd3\xf2\xf2\x72\x45\x45\x05\x1e\x8f\x9f\x99\x99\x19\x1d\x1d\x2d\x28\x28\x28\x2d\x2d\x1d\x1c\x1c\xf4\xf5\xf5\x4d\x49\x49\x39\x38\x38\xf8\xf7\xef\xdf\xc2\xc2\x02\x99\x4c\xde\xdb\xdb\xdb\xd8\xd8\x98\x9a\x9a\x0a\x4d\x8a\x19\x1e\x1e\x7e\xf5\x36\x61\x65\x65\x65\x67\x67\x67\x6b\x6b\x2b\x21\x27\x63\x76\x76\x76\x69\x69\xe9\xbe\x8f\xc7\xf8\xf8\x78\x4c\xe6\x5b\x61\x0d\x45\xdf\x28\xec\xbb\xbc\xf7\xdd\xdd\xdd\x2f\x62\x23\xfb\xfa\xfa\xce\x4e\x4f\xd1\x8a\x52\x87\x47\x47\x51\x19\x29\x00\x08\x50\xd3\x50\xe7\x56\x97\x67\x96\x14\x9c\x9c\x9c\xd8\x78\xba\x3c\xf0\x7f\x6a\xf6\xc8\x5e\x42\x5b\xd5\xd2\xe3\x81\x81\x93\x2d\x3b\x17\xbb\x7b\x48\xc0\x93\xb0\x40\x0e\x69\x91\x47\x41\x7e\xaf\x12\x62\xe2\xde\x26\x8b\xde\x15\x74\x7a\xfa\xd8\x37\xe4\xe5\x87\x2f\x55\xee\x2f\x7c\x74\xee\x5b\x56\x7f\xad\x4d\xca\x48\x0b\x7f\x13\xf5\x3a\x31\x2e\xef\x63\x99\xf7\xcb\xe7\x41\x91\xe1\x12\xf6\x22\x1e\x4f\xbd\x04\x8c\xae\xbe\xcb\xce\x0c\xc4\x85\x38\x3c\x7a\x88\xb1\x32\x6e\x6a\x6e\xbe\xa1\xaa\xaa\x60\x74\x2b\xa9\x28\x47\x4b\x5b\x0b\xe0\xa6\x2e\xfd\xf0\xe1\x9a\xc0\x35\x1e\x75\x2e\x59\x59\x59\x36\x09\x16\x31\x71\x71\x88\x22\x98\x57\x50\x40\x2d\x82\xb6\xb4\xb9\x1b\xb4\x12\xac\x6f\x60\x10\x47\x4c\x14\x28\xe2\x65\x0b\xa6\x12\x8b\x14\x60\x72\x44\x78\x0d\x3e\xbe\x91\xa6\x60\x52\xa5\x6f\xd7\x72\xef\x43\xb6\xb8\x05\x00\x00\xbc\x7e\xe6\xc6\x77\x00\x46\x4a\x31\xb4\x9c\x99\xa6\x7d\xf4\xfa\x0b\x81\xf5\xd1\x80\xe1\x4f\x8f\x1f\x57\x0d\xb6\x10\x76\x7b\x72\xf4\x23\x8b\x9a\xe4\x5d\x9c\x7b\x00\x40\xca\x5d\x4f\x5b\xd3\x22\xe0\x4f\x5f\xfa\x60\xcc\x28\x86\xb9\xeb\x63\x57\x31\x92\x4b\xd4\xe3\x49\x7e\x86\x9c\x56\x77\xe4\x43\x2d\x34\xff\x7d\xbe\xda\xe7\xd3\x5a\x9a\xcc\xa9\xc8\xc5\xb6\xdf\x4e\x2c\xf4\x56\xa4\xcb\xb6\xb4\x13\x41\xd9\x5f\x86\x1f\xd8\x8c\xce\x33\x14\x35\x46\x56\xaa\xe7\xbd\xe6\x9a\xfa\xbe\x1e\x24\x04\xc8\x55\x8b\x7b\x04\x04\xcf\x52\x54\x56\xa2\xb1\x86\x74\x22\x3a\x2b\xce\xc9\x87\xc2\x26\x34\x9e\x39\x8c\x6a\x4e\x93\x5c\xb6\xbf\xf3\xce\xbf\xf9\xda\xa8\x72\xd2\x9c\xdf\x9d\xa9\xbf\xf6\xf3\x26\x6b\xa4\xea\x32\xe2\x29\x0d\x2e\xc0\xf9\x44\x75\x3d\xcc\x50\x2e\x62\xff\xcd\xef\x9c\xcc\x91\x2d\xa3\xc1\xf5\xe7\x92\x94\x1f\x79\x6b\xa2\x71\xfa\x63\xf1\xbe\x93\x16\x55\x9f\x96\x38\x73\x6a\xb5\x92\xe4\xe3\xd0\xb2\xe6\x19\xf2\x7f\x3b\xfb\x63\x62\x3e\xe7\x4d\x3f\x08\x11\x26\x28\xe1\xa3\xf3\x5f\x60\x40\xe8\xb1\x75\x57\x8a\xfa\x9a\x9b\x99\x32\x90\xff\xdf\x6e\x8e\x75\xda\x67\x06\x44\xb0\xc8\x3c\x70\x3f\x2f\xb1\xc3\xdd\x75\x51\x8f\xde\x69\xf6\xd5\xcb\x2d\xe1\xac\x3c\x7f\x2a\x3d\xa9\x65\x1a\x3f\x27\x8d\x05\xd0\xaf\x3f\xec\xcf\x38\x15\x5b\x2a\x85\x6b\x58\x0b\x8b\x27\xe2\xbd\xec\x41\x59\x17\x80\xb2\xe7\x7b\x8c\x8d\x00\x35\xd6\xa1\x11\xe9\x9f\xc5\x54\xc3\xb2\xbb\x55\x25\x18\x96\xef\xfd\x58\xcf\x7e\xab\xa9\xe1\xa4\x1b\x7d\x75\x62\x19\xe2\xa1\x82\x19\x33\x94\x12\xbd\xa6\xd8\x1b\x76\x9a\x39\x24\x1e\x30\xac\x7c\x2d\x7c\x55\xfd\x4e\xcf\x5b\x86\x9e\xc8\xea\x06\x79\xa5\xba\x64\x53\xfd\x09\xb3\xfd\xfe\x91\xf4\x7c\xda\x67\x14\x5f\x4a\x57\xc5\x60\x5a\x3c\x22\xb3\x3f\xf5\x6b\x6e\xcd\x33\xda\x3a\x5b\x68\x72\xcf\xa3\x32\xd2\xa3\x7b\x70\xb2\x2c\x61\x5d\x5a\xc7\x6c\x7d\x5f\xca\x46\x08\x4a\x3f\x19\xda\xee\x74\x4c\x72\x8d\x13\x2a\x22\xf2\x02\xb7\x72\x87\xb6\xa7\x21\x8a\xec\x09\x3f\xe5\x75\x4e\x68\x75\x9c\xab\xd8\x29\x04\x2a\xc5\x04\x6f\x38\xc9\x7f\xb3\xa2\x87\x15\xa0\x6a\x43\xf9\xd6\xd4\x6b\x38\x50\x8e\x98\x84\x82\x68\x1d\xf6\x99\x73\xb7\xed\xe5\x17\xea\x6a\x91\x43\xd4\x29\x9a\xfd\x2c\x4d\xd9\xed\x2f\x91\xcb\x02\xa3\x49\xb3\xbb\x78\x8f\xd4\x30\xbb\x52\x67\xc4\xdd\x3c\xde\x43\x73\x61\xc2\xba\x82\x7f\x49\xfd\xd7\xd0\x57\xc2\x61\x89\xd5\x0f\xd6\x4a\xa9\x24\xed\xaf\x84\x48\xf5\x77\x19\x5a\x69\x37\x30\x8c\x54\x6c\x99\xeb\xfe\x9d\x8f\x5f\x27\x8e\x77\x6c\xce\x70\x4c\xa9\x17\xb5\x0c\x2a\x5f\xe4\xff\x9b\x9f\xea\x69\x03\x7a\x79\x5e\xe0\xa7\x26\xa9\x02\x9c\xb1\x65\x08\xdd\x8d\xe2\xa1\x45\xf3\xcf\x26\xbe\x75\x74\xd9\x33\x75\x36\x4d\xc1\x17\x2f\x2f\x56\x0e\x26\x78\xf1\x27\x61\x93\xd7\x28\x08\xce\x8e\x61\xf3\x5b\x75\x83\xc9\xfc\x19\xa4\x33\x9a\xa0\xff\xaa\x26\x78\xe7\x5c\xa4\x13\xf6\xcf\x87\x62\x8c\xa8\xcd\xf3\x3a\xef\x2c\x48\xf1\x05\xd9\xc3\xfc\xd8\xf3\xe3\x08\x7b\x35\x27\x5e\xa1\x94\xf0\xa8\x64\x16\x6f\xdd\x77\x31\x9c\x35\x53\x05\xc3\xbb\x29\x7f\x3d\xbf\x30\x72\x5b\xfd\x2a\x71\xa2\xa8\xa0\x73\x18\x20\x6f\x9c\xa9\xd1\x77\xc5\xd3\xd3\x75\x64\x42\x53\x6f\x4b\x34\xd3\xf0\xd0\x3f\x89\x7c\x43\x8b\xbe\xf0\x7a\xd2\x82\xf8\xfb\x91\x0a\xf3\xd2\x7b\xa6\xdf\x45\xa8\xe1\x5e\x53\x60\x62\x3d\xaf\x60\xa3\x7f\xcc\x8d\xb6\x8a\xcc\xb6\xbc\x0b\x72\x78\xd7\x13\xa1\x68\x83\x5a\x6e\xd7\x41\xdc\xed\x09\x85\x9e\xd2\x54\x36\x59\xbb\x07\x67\xa5\xad\x6f\x2c\x05\x9c\xcd\x3f\x67\xcc\x5e\xdc\x0a\x81\x9c\x30\xed\xee\xae\x16\x05\x95\xd4\x4c\x32\xb7\x7e\xad\xa6\xd5\x69\x72\x03\x47\x7e\x37\x7c\x9b\xa6\xd7\x6d\x69\xfb\x24\xfb\x6c\xaa\xde\x52\x5a\xf3\x48\x3e\xf9\xef\xac\xde\xa3\xdd\x43\x81\x5f\x8f\x1c\xe7\x34\x1f\xe1\x75\x32\x78\xad\x34\x1e\x3c\xcf\x95\x9d\x41\xbc\x85\x73\x0c\xf7\x68\x74\xcf\xba\x80\x3d\x13\xe2\xd5\x17\x3e\xe7\x8d\x8f\xef\x73\x63\x5a\x99\xc3\x9f\x94\x8a\x8c\x64\xec\x10\x24\x0b\x04\xe7\x61\x12\xe9\x3b\x6f\xea\xee\xb2\x86\xad\x98\x08\x7f\x52\x96\x0a\xd8\x46\x76\xca\x39\x31\x4d\x87\x6e\x3e\xf2\x7a\xa3\x1f\xc2\xf7\x22\x3d\x37\x84\x94\xa5\xad\x69\xd7\xdc\x1e\x19\x23\xb8\x86\xd7\xda\x13\xf9\x5b\xb9\x54\x10\xf2\x16\xc6\xc3\x99\x78\xb1\x0a\x05\xff\xdd\x9d\x84\xcc\xf7\xbf\x47\xeb\xb6\x5a\x9c\xa5\x70\xd2\x37\xf6\x9b\xab\x59\x15\x27\x08\xd7\xd8\xcf\xa4\x6d\xbe\x3b\xf2\xea\x86\x4c\x45\xdf\x7a\x11\xba\x17\xc8\x75\x36\xf0\xcd\x8a\xd2\x5d\x28\xec\x72\x1d\x7b\x14\xa5\x70\x70\xe9\xc6\xf9\xa6\x20\xe0\xd0\x25\xd4\x74\x89\x3e\x33\xfd\x71\xec\xd7\xdf\x9f\xa0\xfa\xf9\x7d\x2c\x22\xcc\x52\x57\xee\x3b\xcd\x9a\x4e\x39\xbd\x02\x53\xd8\x1f\x40\xf6\x2e\x4d\x54\x4f\xe6\xf6\xed\xe6\xcb\xf9\x1f\x2e\x1d\x3e\x5b\x32\xc0\x06\x0d\xdc\xd8\x99\x3d\xef\xe9\xfd\xee\xb0\xf4\xd3\x61\x98\xeb\x49\xe8\xa7\xb3\xd3\xe1\x73\x7f\x67\x12\xb4\xa1\xe3\xd5\x02\x39\x47\x39\x5f\xef\x5d\xb1\xdb\x17\x8d\x0c\x9a\x28\xf3\x4d\xba\xc8\xc0\xcc\xfb\x92\x6a\x72\xea\x36\x4d\xcd\x21\x86\x57\x70\x17\x3f\x2b\x10\xcb\x6d\xe1\xea\x82\x2f\x5b\x6e\x7e\x83\x48\xee\x76\x0a\xf5\xc6\xcd\xfe\x9e\x1e\xac\x25\xb1\xa8\xa7\xf0\x56\x43\xf9\xcf\x90\x61\xc0\x66\x2d\xdb\xab\xfc\xa5\xef\x2d\xab\x2f\x3c\x31\x48\xe8\xbb\x8d\x5b\x95\xe1\x09\xa3\x61\x27\x85\x2d\x93\x4c\x6d\xf6\xa5\x2a\x7b\xdb\xeb\xeb\x07\x7f\x46\xb6\x3d\xa7\x55\x45\x35\xfc\x94\x83\x68\xed\xe0\x95\x84\x4f\x2f\x9b\x75\x48\xf6\x6f\x88\xd4\xc1\x9a\x21\xb8\x7b\x11\x0e\x32\xaa\x86\x58\x2a\x23\xbd\xbd\x31\x5a\x1b\xba\x5f\xd3\x8e\x9d\x37\xf6\xc6\x0e\x66\x51\x0a\x16\xfa\x9d\x02\x37\xc4\xe8\xb8\x9f\x84\x7e\x9e\xae\x1b\x18\xc9\x34\xa3\xef\x7c\x15\xe5\x43\x90\xc7\x51\x37\x7d\xf0\x2c\xfc\xfd\xc5\xb7\x93\x9e\x4a\x6d\xee\x2f\xed\xa6\xcc\xa9\x1b\xfd\xe8\x0e\x2b\x22\x80\xe6\x3a\xd1\xac\xe9\xc1\x9b\x20\xef\x6e\x78\xdc\x20\xce\xe9\x97\x8c\xd2\xac\xbb\xa2\x19\xcd\xe3\x66\x0e\xcf\x85\x97\xdf\x68\xb0\xb9\x7b\x2e\xce\x5b\x0a\xef\xb2\x3c\x5c\x7e\x92\xa8\x2f\xff\x7a\xfb\x7c\x48\xb9\xbe\xae\xa9\xc9\xde\x16\x56\x26\x73\x61\xd9\x69\xcd\xcf\x71\x14\xc6\xcb\x9e\x5b\xa4\x55\x1d\xfc\xb5\xe1\xa1\xfc\xba\x28\xb4\x07\x54\x46\xab\xdb\xab\xb6\xea\x79\xd9\xb0\x7f\xbb\x41\x98\xff\x6e\x46\x7a\x88\x8d\xfe\x6e\xcf\xc7\x4b\xf3\x05\x24\x52\xe0\x07\x35\x07\x21\x8a\xa3\x35\x33\x22\xa3\x39\xbb\xf8\xfe\xeb\xfa\x77\x34\x0c\x05\x49\x06\x62\x74\xa4\xa9\xc7\x13\x5c\xef\x4e\x37\x75\xb9\xad\x89\xb2\xc9\x5d\x83\xee\x88\xa0\xf7\x0b\xd2\xb6\x6b\xe4\xac\xd5\x09\xc2\x04\xb1\x93\xc4\x2b\xe8\x86\xed\x8f\xf7\xff\x6a\x97\x69\x86\x88\xe6\x0d\xe7\x27\x8d\x13\xa9\x5b\xae\x0e\xfe\xcb\xb7\x75\xa7\x4f\x85\xee\x24\xc4\x3f\x91\x8c\x12\x6f\x9b\xf8\xe8\xb0\xd1\xb9\x04\xdf\xaf\x37\x0c\xa0\xa1\x24\xd1\x2e\xb6\x7e\x37\x27\x51\x14\xc4\x1c\xe9\x85\x8d\x4b\xbe\xe1\x93\xc6\xd1\x3a\xbc\x1d\xd9\x79\x69\xf9\xe8\x3d\x85\xf0\xf1\x7d\x52\xd0\xe4\x74\x96\xc5\xdd\xb6\xf1\x23\xa1\xc0\xe2\xe1\xe6\x8b\x89\x37\x0b\xc0\xa1\xd3\x1e\x89\xf5\x40\x4c\x9e\xeb\x7d\xc9\xd1\x87\xff\xc8\x6f\xca\x15\xa7\x0a\x7f\x51\xf0\xda\x21\xe9\x8b\x7e\x1e\x3d\xa2\xdd\xed\x3f\xad\xda\x86\x10\xf5\x4e\xf7\xa4\x2e\xb0\xf3\x9e\x8f\x08\x67\xee\x3f\xec\xd6\xfd\xcc\xe8\x8c\x28\x74\x63\x3c\x62\x78\xaf\x5e\x5a\x7d\x5e\xa7\x1d\x8f\xbe\xec\x20\x44\x55\x6f\x6d\xb9\xd0\xad\xd9\x54\xc6\xe3\x76\x6a\xa8\x86\x88\xef\x72\x5b\x16\x3a\xfd\x77\xcf\x19\x7a\xff\xde\x2d\xf5\x8f\xee\x37\x8d\xc0\xf4\x2c\xdc\xcd\xe2\x82\xdc\xdb\x81\x7e\x97\x08\x39\x58\x1c\xbe\x49\xbc\xc7\x39\xb3\xdc\x55\xd8\xba\x33\xed\xb3\x69\x49\xf4\x4b\x1d\xc4\x94\xf2\x73\xa0\x60\xf3\xa5\x2b\xb7\xc7\x35\xd2\xfe\x5e\x4e\x40\xae\x67\x26\x71\x55\xf4\xe8\x4b\x71\x85\x49\x72\x42\xa6\xd7\x62\xd8\x02\xe4\x5e\x1e\x96\xdf\xe8\x9d\x77\x77\xa4\x0c\x0d\x3d\xd7\xf5\xba\x8c\x6d\xcd\xe4\xb7\x59\x73\x0f\xe9\x14\x41\x68\xe7\xff\x01\x9d\x83\xb5\xd5\x97\x8d\x2e\xd5\x2e\x88\x56\xaf\xd5\x7d\xa4\x6f\x5a\xe7\x71\x13\x22\xd1\xa5\xa3\x0b\xc8\x1b\x79\x74\xb7\x87\xc2\x72\xde\xaa\xbf\xf8\x5d\xc1\xb5\x1b\x9b\xd0\x48\x4d\xae\x7f\x60\x49\x66\xe7\xdd\x0a\x4a\x16\x80\x66\x5c\xba\x53\x02\x1b\x23\x10\xca\x74\xde\xe4\xaa\xd7\x25\x35\xd2\xe9\x67\xf5\xa2\xb7\x01\xe4\xb1\xe4\xbf\x84\x7f\xef\x5c\x0f\x75\x6c\xf9\xa6\x90\xc5\x42\x16\x74\xaa\xc1\x32\xab\x7b\xa1\x0d\x95\xdd\x60\xe5\xb6\x67\x61\x63\x93\x8f\xfb\x57\x8d\x94\x66\x98\xcc\xbe\xd7\x36\x2c\xe9\x5a\x20\xfa\x18\x70\xa7\xe4\xb8\xed\x98\x29\x76\xf5\x38\xe8\x53\xb7\x22\xb7\x77\x58\xf0\x1f\xba\x67\x5a\xbb\x4f\x6b\x66\x93\x4b\x50\x99\x6a\xec\x68\xe8\xc1\xee\xce\xef\x88\xfb\x61\x55\xb5\xd4\xe4\xe2\xa4\xb6\xd0\x86\xdc\x87\x78\xc5\x83\x4b\x78\xda\x31\x5e\x93\xa5\xdb\x19\x7e\x58\x2a\xbd\x8c\xeb\x53\x14\x37\xdd\xcb\x35\x7b\x68\x9b\x68\x44\x43\x71\xac\x38\x3d\xc6\xfc\xe7\x6f\x38\xee\x0f\xd5\x02\x9e\xfb\xc0\x8b\x63\x17\x71\xa9\xb8\x78\x77\x34\xa1\xd3\x84\x18\x9e\x07\xa9\xa2\xf1\xde\x57\x5d\x15\x90\xe2\xb9\x61\xf7\x9f\xeb\x0f\x68\xc0\xd5\x2f\xd8\x8f\xad\x41\x5b\x92\x14\x61\x5a\x24\xa3\x2d\x7c\x9c\x12\x86\x35\xbd\xd8\x21\x87\x06\xdb\x7a\x4f\x7b\xde\xa2\x66\xb4\x4c\xe2\x7a\xb0\xf8\xa0\xc6\x59\x4b\xec\xae\xec\x42\x55\xf9\xce\x37\xc7\x9c\x93\x7d\x82\x9c\x03\xf9\x03\xa1\x70\x5a\xf9\xb5\x22\xbe\x27\xed\xb6\x7f\x2c\x31\x85\xec\x85\x33\xf0\x19\xd1\x9b\xc2\xf1\xae\x8e\xb1\x72\xd5\xe5\x1d\xf2\x86\x0b\xf9\xee\xd6\x54\x30\xaa\x1d\x4c\xfc\x21\x4c\x98\x68\x08\x7e\xcc\xe8\x5e\xa6\x67\x80\x72\xee\xeb\xe9\x93\x36\x81\xdf\x97\x3a\xff\x59\xe3\x2f\x34\xae\x79\xb8\x29\x12\xeb\x16\xc1\xbd\x1b\x93\x54\xbf\x6d\x16\xf1\x11\x3e\x18\x17\x87\x4a\x5b\x80\x9c\xb9\xcb\xb5\x2b\xb1\x50\x3f\xa8\x4f\xa2\xe0\x4d\x8e\x0d\x0c\x0e\xb7\xaf\x39\xf9\xe7\x9c\x1d\xbf\x9f\x4d\x83\x0e\x61\xaf\x6e\x03\xf3\x28\x31\xdc\xe1\x6f\x2c\x24\x6b\xe1\xb1\x09\x60\xdb\x88\xa5\x38\xf4\x99\x14\xf1\xd6\xe0\x2f\xf1\x66\x86\xf5\x1f\x9f\x95\xbc\x2f\xcf\xe6\x3f\x33\x2b\x46\xe2\x15\x05\x2e\x3d\x14\x33\x79\xbb\x78\x7e\x46\xbd\x44\xb4\x7c\x05\xef\x4a\x71\xc6\x8d\xa8\x96\x02\x41\xf0\xa4\x37\x78\x3a\x3e\xb4\x8c\x78\x59\x07\xb8\x9b\x43\xa7\xb9\x00\xec\x44\xac\x3b\xec\x9e\xb3\xd3\xd5\x8e\x4d\x0d\xf5\x01\x56\x6d\xcd\xe7\xf6\xd7\x4e\x64\x2e\x51\xfd\x97\x7f\x4c\x0f\xae\x91\xd7\xab\xab\x90\x79\x61\x3b\x6c\x56\xb1\xb7\xc3\xcd\x54\x38\x8f\x08\x18\x5e\x66\x7c\xa6\x0f\x0c\x1b\x7a\x93\x6e\x86\x21\x7c\x8a\xf6\xaa\xe8\xbd\xa1\x3b\x5c\xb8\xe7\xd8\x4e\xa2\xef\x43\xe0\x82\xd7\xa4\x7e\xf1\x5d\x3d\xdf\xa5\x60\x2b\x5e\x36\xd4\xd2\xa9\x8d\xfd\xd0\x8f\x4a\x83\x3f\x06\x0b\x23\x2f\x2e\xae\x57\xc9\x7f\x5a\xc1\xdc\x44\x93\x22\xa0\x6d\xd5\x8b\xd8\x27\x69\x26\x5d\x45\x81\x13\x90\xbe\xd0\xd5\xe7\xdb\x53\xe1\x37\x7b\xdb\x43\xef\xac\x98\xb0\xe7\x32\x97\x87\x95\x76\x9f\x7f\x4b\xc4\x7a\x5b\x08\xf7\xbf\x2f\x38\xb9\x0a\x55\xe8\x00\x12\xd9\xc2\x21\xbb\xf7\x27\xa5\x0f\x29\xf8\x16\x26\x58\x49\x3f\x70\xb9\x5d\x5b\x6f\x75\x52\x68\xd3\xef\xc3\x4e\x36\x28\xc8\xe6\x3e\x12\xdf\x5f\x73\x99\x59\x34\x06\xeb\x7e\xb2\xf8\x70\xbf\xce\xd2\xde\xfd\xa6\x6e\xb4\x1e\x3a\x55\xde\x80\xf2\x97\xd3\xa2\xaa\xfb\xf5\xaf\x52\x72\x5f\xc1\xab\xd9\x25\x8e\x69\xe5\xe5\x5f\xcc\x2e\xed\xd7\x1b\x4e\xc2\xba\xe8\x75\xeb\x5d\x7e\xae\x7c\x4e\xfa\xb2\xda\xd7\xe0\xbf\x1d\xbb\x15\x64\xc9\xbc\xed\xaa\xc7\x9c\x71\x79\x2b\xb2\x95\x36\x40\xf8\xa9\x45\xb3\x16\x05\x41\x89\x8e\x8e\x5f\x8c\x7e\xac\x21\x10\x24\xff\x7a\x76\x07\xe2\xa7\x0b\xd9\xee\x05\x5c\x51\xa2\x63\x43\x36\x91\xa9\xb4\xc6\x6f\x21\x32\x1c\xfc\x85\x04\x5a\x55\x6c\x03\x5a\x91\x85\x34\x58\x4e\xfc\x28\x19\x1d\x03\xf1\xa1\x7c\x48\x7e\x40\xce\xbb\x4e\x7a\x12\x56\x29\x1b\xea\x38\xd8\x3f\x8c\xf9\x6f\xed\x06\x81\x1e\xbe\xb6\x9b\x19\x5f\x18\x91\x89\x80\x07\x09\x96\x73\x63\x11\x3d\xb1\x19\x4d\x95\x7e\xa6\x9c\x63\x69\x6a\x6f\x91\xb2\x48\x92\x08\x72\x59\x75\xb7\xb5\x80\x50\x2a\xb1\x42\xf7\xb0\xab\x75\xb6\x72\x4e\x90\x38\x3f\x81\xe1\xa9\x7e\x7d\x99\xac\xaa\x69\xc7\xad\xf9\xeb\x5a\x35\x8c\x89\x62\x48\xce\x0c\xcd\x8a\xe1\x76\xe8\x58\xaa\xb4\x83\x67\xfc\x07\x3c\x54\x75\x98\xb7\x2f\x51\x4b\x9c\x12\xb5\x54\x1c\x0b\xbe\x70\x24\xf9\x0b\x6c\x88\xac\xac\xb9\x96\x85\xd6\x23\xf5\xc8\x69\xf2\x82\x26\x40\x68\x84\x12\x1d\x77\x0f\xc8\x30\x7a\x9b\x22\x54\xa7\xdb\x8c\x70\x4e\x05\xa6\x87\xf2\x7c\xa6\xf9\xa0\xdb\x6d\x12\xb5\xf2\xd7\x59\x90\xe9\x9d\x2c\x6b\xc0\x91\xc6\x76\xf1\x4b\x33\xf1\x12\x8a\x1c\xba\xec\xe8\xcb\x61\xb9\x15\xfa\xe9\xfb\x1d\x37\xb2\x3b\x34\x12\x12\xf9\xd3\x03\xec\x96\xa5\x21\x89\xf4\xd6\xb8\x44\x96\xb2\x8a\x64\x7c\xc1\x0f\x92\xd8\x48\x83\x08\x7d\xc5\xc0\xc1\x43\xc1\x8a\xec\xf2\x5f\x58\x55\x6f\x63\x4d\x94\x98\xf7\x6d\xe4\xc3\x2b\xbb\x6d\x3c\xc4\xcf\xd5\x6f\x1d\x13\x98\xec\xaa\xc9\xf3\xa2\x09\x29\xa4\xd0\xfa\x77\x87\xc3\xce\x75\x8d\x2a\x0d\x87\xa2\x8c\xa9\x05\xc2\x2b\x26\x22\x38\x81\xa1\x9b\x02\x78\x1f\xd2\x9d\xc7\x4c\xc7\xab\x0f\xe2\xaf\xb1\x51\xfc\x7a\xa6\x79\x50\x5f\x04\xf9\x8f\x85\x6d\xec\xbf\xe8\x70\x66\x14\x69\xd7\x9e\xd4\xbb\x25\x17\x7a\x0c\x25\xd1\x93\x1b\xcd\xc2\x2e\xff\x36\x37\xfc\x21\x3c\x24\x87\x7d\x1f\xea\x12\x19\x2b\x7b\x5b\x7d\x38\x7a\x0d\xf3\x93\x57\x18\x1e\x70\x5b\x03\xc3\xd9\xd7\x01\x93\x9d\x2f\xad\xa7\xe5\xf6\x7b\xc7\x33\x2a\xd4\xa7\x4c\xb7\xd2\x03\xfb\x17\x81\x7b\x34\x2b\xa4\x80\xb4\xd7\xc2\x69\x35\x44\xdd\xe4\xc6\x56\x32\x07\xec\x1d\x42\xf2\x86\xfa\xe9\xd5\xe5\xf0\xe4\xd5\xdf\xf9\x85\xbf\x5b\x99\x81\xf6\x5c\xe6\xc4\x4f\x57\xdc\xc5\xec\xbf\x73\xfe\xc8\x30\x14\x64\xa0\x53\x43\xde\x8c\x02\xc8\x63\x42\xb8\x25\x85\xd8\x61\x02\xe2\x5b\x23\x47\x6c\xb5\xe8\xf1\x5b\xca\xb5\xb0\xa2\xeb\xa6\x2a\x03\xe3\x7e\xd8\x77\x95\x54\x83\xe3\x82\x0c\xf6\x32\x53\x37\xd6\xac\x96\xd0\x0e\xa0\xba\x2e\xce\x65\xbe\x96\x2e\xe3\xfb\x6e\x49\x8a\x83\x4b\xa9\x75\xb6\x08\xb9\xf6\xdc\x85\x21\x18\x6d\x0c\x68\xcf\x7f\x6a\x64\xce\x79\xc6\x86\x45\x82\xdd\x35\x08\x63\x4a\x25\x9a\xa0\x55\x39\xa9\x23\xda\x68\xd5\x85\x48\x69\x2c\x4d\xcd\xf7\x56\x49\x86\x76\x60\xe6\xce\x2d\x86\xc4\x3f\x42\xe7\x6a\xc5\x78\x09\x49\x6e\x9e\x47\xfb\x04\xa4\x3d\x89\xa8\x0d\x0e\xd9\xfa\xa4\x59\x75\xb4\x61\xc0\x21\xbb\xa2\x93\xcb\x81\xd7\xad\xe8\x86\x7f\x54\xdf\x9e\xe1\x36\x46\xb0\x26\xba\xb0\x28\x3e\xbe\xff\x46\xe8\xe3\xda\x92\xf7\xb1\xf6\x2a\x50\xa0\xea\x70\x6e\x77\xc2\x20\xaf\x1d\x33\xde\xe9\x46\x3f\x66\x9e\xd5\xe9\x32\x7a\xe1\x6e\x33\x82\x99\x83\x64\xb1\xe9\x68\xd4\x69\x5a\xfd\xbd\x58\x44\x4e\x02\xdb\x2d\xfa\x5b\x97\xed\x80\x08\x59\xdb\x6e\x8d\xda\xdf\x0a\x8c\xed\xdc\x25\xec\x7c\xdf\x04\xd7\x16\x14\x74\x08\x37\x08\x54\x58\x3d\x28\xca\xf1\xd5\x81\xeb\xe7\x64\xb4\xa2\xd8\xc0\x69\xea\x40\x78\x0f\xd4\x86\x1a\x83\xa3\x54\x7c\x67\x6e\x94\xed\x8f\x5f\xf9\x40\xe8\xda\x92\x28\x10\xa3\xd3\x85\x70\x78\x5f\xf6\xdf\x90\x27\x27\x83\xda\x80\x1e\xb0\xb7\x26\x46\x4d\xcc\xfc\x21\x0c\xcf\x87\xc1\x60\xe9\xda\x74\x73\x9c\xe1\x9a\x3f\x68\x05\xa8\x28\x3b\x28\x7e\x8a\xcb\xed\x56\xb6\x09\xea\x7e\x30\x7d\x2d\xdf\xad\x84\x79\x82\x4a\x1d\x90\x6d\xfc\x2b\x39\xff\xb2\x35\x37\x17\x89\x63\xcb\x62\x04\x0e\xec\x3a\x43\xcd\xb1\xfb\x4f\x64\x40\x88\x2a\xbc\x08\x82\x7e\x34\xb0\xb9\xa1\x4e\x69\x40\x88\xd2\xf5\x92\xbc\x1a\x5b\x72\xe7\x3a\x25\xa9\x8d\x2f\x3b\x1e\x3a\x91\xcb\x71\x2d\x09\xb3\xd9\x1a\xe0\xad\x96\xab\x1e\x17\xf0\x1c\x45\x35\xc4\xa1\x9d\x29\xbb\xa0\x42\xed\x0a\x1b\x0b\xd7\x83\xcf\xb5\xb6\x4c\xf5\x0a\xe0\x4a\xc3\x09\xa5\x95\x43\x96\xcb\xe3\x6a\xe4\x9a\x87\x10\xdd\xdf\x80\x51\xdd\xbf\xd0\xf2\x35\xe8\x33\x58\x73\x24\xeb\xf7\x16\x3c\x6d\xb3\x88\x23\xf7\xa9\x26\x6f\x55\x0e\x34\x07\x71\xc7\xbe\x01\xef\xcf\xba\xff\x68\x85\x3a\x98\xe5\x5c\x5d\x2c\x4f\x3c\x71\x71\x10\x29\x57\x75\x87\x35\xc0\xf7\xb7\x10\x79\xea\x86\x10\xec\xdc\x43\x62\xb7\x7f\x40\x10\xfc\x13\xb8\x33\x27\x24\x8b\x97\x81\x94\x95\x9e\x5c\xa4\xb5\xed\x2c\x5d\x8a\x8d\x62\x11\xe4\x9a\xa9\x58\x78\x74\x49\x58\x48\xa8\x0a\x0f\x9d\x5e\x98\xde\x24\x7b\x87\x34\x5a\x19\x7b\x15\x09\xd0\x8b\xb1\xd0\x40\xc4\x12\x6d\x19\xa5\x02\xe5\xd6\x8b\x39\x89\x5d\xbd\x7c\xdf\x1b\xd9\x59\x27\x51\xd0\x73\xfb\x2a\x96\xbc\xa3\x54\x60\x47\x0f\xe0\x08\xcd\x63\x9d\xff\x87\xd2\xba\x07\xe9\x13\xe1\x34\xdd\x96\x79\x14\x37\x55\x71\x93\xd3\xb0\xe4\x4e\xb5\x60\x2c\x7c\x74\x7a\xda\xa1\x96\x5d\x9c\x9f\x49\xd2\x94\x46\x35\x9a\x07\x1a\x14\x5b\x88\xfd\x47\xf1\xb3\x1b\xe3\x9f\x7d\xd6\x03\xf4\x80\xda\xf6\x56\x11\xdc\x90\xcf\xf0\xb9\x66\x35\x2b\x2e\x68\x06\x6a\xf2\x7c\x5d\x84\x42\x78\xf5\xe3\x4a\x39\x33\xfe\x16\x86\x1e\x32\x29\x4a\x2f\xc3\xed\x20\x80\x8b\x7c\x6d\x4f\x1a\xac\xbb\x82\x8a\x7f\xdd\xd2\x01\x7c\xa2\xd3\xc5\xfe\xdb\x5c\xc8\x5f\x68\x8c\x01\xcb\x6a\x8c\x66\x92\xac\xc1\x53\x3c\xdb\xfa\x88\x5a\x2b\x94\x5c\xd8\x53\x64\x31\xf8\x10\x88\xcb\x53\x40\x68\x03\x54\x38\xaa\x2f\x84\x48\x7e\xd3\x39\x34\xde\xca\x2c\x91\xa9\xe0\xdc\xa0\x1d\xe4\xc3\xec\xeb\x5f\xf6\x50\xc4\xaf\xe4\x09\xbd\x6b\xcf\x06\x1e\x71\xc3\x86\xbd\x37\xf3\xb9\x8b\x54\xd9\xa5\x09\x38\xe1\x6c\x4d\xac\x03\x23\xa2\x2c\x61\x58\x58\x13\x79\x99\xa4\xff\x0a\x2b\x4d\x85\x68\x72\x1d\x12\x34\x96\x01\xe4\xe0\x7f\xfc\x5b\xf9\x7c\xcb\x51\xdf\xa6\x7a\x85\xf3\xf8\x08\xde\xcd\xc0\xa7\x6d\x5b\x6a\xd3\xeb\x05\x34\xac\xa0\x05\x87\xab\x10\x0d\x2a\x0b\x53\xc6\xc4\xc5\x80\x21\x9c\x2f\x04\x70\x13\xe9\xd9\xdd\xfc\xb9\xe1\x78\xfe\x02\x46\x65\x2c\xe0\x09\x88\xa1\xc5\x55\x19\x7f\xc2\x5f\x6c\xeb\x88\xd2\xff\xee\xf2\x06\x1c\x52\xfd\x68\x37\x6b\x0e\x0d\x80\x9d\x64\xe8\x53\xe9\xb5\x8b\x64\x66\xec\x69\xa1\xec\xb1\x02\xe5\x90\x67\xdd\x44\xfc\x25\xab\x78\xf5\xc9\x7e\x70\x01\x4d\x93\x7d\x5c\xab\xa9\x89\x12\x56\x02\xbd\x09\x5d\xb4\xa7\x08\x64\xc3\x97\x4f\xba\xb1\xd3\xcb\xfa\xaf\xa8\xc8\x0a\x15\x0d\xeb\x60\x4d\x5f\xf5\x49\xdd\x98\x83\xc8\xc9\x1a\x92\x92\x3e\x5b\xc7\x72\xb6\x48\x67\x36\x44\xbf\x09\xb0\x91\xf2\xf4\x0b\x58\x2a\x92\xc9\x28\x33\x80\xd9\x50\xa4\x9f\x5c\x7f\x00\xce\xe5\xce\x58\x6a\xf2\x9a\x8e\x62\x1e\x63\x0b\x67\x8f\x20\xcc\x8d\x9f\x9a\xc0\x35\x16\x64\x6d\x19\xe3\x00\x25\x6a\x7e\x45\x68\xd3\x59\xdd\x10\x8e\xc5\xdf\x8e\xf9\x39\xd7\x97\xb7\x10\x4f\xf2\x1e\xc3\xbc\x00\xce\xe4\x68\x47\x1c\xce\xc1\x7d\xf0\x2a\x32\x7c\xca\xd5\x51\x6e\xe6\xd7\xc3\xe5\x28\xc2\xf5\x2b\x75\x0b\x45\x72\x84\xad\x83\xa4\x92\x34\x99\xc3\xa5\x5e\x53\xa3\xff\x04\x4c\x11\x94\x27\x94\x1e\xfe\xf0\x72\x0a\x2e\x98\x92\xd2\xa4\x9b\xd0\x2f\x6a\x7f\x6e\xa5\xba\x7a\x5e\xfb\x2e\x24\x06\x58\xe0\xd5\x4a\x2a\x6d\x18\x4c\xf2\x0f\x81\xa4\x7c\xbe\x6d\xa0\x1e\xfb\x7c\x59\x0e\x21\x3b\x64\xbf\xdf\x65\x8b\x72\x43\x73\x9a\x9c\xd7\xe7\x68\xec\x39\xe0\x32\xa8\x35\x67\xa4\x76\x93\x94\x87\xa4\x8a\xcf\x68\x25\x38\x41\x53\xbc\xeb\x7a\x66\x6a\xa8\x32\xd9\xc8\xe0\x39\xdc\x18\xeb\xef\x65\x6f\xf9\x16\xf8\xf7\xbe\x6d\xe9\x47\xf8\x3e\x02\xf4\x1e\xbc\xe1\xcb\x46\xe9\x0f\x8a\x7b\xfb\xbf\x43\xa1\x3b\x79\x97\x53\xfc\x71\xdd\xda\x98\xe3\x1c\x2a\x4e\x57\x4d\xb9\x23\x53\x64\xa0\x05\xf5\x40\x2c\xdc\x9a\x31\x54\x1c\xc8\x03\x22\x67\x70\x5a\x37\xaf\x1d\xf2\xf1\xf7\xa0\x39\x8f\xd5\x5e\x45\xc7\xf8\xf8\x44\x9c\xa5\x4b\x16\xd0\x56\x47\xca\xf9\xdd\xbf\xb3\xf9\x41\xda\x41\x67\xf6\xa6\x48\xad\x0c\xbe\xa0\x9c\xa7\xff\x6e\x1a\x65\x07\xf0\x69\x1f\x01\xd2\xf5\xde\xb2\x46\x6f\x91\xd9\xb7\xb4\x12\x58\x50\x60\x32\xa9\x0e\xf6\x22\x62\xb9\x87\xf5\x41\x82\x33\x3e\x80\x32\x36\xe9\x34\x48\x58\x86\xf3\x97\x3b\x25\x9a\x9a\x68\xc1\xa6\x55\xdc\xbd\xf4\x6b\xa4\x90\xe6\x1f\x32\xe0\xbe\xcd\xc9\x0c\xc8\x01\x18\x44\x7e\x9f\x1d\x63\x1e\x23\x56\xc1\x12\x3b\x09\x73\x8c\x79\xb9\x36\xa8\xed\x07\x2e\x81\x81\x29\x40\x35\x30\x6c\xc5\xaa\x3b\x89\x2d\x6d\x1f\x7f\x5e\xfd\xbd\xf7\xd6\xb0\xbd\xdd\x7e\x92\xef\xad\x68\x28\xfd\x0c\x89\xaa\x38\x44\x45\x19\x17\x53\x76\x1b\x2d\x15\xf8\x01\x64\x43\xc8\xa1\xb3\x68\xba\x60\x09\xa8\x6f\xd2\xa6\x76\xc6\x82\x28\x00\xdf\xfe\xd9\xad\xea\x78\xfe\xed\x85\x1e\x38\xdb\x62\x6d\xa5\xb9\x73\x19\x62\x69\x4d\x05\x14\x69\xe5\x0d\x89\x7a\x31\x12\xf2\x9a\xe2\x56\x43\xa4\xf6\xdb\xb3\x4c\x82\x28\x6d\xe9\x9f\xa2\x66\xa6\xdb\x35\xfa\x5b\x92\xb1\x7c\xad\xaf\x98\x00\x99\x5b\x91\xb4\xf3\x1c\xf3\xea\xc7\xb7\xa0\xff\xa1\xbc\x9b\x16\xa4\x7c\xb8\xe2\x1c\xf4\xef\x80\x41\x37\xf9\xd0\x46\xfa\xe2\x7a\x4e\x1c\x8c\xa1\x56\x34\x9a\x78\xe5\xdb\x71\x6a\xa3\x13\x4f\xce\x17\x1f\xba\x01\xfe\x9d\x1a\x96\x34\x31\xe0\xf7\x4e\x77\x47\x2d\x4a\xe6\xde\x69\x27\xf0\xdf\x22\x10\xe1\x99\x51\xf9\xba\x13\x22\xf7\x16\x10\x3b\xfb\x0c\x93\x23\x22\xe9\xc7\x82\x9d\x4c\xc6\xaf\xda\xfd\x37\xbf\x77\xc6\x96\x30\xfa\xfa\x86\x2c\x67\xc0\x25\x71\x7a\xcb\x1b\x62\x6f\xe8\xa6\xd0\x61\x8e\x43\x39\x16\xa0\x5d\xd7\x8a\x3a\xaa\x21\xe7\x5c\xcf\x3e\xc9\xb0\xbb\xbe\x6b\x29\x6a\xe5\x1f\x24\x3d\x33\x68\xe5\x03\x13\xc1\xcb\xa9\x2a\x51\xf2\xbf\x9b\x6b\x01\x8d\x11\x2e\xcf\x27\x6e\x10\xce\x25\x28\xb1\x59\x1a\xaf\xbf\xd3\x04\x23\x44\xe9\xe7\xee\x49\x5f\xde\x81\xdc\xb3\x8f\x0b\x79\x73\x49\x19\x47\x08\x43\x44\xa6\x28\x3b\x3a\x07\x9c\x13\x95\x68\xfe\xb3\x50\x39\xfa\xbf\x44\x4f\x02\x02\xef\x2d\xbd\x01\x44\x63\x3b\x25\xae\xfa\x97\x95\xfc\xeb\xd7\x2c\x35\xae\x1f\xbc\xeb\xa9\x62\xf4\xf1\x83\x74\xa8\x5c\xbe\xc9\xda\x3f\x6f\x80\xcb\xc1\x34\x71\xf5\x40\x36\x4d\xaf\x01\xe1\x7e\x65\x11\xfc\xbb\x6f\xe2\x71\xf2\x83\x0e\x8d\x7f\x7e\x77\x1f\x4d\x5c\xb9\x25\x8c\x28\xc0\xbf\x66\x24\xa4\x5e\x54\x55\xae\x95\xaf\xb1\x79\xd3\x54\xc3\x68\x25\x1e\x92\x33\xef\x95\x56\xb0\x1f\x2e\x00\x46\x30\x2c\x54\xb5\xbf\xab\x4f\x71\xf3\x24\x8d\x13\x7d\x1f\xc5\x0d\xde\x77\xe3\x7a\xae\xf2\x72\x9e\xfb\xb8\x7f\xa0\x7f\xe6\xcb\xdd\xed\xbb\xa4\xad\x9c\x90\xe0\xfd\x64\xe2\xed\x80\x11\xa5\x2c\x58\x02\x38\x80\x9d\x85\xc0\x26\xef\x35\x72\x87\x7c\xa6\x06\xd7\xc8\x2e\xf1\xa1\x7f\x43\x23\x3c\x17\xb6\x1a\x99\x18\x30\x8f\x70\x0f\x49\x13\x11\xea\x5b\x8b\x83\x9a\x43\x78\x91\x38\x04\x07\x70\xfb\xd6\x76\x8c\xb4\xa9\x24\x91\xb6\x9c\x16\x72\x1d\xb9\x17\xe9\x99\x9e\x89\xc1\x85\x6c\x7d\xdd\x31\xa2\xfc\x96\x1b\x7f\x8b\xf0\xec\x77\x35\xb8\xb3\x74\xf2\xdb\x01\xfe\xa9\xd4\x77\x85\x49\x2a\xb8\xd9\xe5\x5f\xab\x1e\x95\xf7\x7f\xd1\x2c\xba\x1a\x76\xaf\x69\xbe\xfd\xa0\x6f\x0e\x25\xef\xfd\x91\x78\xaa\xca\x58\xd7\x4f\x8f\x4f\x84\x8e\xed\xae\xc6\xe0\x52\x02\xb3\xe0\xd1\x42\x1a\xd5\x83\x08\xcc\xb5\xb7\xa0\x36\x98\xe6\xae\x45\xaa\xd6\x4c\x0b\xd3\x49\xfd\xfd\x42\x03\x95\x1b\xfb\xc6\x0b\x4f\xe3\xf9\x63\xc3\xe4\x2a\xf8\x0e\xae\x62\x80\xc2\x40\xa2\xb1\xf3\x7f\x03\x8a\x71\x0b\x8e\xcb\x47\x1f\x4f\xe3\xc0\x56\xa1\xc8\x5c\xd9\x3c\x37\x2d\x40\x12\x44\x01\xa7\xdc\x78\x08\x69\x44\x22\x19\xec\xe1\x52\x80\xb7\xae\xb0\x4f\x46\xc0\xf7\x39\x4c\x87\xd4\xa3\xad\x71\x06\x31\x2c\xd6\x1a\xf0\x44\x70\xc5\x55\xdb\xe6\x1f\x1a\xe9\x6a\xcd\xe6\x46\xbb\x58\xe5\xf6\xfe\x6f\x88\x94\x09\xec\x88\x21\x98\xcd\x6d\xc0\x7b\x32\x77\xff\x93\xdc\xb0\x76\x14\x9c\xd2\xc8\x5d\x60\xf6\x07\x8d\x6e\xbe\x0b\xdd\x8d\x70\xfd\x41\xc6\x63\x19\x12\xc1\x2b\xe3\xfb\xc5\x79\x16\x93\x28\x58\x8e\xb7\x25\x77\x35\x76\x63\xab\x6f\x83\x0c\xd5\x53\x90\x64\x8a\x29\xe0\x05\xfc\x7b\xa0\xd0\x8e\x09\x1a\x33\xf8\x4e\x1f\x63\x89\xe9\x81\xf7\x6e\x19\x84\xc7\xb3\x07\x18\xd8\x13\x99\x21\xe1\xf1\xa9\xe7\x32\x9f\x7e\x19\xba\xee\xfa\x7f\xc3\xaa\x6c\xd2\x9b\x51\x44\xc3\x36\xc3\xad\xa1\xa3\xdf\x86\x23\x0f\x3d\xaa\xe4\x74\x1d\xd7\xf3\x5a\x61\x07\xf5\x7e\x07\x64\xda\x34\xc0\xd0\x3e\xd1\x0e\x69\xe1\xff\x7c\x0a\x98\xd7\x98\x0e\xb0\x87\xc1\xf2\x54\x27\x23\x20\x1b\xc3\xdf\xf3\x3f\x25\x19\x8c\x7e\xd5\xc6\x05\x98\x58\xeb\x61\x48\xab\x07\x88\x3a\xf9\x32\x0f\xe3\x66\xb3\x61\x7d\xcd\x08\xa1\xe7\x08\x65\xc8\xbc\xbc\xda\x9f\xeb\x31\x1e\x57\xdf\xdb\x32\xdf\xc8\x77\x73\x57\x7c\x9b\x6f\x24\x7f\xd9\x20\x9d\x3e\x5d\x42\xd7\xbd\x1b\x1e\x3a\x58\xbb\xf5\x47\x00\x8e\x69\xd0\x37\x85\x08\x53\x6c\x7a\xd5\x51\xc7\xb2\x65\x5d\x8f\xc5\x91\x45\xe9\x91\xfb\x9a\x18\x96\xbd\xde\x4c\xe7\xe9\xfe\x54\x08\x1f\xc8\x79\x0f\xb8\x6f\x8c\x50\xa1\x0f\x15\xbf\x5d\x1c\x5f\x54\x62\x49\x23\x8d\x7d\x85\x07\x90\xd9\x4e\xbb\xe7\xba\x07\xc0\xbc\xd9\x5f\x69\x3f\xf4\xcd\xd1\xa3\xd7\xc5\xf4\x2a\xbf\x4d\x29\x06\xb6\x8c\x6f\x7f\x3c\x4a\xd7\x18\xad\x7f\x49\x1c\x6b\x5c\x04\x84\x85\xc9\x19\x75\x43\xc7\xc6\xca\x0a\xf8\x03\xda\xbb\x3a\x74\x53\x90\x7b\xdc\xb0\x17\xba\xb7\x82\xc2\xde\x10\xcb\x5d\x96\x4e\x5c\x98\xbb\x8b\x20\x11\xa7\xc9\x2f\x67\x36\x52\x95\xfe\xd2\x89\x60\x47\xec\xf5\x78\xef\x50\xc9\xc9\x7d\x7e\xda\xcd\x19\x9b\x8f\xdb\xa2\x72\x80\x27\xe7\xcd\x73\xc8\x9d\xf7\x9d\x16\xcc\x65\x6c\x10\x1e\xcb\x56\xd8\x0d\x56\x2c\x04\x5d\x20\x40\x6f\xed\xc3\x8f\xff\x50\x89\xd4\x1d\x81\x37\x3a\x21\x8e\x97\x9d\x07\xab\x1e\xa7\x5a\x6f\x13\x5a\xa6\xe1\xa1\x2f\x99\x2b\x9a\x6d\x70\xa0\xb8\x37\x48\x6d\x5d\x80\x11\xa4\x61\x8e\x2e\xff\xfe\xfd\x9e\x1e\x24\x37\xc0\x28\x41\xab\x50\xac\x7b\x2e\xa0\x5b\xaa\xff\x83\x1b\xdd\x02\x24\x09\x13\xe4\x50\xd5\x9e\x6f\xcf\x9b\xfd\x5b\xfc\x8a\x54\xe0\xfb\x81\x0f\x7a\x0a\xb5\x52\x67\x04\x7a\x69\xc3\x03\x1e\x10\x20\x6f\xf8\xa2\x21\x79\x9d\x08\xa5\xef\xe7\x87\x53\xb2\xc2\x89\x13\xc0\xfc\x85\xc5\xca\x0b\x87\x73\xc2\x07\xb4\xf0\xa2\x08\x6e\xde\xa3\x64\xb6\xe6\x2d\xe4\x34\xd9\xd4\xcc\x19\xda\x3d\xad\xba\xce\xa9\xa1\x73\x8d\x11\xab\x6e\x05\xf5\xe3\x81\x30\x22\xc7\x54\x63\x9e\xb1\xaf\x87\x37\x28\x8f\x3d\xd1\x0e\xa6\x1d\x35\x3c\x68\x7a\xf4\x89\x38\xe1\x97\x2d\x69\x06\x61\x61\x0e\x45\xdb\xc0\x53\x93\xc0\x84\x6e\x60\x7b\x90\xf7\x33\xc4\x63\x77\xa9\x6d\xf8\xe3\xac\x8c\xc8\xd0\x9c\xdb\xa7\xbb\x79\xb3\x3a\xca\x87\xfb\xb3\x06\xaa\x6c\x0f\x8e\x92\x13\x5a\x05\x4a\xc2\x91\x27\x75\xbb\xc5\x4f\x2b\x77\x37\x02\x9c\x41\x07\x70\x10\xb2\xf0\x61\x6a\xa7\xf0\xe4\x2d\x0c\x0b\xbf\x86\xda\xbd\x56\x32\x3c\xec\xf1\xf5\xf6\x92\x9d\xf4\xca\xdb\xb9\xb7\xbf\x0e\xfb\xff\x34\x3c\x19\xef\xdb\x50\x0c\xe5\xfe\x01\xd0\xe4\xe7\x7a\x03\x10\x87\x98\xd3\xbb\xe1\x5d\x3f\xe9\x07\xad\x71\x41\x40\x50\x4d\x8d\xe2\xa7\x64\x38\x3f\xe5\x57\x85\xbc\x98\x0c\x42\xbd\x6d\x3f\x23\xbc\x8c\xbf\xb7\x03\x07\xf0\xe3\x66\xe2\x5a\x81\xcb\x14\x82\x05\x37\xf0\x9c\x1b\x7e\x21\xa2\x07\x8c\x95\x30\xd3\x89\x31\xa0\xc1\x23\xdb\xe2\x3b\x1c\x99\x16\x74\xbf\x57\x31\xff\xb2\x79\x4c\xaf\x92\xd5\xae\xc4\xbc\x9a\x67\x00\x1e\x63\x83\xe8\xb4\x7f\xcc\xff\xe7\x00\x6e\xdb\x69\x20\xe8\x35\x2a\xcd\x08\xca\xb3\x57\x1a\x2c\x95\x02\x13\x5d\xa0\x31\x5c\xe1\x79\x71\x68\xca\x81\xed\x3d\xcc\xc7\xd9\xde\x00\x4c\x03\x41\x0b\x04\xf5\x97\x81\xf7\x57\x80\x9e\xe0\x48\xbd\x27\xe5\x32\x61\xf8\x72\xf9\x7f\xd2\xfa\xff\x71\xe5\x99\x7b\xd8\x29\xd7\x55\x0d\x68\xf2\x0e\xfe\x5e\x57\xb2\x6f\xca\xc2\xda\xab\x40\xc6\xb8\xbd\x6b\x69\x80\x99\xbf\x17\x09\x8a\xc4\xe3\x6b\xe6\xe8\x7e\x5e\x0f\x5c\xb8\x53\xe3\xee\xc9\xf4\x03\xba\xb0\xfb\xc8\xb0\x23\xd7\xd1\x76\xe2\xac\xbc\x5f\xbd\x91\x81\x48\x9e\x21\x9e\x68\x4a\xe6\xc7\x1a\x43\x62\x03\x53\x61\xe7\x22\x18\xc8\x3e\xf8\x70\x2d\xdf\xe6\x22\xdf\x90\xf3\x0a\x31\x97\x47\x43\x72\x5c\x72\xad\x0a\x39\xe6\xb7\x31\xa3\xda\x1a\x4a\x8a\x60\x17\x05\x93\x4d\x4c\x36\xeb\xfe\x44\x5c\x23\x1d\x0c\x2f\x6e\x4e\x84\x36\x75\xce\xba\xd8\x50\x13\x7e\xd4\xec\xf8\xd3\xfd\x89\xd0\x18\x80\xbc\x90\x21\x7a\xbb\xf8\xa2\x98\xef\x55\xd9\x2d\x55\xdd\xcf\xbe\x67\x03\x47\xe3\xda\x47\x6d\xcb\xb8\x82\x32\x0d\x40\xf1\x4c\xd8\x19\x47\xba\x38\x04\x6f\xa5\x94\x2b\x7b\x71\x27\xf6\x9b\xdb\x84\x58\xc0\x43\x02\x64\xc9\x6b\x7a\xf5\xb0\x54\x78\xbb\xc7\x72\xa5\x35\x6d\x10\xc7\x08\xa0\x08\x44\x09\x58\x34\xeb\xf9\x7c\x48\xb6\x15\x76\x2e\x98\x64\x44\x0d\x69\xba\xd8\x98\x68\xbc\x4f\x89\x96\x6d\xd3\xa9\xe5\x1d\x08\xf9\x51\x33\xac\x0c\x07\x24\x28\xc4\x33\x46\xf1\x8a\x6f\x34\xa8\x09\x57\xab\xda\x00\x4c\xa8\x84\x2e\xbb\xab\xb7\x65\x18\xc0\x7e\xb2\xb1\x11\x13\x43\xcd\x54\xf7\x60\xeb\x8c\x93\xe4\x41\x89\xed\xa5\xe0\xb9\xbc\x29\x09\xc1\x06\x20\xca\x74\x5c\x5a\xc9\x67\xec\x73\xa4\xfd\x2d\x21\x5e\xad\xb0\x55\x3d\x0a\x55\x2d\xa4\xf8\xd7\xde\xfa\x50\xf2\x8b\x46\xa1\xc1\xae\xe7\xb9\x94\x61\xc7\xdb\xc4\x0b\x78\x9f\x8b\x18\x3c\xf3\x54\xc2\x07\x15\xcc\xf9\x19\x7c\x3c\x5d\x33\xc2\x00\x98\x07\x47\x64\xf6\xd1\xc9\x7f\x7c\x90\x1a\xad\xee\xfd\xec\xd3\xd2\xd5\x8e\x18\x0f\x83\xaa\xf7\x59\x77\xd3\xd2\x41\xdc\x33\x91\xc4\x47\x00\x12\x28\xf3\xf5\x3f\xde\xd8\x68\x2d\xad\x69\x2c\xbc\xae\x71\x1a\x0d\x0b\xa8\x14\xc5\xba\x0c\x6a\x2c\xc4\x14\x2e\x4c\xf1\x56\xf1\x7e\xed\xe5\x07\x72\x68\x76\x3c\x51\x79\xcd\xe3\xe5\x73\xd7\x28\x93\x91\xa9\x16\xdc\xdd\x22\x55\xed\x6e\xe4\x8f\x23\xe6\xd8\xb7\xd0\x77\x33\x83\xc3\x6b\x86\xa6\x7c\x04\x62\x5a\xad\xc7\x82\xdb\xf4\x9b\xf6\x3f\x85\x09\xcf\xfe\x58\xb9\x8d\x94\x18\xfd\x71\x5a\x2a\x1a\x7b\x7a\x60\xdd\xc8\x3c\x01\x95\x4f\xf8\x29\x72\xd3\xf5\x56\xbe\x54\x09\x65\xb7\x96\x8d\x29\x33\xb8\x78\x69\x70\x3e\xa0\x3c\x71\xf2\xa7\xe5\xf5\xb4\x67\x6e\x38\xf4\x89\xeb\x69\x4b\x60\x8b\xef\x40\xb3\xa2\xc2\xa0\x5d\x6b\xec\xeb\x60\x8a\xb9\x1d\x47\x4f\x9a\xeb\x43\x31\x5d\x69\xd3\x2d\x1f\x59\xba\xe1\x18\x26\x09\xd3\xfb\xce\xf8\xb6\x6d\x92\xd3\x30\x92\x47\x83\x18\xc6\xa2\x0b\xc7\xbb\x9c\xf0\x38\xd2\xbd\x42\x2a\xde\x97\xbc\xe7\x04\xf1\x2a\xa4\xe2\xb3\xe5\xfc\xc6\xf1\x05\xe2\xda\x6b\x3e\x5d\x74\xfc\x88\x4a\x63\x4a\x8a\xd4\x09\x96\x85\x51\xdc\x97\xea\x00\x75\xd2\xfc\xfc\x76\xd7\xaa\x99\x03\x28\xcf\x4c\xec\x34\xfe\x09\x29\x16\xbf\x6e\x34\x49\x24\x0b\xd3\xd2\x74\x75\xb5\x94\x78\x29\xe3\x71\xa0\x12\x30\xc0\x00\x84\x4f\xff\x58\x7a\xb4\x4b\x54\xa7\x02\xe0\x7e\x40\x22\xb0\x55\xcd\x3c\xee\x79\x77\xb4\xad\x51\x05\x26\xac\x00\xaa\xcf\xcd\x77\xbc\xd7\x69\x8d\xa9\x63\x0f\x2a\xd3\xce\x5b\xc2\xb7\x8f\xd6\x51\xc0\x4c\x95\xa5\xae\x80\x57\x20\xa3\x2e\x57\xb4\x5f\x1b\x8c\xe4\xb7\x5e\x9e\x25\xe8\x54\x78\x8a\x8e\x71\xb5\x04\x9c\x7e\x65\x38\x51\xd9\xcb\xf2\x4d\x33\x7b\xb0\xec\x8e\x31\xaa\xc3\x1c\xfd\x02\xc0\x6a\x95\xc6\x9c\xb1\x9e\x84\x9d\x79\x26\xa6\x74\x80\xb8\xd9\x76\xed\x3a\x4d\xf3\x88\x93\xbf\xca\x15\xf1\x32\x94\xb7\x80\xab\x57\x65\xc4\xdf\x9a\x55\xb5\x58\x7a\x27\xec\x93\xbc\x93\x37\xa7\x87\x9f\x51\xe4\xf3\x37\xad\x3e\x1a\xdd\x61\xf2\x18\xa1\x10\xc4\x7d\x55\xdd\x30\x97\xc1\xb9\x9f\x87\xca\x44\xff\x33\xb5\x99\x47\x45\xbc\x09\x2e\x6c\x75\xaa\xa1\xa3\x08\x85\xc2\x54\xf0\xf8\x79\x12\xa4\xbe\x77\x60\x29\x8a\x7e\x1f\x12\x74\x4a\x30\x1b\x9f\x2e\x40\x7d\xcb\x94\xdc\xd9\xcd\x94\x01\x98\xb4\x8b\xe3\xea\xaa\x95\xb3\x8e\xca\x68\xc2\xd5\xa6\x30\x72\x52\x44\x6b\x32\x1f\xb0\xae\x2a\xff\x4c\x95\x4c\x9b\x6f\xfc\x63\xae\x8b\x43\x28\x48\x87\xc2\x5a\xe3\xc5\x31\xe4\xc1\x1c\xf8\x42\x98\x57\x31\x11\x8c\xaa\x99\x7e\xe7\xc5\x97\x97\xcc\x04\xb0\x59\xe8\x53\x7b\x1b\x91\xee\xee\x54\xde\x8b\x5f\xd5\xc2\x52\x9c\x18\x72\xf2\x93\xf9\xb1\xe1\x28\x57\x5e\x33\xca\x6c\xe2\x3b\xaa\xc7\xb5\x42\x55\x3f\x4d\xf3\xa0\x70\x29\xc6\x73\x7d\x08\x17\x2f\xe1\xfa\x2d\x29\xb7\x70\xa5\xef\xe0\xd0\x40\xe1\x68\x14\x70\x3e\x68\xc7\x47\x75\x18\x78\x21\x49\x1e\x25\xef\xe6\x51\x43\xb9\xcf\xbf\x42\x19\xc1\xff\xd8\xf1\x02\xe4\x4f\x22\x54\x18\x8e\xbc\x2d\x0c\xf6\x5c\x14\x16\x0b\x9c\xf0\xc2\x43\xa8\xa0\xf1\xfa\x4a\xef\xb7\xaf\xa4\xbd\x2f\x62\x0c\xe0\x1a\xc5\x1c\x9e\x3d\x9a\xf4\xf0\xbb\x11\x07\xad\xe8\x37\xb3\xef\x13\x4b\xcd\x85\xd9\x8c\x0c\x73\x60\xd7\x7a\xbb\xc3\x25\x35\x1f\xae\xdd\x2f\x1f\x54\x69\xfe\xe9\x65\xc2\xf5\x83\x27\x92\xf7\xf2\x01\x05\x55\xde\x34\x47\x6e\x78\xee\x7b\x48\x44\xa8\x00\xcd\x86\x13\x96\x61\xcf\xde\x9c\x2a\xda\x9d\xbc\x54\x25\x01\xb6\xa0\xc1\x1e\x4a\x9c\xab\x4c\xba\x71\x5a\x7d\x84\xa0\xd4\x2c\x5e\xcc\x1c\x88\x60\xdf\xd0\xaa\x15\x17\x02\xaa\x12\xd8\x30\xee\x84\x36\x5c\x41\x95\x0c\x3a\x65\x07\xa7\x42\xf7\xa2\x3d\x03\xa5\xf1\xe2\x25\xf6\x9e\x45\x53\x50\x83\x49\xfb\x49\x57\xdc\xc2\x7c\x9d\x36\x30\x4b\xc1\x09\xb5\xa8\xe7\xde\x4d\xe3\x09\xf0\xf2\xda\xd9\x27\x7d\x63\x54\x91\x77\x49\x01\xe8\x22\x30\xa7\x10\xe4\x7f\xef\xcb\xf4\x51\x19\xb6\xae\x62\x18\x37\x07\x76\x7f\x3a\x0b\x94\x9f\x1e\x44\x75\xb5\xda\x8c\xee\x45\xb1\xb8\xfb\x2e\x51\x0b\x62\x94\xab\xbb\x8a\xb1\xed\x38\x37\x39\x39\xa7\x60\x27\x23\xf6\x78\xe9\x96\x34\xfc\x6b\xc1\xcd\x8c\xea\x56\xcb\xb7\xb0\xea\x1f\x88\xf4\x5c\xee\x3a\xa9\x85\xa7\x7a\xd8\xf3\xaf\x80\x65\xee\xef\x8e\xe6\xe3\x63\x9f\xd7\x72\xde\x15\xdb\xd9\x0c\xac\x25\xb8\xa5\xd9\x3d\xa9\x9f\x6f\x47\x69\xe5\x56\xa5\x04\xf4\xaa\x73\x0b\xe2\x0b\x81\x97\xb7\x52\x4e\x15\xd9\xdf\x34\x5f\x0d\xcd\x1a\x47\x6f\xab\x6a\xf6\xcc\x76\x7d\xa4\xff\xae\xb6\x9b\x20\xf4\xa6\x35\x83\x22\x1a\x30\x07\xa7\x09\xd7\xd5\xa4\x88\xf4\x3a\x13\x4c\x6c\x35\x61\x47\xac\x01\xbb\x4c\x30\x6f\xd5\x00\x9e\xb8\x16\x19\x50\xcf\x54\x5e\xb0\x9a\x61\xb8\xc8\x21\x3a\x3e\x3a\x1c\xfb\x0d\x03\xa9\x66\x64\xe9\x93\xd5\x78\x2c\x15\xbc\xe0\xe5\x99\xe9\x4a\x1b\x5a\x71\x2b\xfa\x81\x9f\xda\x75\xa9\x33\x39\x52\x1c\xbb\x7d\x3c\x1b\xc1\xeb\x08\x2b\x0a\x95\x2b\x94\xe0\xe5\xc0\x70\x60\x65\xd1\x0a\x51\x6e\x36\x9b\x51\x51\x5f\x81\x13\x5d\x60\x8e\x27\x7e\x3d\xed\xe3\xbf\xa0\x67\x0b\x2a\xb7\xdc\xf0\x15\x8c\x5a\x38\xbb\x25\x6c\x18\xb6\x2a\x6f\x7b\xd1\x2d\xf4\x9a\x5d\x11\xfd\x90\x7d\x97\x3d\x7d\x08\x4d\x8f\x74\x0e\xa0\x42\xeb\x26\xc2\x28\x1f\xb5\x39\xff\xf7\x6e\xfc\x7c\xe3\x33\x09\x8c\x3a\x24\x13\x57\x8b\x2d\x03\x7a\x67\xd5\xde\x82\x5a\x03\xd1\xad\x15\x4c\x04\x97\x66\x09\x46\x23\x53\xca\x95\xbb\x31\x5f\xd2\xd1\xa2\x72\xb8\xc0\x73\x7a\x66\x2c\xed\xdc\x48\x02\x5c\xa1\xa3\x6b\x46\xb5\x82\xd2\xeb\xed\xf9\xc4\x41\xc2\x5f\x80\xe8\x16\x3d\x5c\x1a\x5a\x37\x1d\x80\xdb\x59\x4c\x3b\x89\x62\x63\xb9\x02\x1c\xab\x96\xc5\x23\x85\x05\x08\xb1\x50\xc2\x7e\x90\x39\x8e\x1f\x5f\x58\xe5\x0b\x09\x8a\x97\x21\x76\xfa\xcb\x0b\x4d\xf1\x54\xe6\xd0\xad\x8c\xfe\x9b\xfd\x6a\x0c\x7d\xe2\x55\x03\xa9\x6b\x0c\xfd\x29\x0d\x17\x72\x75\xa5\xaa\x45\x74\x9a\x5e\xef\x1f\xd9\xf9\xe8\x9a\x86\x73\xc1\x2c\x18\xdf\xb5\x8b\xb7\x02\x5c\x9a\x78\x58\x16\x4c\xe4\xe1\x5f\x04\xdb\x01\x4b\x7c\x2c\xb8\xed\xed\xf1\x68\x8a\xf3\xc3\x1d\xba\xb1\x38\x14\xf1\xa3\x38\xf1\x49\xde\xd7\xa8\xe6\x32\x08\xf9\x60\xfc\x06\x9b\xb4\x69\x36\x02\x8b\x9a\x30\xef\xd3\xc5\xa9\x8f\x62\x9e\x43\x88\x2e\xaf\x28\x79\x31\x8a\x06\xf3\xe9\xf6\x6b\xd4\x2f\x3e\xb3\x8f\x96\x2a\x7c\x9e\xb0\x24\x89\x80\x9c\x67\x2f\xc8\x46\x09\xf7\xf0\x2a\x34\xde\xb7\x10\xdf\x54\xee\x19\x06\x4d\x71\x3e\x0d\x0d\x76\x7d\x83\xe9\x81\xda\xb7\x1d\xba\x07\x2a\xd9\xd2\xac\x98\x12\x6c\x33\xf2\x5e\xd9\x7e\xe3\x18\x86\xf8\xdf\xe1\x1c\x4d\x7f\xf6\x0a\x29\xcb\x46\x8b\xb4\xe8\x86\xb7\xbe\x26\x4f\x7a\xb3\x1d\xcb\x11\xbf\x9c\x19\x5c\x6a\x56\x99\xe8\xa1\xaa\xae\xf0\x8e\xcf\xc2\xbe\x82\xdc\x95\xd8\x67\xee\x38\x7e\x68\x0e\xaf\x57\x14\xcf\x0e\x8d\x35\x1d\x0a\xfc\x46\xb7\xfb\x95\x72\xf2\xcc\xe5\xa3\xf2\x10\x23\x13\x73\x7d\x07\x83\xbd\x89\x5c\xa1\x66\x61\xe5\xfb\x49\xac\xe2\x5d\x6d\xa5\xaf\x5a\x7c\x8f\x31\x3f\x0a\xbc\x10\xa1\xdd\x68\x97\xab\x36\x32\x81\x36\x30\xcc\xd6\x8f\xd5\x80\x9c\xfb\xe7\x2a\xe0\x1f\xc8\x98\x1b\x83\xf6\x0a\xd0\x17\xfa\xd2\x87\x9f\x03\x85\x01\xd8\x29\xa2\x61\xa2\xb0\x21\x12\xff\x0b\x6f\x2e\x9e\xf4\xeb\xb7\x1f\x86\xb5\xbf\xb8\x4a\xb8\x75\xe8\xb9\x9f\x75\x77\xe9\x2a\x07\xa8\x1d\x4d\x1f\xb3\xfb\x8e\xfa\x1a\x2d\x5b\xcb\xd8\xda\xfe\x03\xb5\x40\xa3\x10\x43\xad\xf9\xb4\xd0\x02\xea\x45\xa0\x1f\x2b\xcc\x45\x23\x2d\x00\x5c\x6a\x49\x02\xc7\xe2\x65\x05\x4c\x00\x3e\xc5\x61\x97\xd7\x81\x75\x53\x1f\x51\x11\x9a\x42\x0b\x30\x41\xba\x21\x96\xcd\x6a\xdc\x12\xc7\x28\xf0\xa3\x2f\x45\x22\x4b\x7b\x19\x06\x27\x33\x7f\x67\xbd\x21\x19\x40\x99\xf3\x87\x59\x83\xf6\x5f\xe4\x74\xa2\x2b\x0e\x68\xfd\x41\x67\xe6\x83\xe6\x31\xd8\x62\x5f\x68\xcc\x1f\xde\x93\xa5\xb9\xcd\x5a\xc4\xf3\x2c\x7d\x4c\xf1\x2b\xd6\x74\x68\xde\x8b\x0f\xb4\x43\x92\x0d\xaf\xde\xc0\xb0\xb6\x1c\x83\x91\x78\x7f\xa3\x66\xb5\xff\x7c\x06\xde\x99\x3a\xa8\x24\xa1\xbc\xf5\x3b\x91\x30\x53\xac\x27\x52\xe3\x07\xb0\x97\x79\xbf\x3d\xc6\x02\x85\x2d\x7f\xe3\xed\xe9\xbe\xa5\xc9\xd6\x21\x1d\x7c\x67\x3d\xd0\xfa\xce\x23\x0d\x41\x5c\xd7\xa0\xd7\xa3\xe6\x80\xe7\x8b\x67\xdb\x7f\x00\x87\xd4\x7a\x88\x33\xf6\xce\x03\xa0\x4e\x91\x58\xc8\x02\x7c\x10\xdf\xd5\x7a\xeb\xff\x53\x55\x46\x93\x9a\xe7\x5b\xd6\x9c\x82\x32\xad\x92\xb1\x23\xe7\xee\x9f\x01\x4a\x24\x30\xe8\xb2\xf4\x4a\xa7\xb4\xad\x50\x30\x16\xd6\xa7\x98\xdf\xa7\x67\xac\xc5\x12\xc4\x10\x85\x9b\xf8\x60\xbd\xd6\xb1\x0b\x1b\xee\x6a\xb6\xfc\xf7\xe7\xfd\x5c\x56\xeb\x30\x50\x14\xd2\x69\x36\x00\xf9\x0e\xdd\x4d\xf0\x3f\x01\xd6\x71\x1e\x43\xaf\x4c\xb5\x98\x8a\xe0\x52\xc8\x32\x14\x31\xa7\x3e\xaa\xf7\x71\xd8\xc8\x22\x12\x59\xf1\xd0\x6a\x59\x49\x4e\x56\xd2\x3c\x8f\xec\x79\xc2\xdb\xe0\xf4\x44\x81\xe7\xa3\x94\xd5\x57\x01\xca\x46\xfe\x06\x97\x7c\x8f\x1c\x33\x30\xf7\x00\xeb\x9b\xd7\xe1\xb7\xe4\x1c\x24\x9f\xd3\xcc\x77\xa6\xab\x58\xda\x37\xaf\x80\x7d\x85\x89\x63\xc2\xc6\x3c\x70\x91\x44\x04\xd0\x43\xcb\x3e\x25\x75\x09\x02\x68\x2c\x0e\x89\xcb\x7d\x9f\x4a\xa5\x63\x60\x8e\xf9\xdd\xc5\x4f\xcb\x2c\x62\x7a\xbf\xc9\xac\xd5\x62\x81\xc5\xb3\xe8\x63\xa4\x21\x3b\xbb\x07\xd7\x17\x9d\xd7\x8e\xc6\xaf\xf3\x83\xae\x4f\x42\xf7\x12\x27\x0f\x5a\x4f\x10\x82\x8d\xf9\xf6\x5a\x4f\x7d\x07\xf6\xbc\xee\x02\x0c\x79\x8c\xbf\xf8\x12\x2b\xb1\x06\xec\x79\x15\xc7\x88\x7b\xf6\xd3\x1c\x18\x18\x0a\xce\x43\xa6\x64\xaa\xfe\xfb\x59\x49\x5b\x10\x0b\x93\x78\x8b\x68\x72\x82\xeb\x9b\xfd\x25\xfa\xc9\xe9\x24\x2e\xfa\xbb\x6e\xce\x36\x25\x9f\x6c\xed\x47\xac\x00\xf6\x7d\x49\xdf\x8f\x88\x59\xaa\xcb\x7f\x7f\x15\xeb\xad\x0f\xa6\xcb\x98\xf7\x66\xad\x2a\x36\xc2\x9a\xcc\x8e\x3c\x0f\x5b\xe0\xcf\x3f\xec\x0d\x9a\x84\x87\x7d\xc2\x10\xcf\xc7\x20\x10\x29\x4c\xc4\x77\x88\x24\xce\xfe\x1e\x62\xc8\x06\xb3\xef\xd0\x09\x5a\x9c\x07\x36\x7f\xd7\xe2\x64\x1f\xef\x60\xa3\xab\xa2\x65\x86\x83\x92\x9f\x4b\x27\x1f\x58\xec\x56\x16\x51\xdd\xa7\x6f\x20\x52\xa7\x01\xbf\xfe\xf2\x46\x95\xfa\x30\x3c\xf5\x11\x38\x12\xe1\x45\x10\x1f\x34\xa4\x86\xed\x35\x36\x35\xd3\x4d\x58\x18\xda\xa6\x5c\xc9\x9c\x45\x89\x56\xee\xb8\x1e\xc7\x6e\x12\x15\xdf\x4b\x98\x7c\xfb\x2d\xe4\x0a\x36\x40\x0c\xbf\x5e\xcb\x60\x3b\x9f\xb7\x46\x48\x31\x24\x2e\x6b\x0e\x9a\xcc\x5d\x63\xc8\x67\xc4\xe0\xfe\xe6\xef\xf0\x67\xad\x38\xe6\x43\x72\x5c\x2e\x39\x05\xf0\x21\x8e\x08\x4b\x68\xc3\xd3\x24\x4d\x1b\xdc\x91\x10\x11\x88\x9c\xe7\x0c\xbd\x03\x9b\xf9\x14\xbd\xb5\xf3\x7d\xba\xbe\x1c\xc2\x5d\x03\x7e\x13\xab\x95\xcd\xc3\x0d\x84\xe3\x0a\x8d\x86\x5e\x41\x51\xd0\x2e\xe7\xf7\x9c\xed\xe6\x57\xbf\xf4\xc9\x29\x56\xb7\x5f\xeb\x37\xfc\xe6\x16\xc6\x1a\x84\x7b\xf9\x68\x4e\x64\x19\x89\x7d\x0d\xe8\x63\x5f\x6b\x18\x4f\x01\xd0\x3e\xd0\xfd\x31\x30\x80\xcd\xbc\x14\x04\xa5\x24\x56\x42\xac\x0d\x51\x57\xcc\x28\xfb\xbc\xfc\x21\x51\x66\x7f\xe3\x87\xc4\x1c\x06\x57\xb3\x69\x0a\xc2\x93\xf8\x4e\xb1\x94\x36\x02\xaf\x54\x78\x61\x0c\xf8\xbc\x4a\x8b\xee\x57\xad\x59\x46\xd8\x27\x7a\xcf\xd1\x81\x30\x72\x0f\xf0\x44\x06\xfb\x0b\x4b\x68\x95\x04\x92\x51\x84\x10\xd3\x15\xac\x80\x38\xea\x0b\xd5\xc0\x7f\x53\x12\x29\xda\x94\xf1\x37\x27\x4d\x27\x06\x7d\xea\x5e\x3b\xe6\x6a\x25\x76\x7e\x47\x4b\xc9\x14\x45\xe5\xc6\xc3\x44\xc8\x0d\x8c\xd6\x7b\x63\x9c\x94\x2b\x58\x31\x05\x3c\x29\x95\x86\x60\xcd\x81\x82\x4c\x32\xae\xa9\x5a\x3f\x5f\x13\x1e\x86\x38\xfe\x7d\x6e\x8c\x6c\xaa\xba\x41\xf3\x8f\x52\xd3\x8c\x41\xe1\xda\xce\x87\xc1\xe4\xaa\x58\x5a\x19\x51\x57\xa1\x35\x80\x32\x3a\x78\x00\xd1\x92\xed\x07\x93\x0a\x56\x9f\x3a\x14\x13\x21\x83\x4e\xf7\xab\x25\x24\x76\x59\xac\x70\xff\x7d\x40\xca\x5f\x76\xd1\x72\x0c\x79\x22\x20\xd2\x60\x9d\xd6\x02\xec\x2d\x30\x5a\x10\x41\xc2\x8e\x30\xac\xb3\x9b\x9a\xa7\xe3\xeb\x69\x01\xeb\xd7\x78\x24\x60\x86\x8f\x55\x1b\x38\x9d\xbc\x3b\x6c\x12\x65\x0c\x9c\xb2\xe2\xdb\x98\x87\xde\xb2\x65\x47\x83\x92\x55\x42\xfd\x52\x8c\x53\x5f\x15\x1f\x62\x1a\x19\x55\x49\x25\x99\x9b\xb4\x3a\xc5\xeb\x57\x0d\xb1\x0f\x6e\x8b\x98\xd2\xd5\x20\x01\x7d\x2a\x1d\x75\xf2\x9d\xab\xa8\x65\x66\xeb\x1b\xa4\x9e\x8b\x75\xdd\x35\xd9\xc0\x6c\xa5\xa6\x2d\x92\x10\x86\x82\xdb\xbc\xaa\x94\x09\xe0\x72\x10\x9a\xdc\x57\xe4\xc0\x23\x32\x08\xd4\xce\x10\x7c\xa1\xed\x9a\x9d\x31\x03\xb4\xb7\x57\xbf\x00\x9b\x0a\x59\x2c\xcf\xa8\x41\x5b\x01\xae\xdb\xcf\x49\x69\x35\xba\x4d\x3b\xd4\xa7\xd9\xa7\x02\x24\x89\x73\x5f\xa8\x03\x33\x06\x84\x59\x46\xf6\xe1\x3e\x33\x05\x3c\x1d\xdb\x57\x16\x1b\x9b\x4d\x05\x37\x81\xea\x2c\x90\x0e\x94\xc6\xc9\xbd\x92\x11\x91\x2a\xc6\xa4\x73\x51\x3d\x3e\x23\x0c\xd0\x7b\x3d\x39\x4e\x09\x47\x97\xdb\x32\xa0\x0f\x8d\x20\xcc\x67\xcf\xc9\xa3\xde\x3f\x24\x6e\x2b\x17\xbd\x10\x03\xa5\x6c\x0d\x89\x5b\x66\xcf\xa9\x4d\xd1\xe0\x6a\x5b\xd4\xa4\x88\x84\x18\x93\x83\x1b\x76\x14\x10\x84\xb7\x14\x03\x7a\x0e\x6b\x69\x5f\x7e\xbb\x90\x39\xc1\xf8\x67\x4b\xf4\xc7\x56\xfb\xb2\xa6\x33\x17\xff\x1d\xf5\x0a\x60\xf2\x7e\xff\x34\xfd\x08\x7b\xea\x24\xea\x20\x72\xe8\xcc\x78\xa8\x95\x04\xd2\x5c\x76\xc0\xec\xc7\xcd\x05\xbd\xcb\x58\x07\x4a\x7b\xf5\x6c\x44\x08\x9a\xa0\x56\x53\xd1\x37\xb6\x48\x0d\x87\xb8\xd6\xd7\xc1\xe1\x86\x97\xd0\x45\xab\x33\x59\x84\x83\x2b\x99\xc1\xc4\x7f\x83\x7f\xa0\x6e\x37\xb7\x00\xa2\x72\xcf\xf9\xc3\x9b\x79\x66\x2e\x3c\x26\x0b\x3d\xc4\xb5\x6c\x41\xfd\x0b\x3e\x3a\xbd\x55\x01\x51\xd9\x9c\x10\xc5\x24\xfa\x4a\x1e\x1d\xe8\x25\xd2\x86\x70\x07\xf5\xe0\x45\xc9\xf2\xaf\x81\x18\x8d\x4c\x35\x79\xf8\xe4\xb5\x4d\xba\x39\x09\x5b\xf9\x4c\xb6\x31\xdf\x8f\x00\x2f\x23\xc6\x10\xab\xef\xa0\x00\x37\xa5\x6e\x0b\x6d\xe1\x74\x2c\x79\xc8\x4c\xf0\xb9\x5e\xca\xcd\x78\x90\x65\x65\x4d\xa7\xa5\x56\x5d\x60\x4f\xb8\x37\x3c\x28\x64\xbc\xfd\x15\x04\x62\x5a\xa7\x33\xb3\x90\x93\x74\x7f\xb3\x2e\xda\x79\x26\x2b\x61\x5f\x74\x2b\x4f\x10\xd2\x63\xab\x77\x6b\xef\x48\x29\x1d\xc9\x22\x1f\x43\xe2\x9a\xd5\x5a\x3f\xaf\x82\x3f\x81\x92\xf5\x29\x2a\xfc\x90\xe2\x5f\x8d\x2c\xc8\x8c\x02\xf1\xe3\x84\xdc\xf5\x88\xcb\xd9\xf3\x5f\xc6\x96\x12\x34\xc4\x77\x20\x72\x8d\x8b\xa9\x1a\xf8\x22\x82\x3a\x75\x31\x30\x27\xac\xdb\x8a\x61\x35\xe8\x86\x99\x7a\x3e\x2b\xc0\xfc\xf6\xba\x1c\x53\xd2\x00\xbc\x63\x62\x75\x95\x10\x05\x8e\xdd\x7a\xd4\x5e\x31\x05\xbc\xf0\xfe\x98\x19\x6d\xd1\x80\x22\x1b\xf4\x81\x17\x0f\x1d\xf3\x32\xa4\xf1\xab\xbc\x1f\xf0\x17\xbc\x8e\x90\xf7\x88\x4c\xff\x3b\xc2\x52\xcd\x2a\x3e\x41\x57\xa0\x89\x79\xef\xd7\x23\x3f\x46\xc4\xb5\xba\x85\x4a\x9c\x43\xad\x00\xeb\x95\xfa\xe7\x18\x78\x5d\x66\x42\xbe\xb0\x47\xc6\x40\x5b\xae\x77\x24\x2c\xd1\x02\xd3\x5a\x06\x61\xee\xa7\x72\xc7\x7d\x88\x58\xd8\x63\xa1\xaa\xce\xfe\xda\xd3\x52\x3e\xa3\x59\xeb\x9c\x17\xd2\xef\xcd\xef\xba\x21\x74\x85\x49\xf0\x82\x49\x7c\x67\x7a\x45\xd1\x3e\x63\x11\x23\x2c\x44\x89\x72\x2e\x7e\x1e\x9d\x3b\xa0\xe7\x54\x24\x19\x93\x57\x86\x9d\xb0\x86\x51\x5d\x98\xc8\x62\xd5\xa4\x59\x99\xba\x6f\x08\x7e\x8c\xaa\xc0\x23\x8b\xf1\x86\x33\x6b\x97\x97\xf6\x94\x4f\xab\x83\x88\x89\x35\x2a\xea\x9c\x5b\xc9\x5a\xe1\xd7\xf3\x65\x6c\x3e\xc0\xbc\x95\xa6\x9e\x16\xc1\xfb\x06\x40\x75\x2b\xa0\xd9\xac\x40\xb1\xbb\xb9\x4c\xdd\x36\x09\x12\x6e\x26\x20\x12\xc9\xfd\x73\xe5\x67\xef\x0c\xd3\xd0\x37\xa6\x9b\x71\x97\xa2\xb4\x3c\x54\x7f\x7c\xd0\x79\x8c\xf5\xbf\x98\xc3\x6e\x6a\xf5\x5d\x37\x9e\x6a\x60\x22\x8b\xf1\x63\x55\x2c\x31\x79\x0e\x74\xbb\x0b\xfa\xbb\xc1\xb2\xc2\x1b\xb6\x93\x73\x88\x50\x87\xdd\x7a\xb1\xcd\x23\x55\xe3\x54\x34\x8c\x26\x09\x2a\x4c\xc5\x17\x66\x15\xf1\xe1\xd3\xaa\xb8\x6e\x76\x91\xac\x01\x46\xaa\x45\xef\x99\xde\x33\x5a\x51\xfa\x7e\xb3\x64\x20\xde\xf6\x6d\xb5\x44\xe8\x76\xd0\x17\x10\xa3\xe1\x05\xd1\x83\x6e\x51\xdd\x74\x68\xc8\xde\x7e\xff\x5d\x0f\x48\x95\x45\x1d\xe3\x0e\xf1\x3a\x80\x31\xc2\x81\x9e\xcc\x38\x47\xaa\x9e\x2a\x85\xbb\xc6\x00\x11\x48\x40\x07\x2a\x0a\xec\xe0\xed\x55\xb0\xef\x5e\xfa\xd3\x54\xb4\x21\xbe\x95\xce\xf3\x5c\xba\x21\xf1\x30\x3b\x15\xda\xec\x1d\x23\x3d\xdc\xac\xef\xed\xab\x5a\xf6\x0d\x38\xf5\xa6\x5a\xa0\x4f\xa7\xd1\x29\xbc\x4d\x8b\x6c\xcd\x0b\x96\xc7\x96\xc0\xbc\xf9\x2d\xdd\xe1\x87\x34\x58\x26\x7c\x6b\xc5\xfb\xe9\x98\x51\x0c\xf3\x4f\xde\x66\x55\xaa\x33\x7e\xd7\x7b\x1e\x22\x4f\x5e\xbf\x41\x95\xcb\x80\xf3\x08\x58\x55\x80\xe6\x98\x95\xed\x64\xad\xd1\xf5\x8a\xb2\xf1\x67\x97\x01\xad\xbe\x03\x0f\x8d\x47\xa4\x87\x75\x43\xf5\x6e\xcd\x7a\xfa\xb7\x5e\x77\x19\xf8\x6f\x40\xae\x93\x19\xe0\xd5\x5f\x27\x5f\x3e\x4a\x81\xa1\x8e\x61\x77\xaf\x1f\x24\x5a\x1f\xff\x52\x2d\xb8\xe2\xc7\xa1\xfb\x5c\x93\x72\x17\xee\x44\x1d\xc6\x8a\x66\x00\xb4\x5b\x6e\xb4\xb3\x56\x9d\x51\x33\x00\x62\xf2\x90\x76\x75\xad\xaa\xd0\x43\xc4\xd9\x2c\xb3\xc3\x26\x13\xa6\x1f\x55\xdd\x86\x35\x1d\x01\xe2\x10\xae\xb2\x2f\x10\x7d\x9e\xa8\x57\xd8\x98\x50\x69\x74\x2c\x20\x07\xbe\xb3\x7e\xbb\x28\xc6\x87\xaf\xba\x96\xd8\x59\xfd\xdf\xe1\x39\x6a\x68\x63\xf0\x0c\xf7\xbe\x25\xfe\x1a\x3f\x60\x03\x63\x57\x34\x02\x16\xb0\xbb\xb6\x77\xdf\x69\x39\x3f\xe2\x76\xc9\xf0\x45\x3f\x86\xfe\x81\xb5\x53\x52\xfa\x10\x72\x1f\x60\xb4\x30\x3d\x54\xea\x90\x6e\x50\x1a\x1d\x8b\x8d\xa7\xc3\x06\xd0\x62\x99\xb0\x86\x3c\x85\xdf\xaf\x3a\x84\x83\xf9\x8f\x19\x1c\x28\xec\x84\xa4\x80\xf4\x88\xbe\x39\xcb\xe0\x1c\xd8\xee\x5a\x39\x58\x4c\x89\x0d\x7a\x86\x4e\x4c\x80\xae\xc7\x20\xfc\xa8\x68\x71\x37\xe9\xc6\x58\x52\x54\xc9\x4c\x68\xaa\xea\x9d\x12\x80\x25\x06\xe8\x1f\x05\xbc\x58\x53\xcc\xa9\x96\x9b\xa4\xbd\x1d\x52\x9e\x6c\xc2\xe2\x7e\xdc\x71\x87\x67\xc3\x7f\x50\x0e\x71\x5b\xe3\x76\x0e\xe5\x01\x7e\x98\x8e\xf6\x10\xad\xcc\x15\xa9\x35\x3d\x72\x0a\x6c\xed\xa4\xcb\xea\x18\xdb\x7f\x7e\xf3\x57\x80\xaa\x48\x80\x5c\x40\x52\x22\x34\x2b\xee\x94\x7d\xde\xad\xf1\xe9\xcd\x44\x9a\x9e\xd7\xd8\x43\xd2\x13\xbf\x8e\x39\xd8\x7e\x31\x7d\x87\xc7\x9b\xb3\x3c\x7d\xed\xd0\x84\x47\x33\x97\x06\x90\xec\x38\x33\xae\x14\x62\xab\x96\x6f\x1e\xf5\x1a\xc8\x85\xc0\x7c\x53\xf2\x71\x5e\x46\x5c\xaa\xf2\xac\x07\x7d\x3d\xa0\xed\x36\x0c\x3b\xfe\xbd\x73\x24\xfc\xbb\x2c\x87\xb2\x8d\xae\x3e\x2d\xd3\x79\xe9\xb1\x82\xf0\xb2\xb0\xc3\x24\xc5\x4e\xc7\x1a\x9a\xae\x7a\x64\x7e\xe3\xa8\x6b\x6f\xb1\xd9\xf1\x42\x06\xc2\xed\xfb\xc4\xf0\xcf\xad\x1f\x3c\xd5\xdf\xad\xf8\x7b\x97\x9b\xd8\xbb\xaf\xb5\x2b\xdb\x02\x9f\xe5\x7e\xe6\x64\xe7\x84\xc0\xfc\x4a\xa3\xfc\xcb\x36\x8c\x05\x53\xe0\x01\xca\x59\xfd\x4e\x4e\x7d\xf0\xfb\xa8\x5f\xd5\x36\xd3\xdd\xa0\x56\x81\xb0\xf7\xd0\xc6\x8d\x9f\xb2\x08\xff\xab\xf6\x9b\xac\x31\xb3\xaf\xb6\x47\x62\x1a\x7b\x0d\xff\x09\x06\x95\x0a\xfd\x4e\xef\x10\x6c\x3f\x9f\x78\x9a\xc0\x88\x1e\x6d\xa2\xa8\xa8\x62\x91\xbe\xaf\xc8\xf7\x90\xe2\x5d\xc0\x64\x87\x45\x4a\xc9\xbf\x77\xd6\x38\x13\xc2\x54\x6b\xb8\x4b\x95\xd5\xbd\xab\x46\x46\x57\x58\xf7\xeb\xa0\xec\x2b\x06\x3f\xc2\xbf\xf1\x81\x72\xf4\xa0\x12\xc1\xfd\x56\x13\xcd\x48\x41\x25\x96\x56\xd8\x51\x62\x00\xb2\xf1\xf7\x65\xb3\x93\xfa\x38\x50\x5c\x57\x81\x7f\x59\xd7\x11\x33\x85\x64\x8c\x92\xcf\x1d\x46\x4f\x87\xb8\x55\xa4\x03\x79\xfc\x45\x7b\x4d\xa6\x84\xf7\xa3\xb3\xef\x7f\x4d\x02\xe3\xb1\x52\x16\xe8\x5c\xd3\xd3\x97\x7b\x1a\x43\x66\xf0\x96\x92\x68\xb0\x83\x73\x2d\x81\x52\x3a\x84\xca\x01\x3b\x6b\x16\xff\x06\x36\xc4\x6c\x04\x2d\xc4\x55\xc5\x3e\xfb\xac\xe4\x20\x23\x8d\xbf\x87\xee\x39\x75\xbc\x23\xc6\xaa\x74\x51\xd0\x74\xb5\x81\x62\x68\x87\x5b\xf7\x26\x4d\x2e\xfe\x66\x68\xac\xbf\xae\x5a\x49\x6c\x50\x2a\x0c\xe5\xf9\x9e\xa3\x8e\xea\x1f\x92\x67\x36\xc4\xe3\x8b\x80\xe9\x85\xd2\x15\xb7\xca\x39\xfb\x53\x5e\xe5\xf9\xd0\xa0\x4b\x31\xf2\x2f\x75\xc6\x20\x54\x51\xf9\x3d\xe3\xb8\x4b\x26\x1a\xa0\x80\x22\x19\x48\x48\x06\xda\xe5\xd0\x0c\x84\xef\x10\x66\x83\x49\xd0\x20\xea\xcb\x31\x5b\x4e\xd5\x29\x79\x73\x73\x93\x4d\x49\xe3\x52\x2f\xe8\x34\xac\xa6\xe3\xae\x7f\x2a\xbe\x26\x24\xec\xc0\x37\x68\x95\x63\x72\x47\x84\xdb\x87\xcb\x7c\xa7\xb6\x1e\x6f\x2d\x1b\x2a\xbb\x71\xb0\x2d\x45\x85\x25\x87\xc6\x03\xeb\x1b\x11\xf5\x14\xeb\x00\x6d\x34\x01\x63\x16\xb2\x48\x33\xe9\x43\xd0\xba\x0c\xd9\xa1\xc1\x06\x25\x93\xe5\x35\xca\xcd\xb4\x08\x38\x4e\x2c\xeb\x55\xba\xc7\x53\x38\x39\x3d\x03\x1e\xeb\x14\xb5\x08\x32\xeb\x27\xdc\x82\x92\x31\xc4\xd4\xcf\x7c\xc8\x99\xe2\x61\x6b\xb6\x2f\x9e\x75\xe1\x77\xee\x19\x71\x90\x8b\x6c\x77\xaa\x73\x5b\x8d\xc4\x7d\xeb\x8b\xe6\x67\x96\x57\x13\xf6\x87\xf4\x07\x7f\x30\x63\xea\xe6\x90\x21\xab\xbc\x14\x48\xbe\x00\x5d\x90\x5c\x56\x1c\x35\xae\x48\x5b\x84\x0a\xe2\x85\xf0\x87\xde\x4c\x3f\x60\xee\xc7\xc6\xea\x4e\xf4\x56\xb2\x4f\x90\x1f\x94\xb6\x8e\x16\xd1\x4d\xce\xe0\x0c\xcc\xa6\xf7\xa2\xaf\xac\x19\xc6\x4d\xb9\x72\x5a\x54\x2c\x6b\x2e\x7b\xa1\x67\x9b\xdd\x3f\xfe\xa3\x08\x6f\x4b\xdc\x81\x3d\x1e\xa2\xfe\xb6\xe9\x45\x7b\x72\xd9\x9c\x64\x01\xfe\xc2\x12\x75\xf2\x2e\xad\xdd\x08\x2f\x1b\x37\xf1\x5b\x13\xb9\x71\xd2\x44\xf9\xf4\x62\x8d\x4d\x9f\x38\xd8\xe9\xf2\xc2\xfa\xc2\x7a\xcb\xba\x17\x8c\x3e\x69\x3b\xf4\xe5\xb7\x76\x0f\xf1\x5c\xc2\xd1\x7e\x2e\xe1\x2a\x13\x9e\xb4\x55\x61\x92\x15\x6e\x5b\x94\x1b\x6d\x1d\x1a\x2e\x8f\xd5\x19\x2d\x70\xfc\x85\xfb\xbd\xf4\x9c\xb4\x7d\xc8\x3f\x4d\xf0\xff\xc0\x07\xbf\xa7\xc2\x19\xee\xc5\x67\x28\xf0\xfa\x7d\x68\x4e\x87\xdc\x38\xfb\x4d\x0d\x0e\x6f\x12\x8c\xbd\x32\x9e\x86\xf4\x48\x64\x8c\xdf\x41\xb3\xfe\xca\xec\x46\x97\xf4\x23\xb1\xa0\x04\xc2\x2c\xac\x89\xc9\x35\x52\xbb\x29\xe0\x3a\x61\xce\x3a\x7e\xb5\xd8\xdf\x68\x26\x98\xbc\x8e\x65\xbb\x73\x9b\xac\xf7\xec\x0e\xce\x53\x21\x36\x70\x3f\xbc\x33\x4c\xbd\xc4\x35\x28\xc1\xbd\xc3\x6c\xc3\x2a\xc6\x05\xcb\x12\x78\x35\x21\x03\x21\x39\x72\x23\x96\x83\xd9\xe8\x17\xc4\xa2\x74\x5f\x94\x9d\x4d\x32\x60\xce\xf6\xce\xde\xc8\xad\xbe\x6c\x9e\xf7\xc5\xa1\x9a\x8c\x6b\x2c\xf3\xd0\x70\x0d\x2a\x8b\x5b\xe1\x6a\xed\xef\x15\xdc\x0b\x2b\xc3\x82\xaa\x99\xd6\x88\x74\x6b\xd1\x28\x1a\x5e\xe6\x37\x1c\xbb\x04\x07\xda\x94\x85\x76\x2a\xa5\xb2\xf0\x77\x1f\x84\xc3\xc2\x7c\x38\xc6\x5e\xfb\x0f\x16\x8d\xa3\xc4\x6f\x92\x3c\xb7\x1f\xbf\x64\x8a\x6a\x43\x8b\x33\x32\xa2\xcd\x93\xf5\x6d\x34\xd3\x7e\xe8\x7c\x7c\xa5\xa5\xb9\xf5\xe4\x7a\xe0\x9f\xcb\xcb\x8f\xcf\xf7\x5b\x3a\x96\xe8\x95\x19\xc5\x9c\x8e\x3f\x3f\x65\x48\xca\x1c\x60\x9f\x96\x5b\xaa\x55\x53\xcf\x4a\x38\x13\xcd\xb4\x7f\xaf\x08\xaa\xfb\xa3\x76\x57\x54\x00\xb7\x89\xf9\x5d\x87\xa4\x46\xb5\x3e\x23\xbd\xaa\xce\xb6\xf8\x3f\xfd\xd4\xdb\x2c\xc5\x52\xdc\x4d\x7a\x88\x97\xf3\xc8\xb2\x33\x65\x50\x3d\x41\x9c\x68\x48\xc3\x5f\xd0\xbc\xdf\x3e\x91\x9b\xd1\x95\xd2\xe6\xcc\xa4\x76\xcd\x31\x36\xb6\xee\x29\x92\x22\x83\xd6\x2e\x64\x92\x1a\x3f\xfa\x54\x61\x4c\x96\x0b\x66\xac\x10\xcd\x4a\x7f\x5a\x20\x92\xb4\x64\x3c\xc6\x56\x38\x14\x0f\x0b\x88\xe8\x6e\xa7\xe1\x82\xd0\xaf\x64\xb5\xfb\x6f\xfc\x73\xb1\x2b\x61\x5a\x58\xf3\x85\x66\x52\x77\xb3\x9d\x9f\xdd\xa5\xcf\xc6\x3e\xd6\x0b\x35\xb9\x58\xdf\x6b\x2e\x86\xb3\x5a\x29\xad\x4b\x00\x1e\x2a\xde\xb4\x5f\x34\x8f\x1b\x8e\xbc\xde\x50\x58\xb4\x29\x62\x4f\x85\x15\xa9\xca\xa0\x72\xf8\x69\x07\xb7\xc1\xe2\xe3\x2f\x4e\x5f\x18\x4e\x3f\x52\x7a\x88\x03\x0b\xe6\x89\x69\xf3\x81\xf1\x90\x7b\xad\xcd\x36\xf8\xc1\x2e\x11\xde\x8e\xdb\x00\x16\x0e\x29\xd4\x4c\x89\x13\x69\x1b\x29\xa7\x46\x1f\x35\xee\xdf\xb5\xa6\xe9\x3d\x63\x7d\x14\xee\x0f\x35\xc5\x31\x25\xc8\x72\xc8\x5c\x98\xb0\xd3\x88\x38\x2a\x08\xb3\x7a\x33\xaf\x79\xde\x54\xe4\x22\xfd\x67\xaf\x91\x06\x69\x6e\x7c\x4c\x59\xd8\xa6\xab\x50\xd6\x86\xc2\xfa\x95\xa7\xfd\xae\xbf\x9a\x43\xab\xb2\x50\x86\x3b\xdb\xe0\x3f\x6b\xdb\xc4\xd8\xd0\x6a\xca\x29\x0d\xfc\xdb\x25\x70\x00\x9b\x8a\xc0\x9b\xd2\xff\x3e\xa2\x06\x11\x0d\x3c\x4a\x53\x79\xdb\xe1\xeb\x8f\x2f\xc4\x54\x12\x39\x28\x54\x62\x21\xd7\xaf\xb5\x46\x4c\xa1\x0b\x35\xab\x6b\x35\x22\x59\x4d\x35\x6a\xda\xa4\xb8\xff\xee\x52\xd8\xc7\x55\x5c\x52\x9d\xe5\xd2\x3d\x3e\xd8\x27\x2a\xff\xb7\x75\xeb\x25\x73\xd3\xeb\xa1\x2c\x98\x12\x12\x72\x6a\xac\x3a\x50\x8c\x9f\xbf\xba\xd4\x72\xda\x5a\x62\xb1\xed\x33\xa3\x15\xc2\x79\x91\x4d\xf1\x75\xba\xe0\xc2\xea\xbe\xba\x8e\xd1\x0f\x4b\xa1\x1d\x34\x67\x9f\x18\x5f\x8e\x2f\xc0\x12\xb8\xaf\x0c\x5f\x63\x8e\x46\x06\xac\xe2\xea\x26\x5e\xa6\xea\xf4\x11\x5f\x7c\xc8\xd4\x12\xd9\x67\x5a\x5b\x55\xcd\x63\x5d\x76\x0e\x54\x47\x7a\xe5\xb9\x65\xde\x81\xcf\xb5\x91\x3c\x7a\xbb\x28\x1f\x3a\x47\x07\x53\xbe\x79\xfc\x75\x33\x6a\xd8\xef\xe4\xdc\x50\xde\x2c\x77\xfd\xec\x10\x11\x4f\x08\x52\xf7\x63\x3f\xbf\xe5\x79\x28\xe4\x8d\x5e\xca\x72\xd0\x1f\x8c\x79\x6f\x1e\xa6\xb6\x26\xf8\xa7\x72\x5c\xc5\x37\x20\xfd\x37\x76\x47\xc7\x3b\xde\xc7\x3e\xec\xbd\xd4\x91\x1a\x96\x64\x71\x31\xf8\x1a\x1f\xe0\x60\x26\x5e\x73\xd0\xa3\x91\xc2\xd4\x63\x92\x9f\x5b\x88\x65\x0d\x90\xdc\x6d\xb8\x9d\xa4\x41\xb1\xfa\x90\x89\xeb\x1a\xaf\xfc\x15\x77\xdc\x97\xf1\x2f\x9d\xd7\x6e\xc6\x87\xbd\x71\x1a\x8f\x56\x52\x2e\x71\x66\xc8\x66\x6c\xd2\x27\x5b\xe0\x57\x8e\x1e\xa2\xeb\x82\x5e\xe6\x3d\xae\xd5\xad\x11\xe5\xc8\xcd\x1f\xe8\x4e\xd8\xe7\x8c\x3d\x58\x8c\xa8\x95\x5b\x79\xaa\xb0\x3f\xc4\x89\x73\x49\x4c\x29\x48\xda\x37\xcf\x96\x4c\x5c\xf5\xeb\x14\xe3\xf7\x51\x5a\x1a\xee\x7e\x9a\x7a\xa9\x73\x6a\xcb\x6f\x79\xcc\x76\x46\x47\x1b\x4e\xdc\xa6\xff\x0b\x00\x00\xa0\x77\xcb\x58\xbb\xf2\xa6\x43\xd8\xff\x02\x00\x00\xff\xff\xa5\xbb\x1a\x65\x51\x32\x00\x00") +var _filesFaviconPng = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xa4\xd2\xf7\x37\x1b\x0e\xfc\x36\x7c\x4a\xab\xa8\x6a\x69\xed\xd1\x56\x51\xb5\xaa\xd4\x26\x6d\x15\xb5\x3f\xaa\xad\x52\xa4\x46\xa9\x95\xd8\x3b\x89\xd6\xde\x6a\x54\xcd\x14\x55\x7b\xd5\x1e\x31\x6a\xef\xbd\xc5\xde\x44\x44\x22\x89\x84\xe4\x39\xdf\xef\x73\x9f\x73\xff\x01\xf7\xeb\x9c\xf7\x79\x9f\xeb\xe7\xeb\x8a\xfa\xcf\x50\x9b\x85\x89\x87\x89\x86\x86\x86\x45\xe7\xf5\xab\x37\x34\x34\x34\xe4\xff\xb9\xeb\x74\x34\x34\x34\x99\x65\x6c\x29\x34\x34\xb4\xbe\xff\xe9\xbf\xd5\xa4\xf9\x5f\x05\x05\x05\xf5\xf5\xf5\x63\x63\x63\xb3\xb3\xb3\x53\x53\x53\x13\x13\x13\xc3\xc3\xc3\xfd\xfd\xfd\x13\x13\x13\xdd\xdd\xdd\x1d\x1d\x1d\x08\x04\xa2\xb5\xb5\xf5\xe7\xcf\x9f\x34\x34\x34\x0b\x0b\x0b\x3b\x3b\x3b\xfb\xfb\xfb\x9b\x9b\x9b\xeb\xeb\xeb\x48\x24\x72\x66\x66\xa6\x15\x81\xc8\xcd\xcd\x5d\x5e\x5e\x3e\x3c\x3c\x44\xa1\x50\x18\x0c\x06\x8b\xc5\xe2\xce\xce\xb6\xb6\xb6\x08\x04\x02\xe9\xfc\x9c\x42\xa1\xa0\xd1\xe8\xb8\xd4\xa4\x90\xe8\x08\x1b\x77\x97\x4f\xf6\x76\x1a\xaf\x34\x64\x65\x65\x85\x84\x85\x68\x68\x69\x68\xf9\x6e\xb0\x88\xdd\x63\xbe\xc1\x2c\x06\x50\xb8\xaf\xf0\xe4\x9d\x99\x69\x50\x78\x48\x54\x62\x5c\x55\x7d\xad\x27\x34\x80\x47\x46\x4c\xdf\xc6\xfc\xbd\xb3\xad\x39\xc8\x41\xc1\x40\xf3\xc5\x7b\xc3\x37\xf6\x40\x2b\x0f\x67\x6d\xcb\x77\x52\xaf\x54\x0b\x8b\x8b\x7f\xe5\xe5\xd9\xf9\xba\x3b\xbb\x83\x55\x54\x55\x75\xf5\xf4\x1c\x82\xbc\x9c\xa1\x7e\xdf\xd3\xd3\x40\xdf\x02\xbd\x22\x82\xfd\x63\xc3\x61\xdf\x63\x42\x53\x13\x62\x32\x52\x13\x72\xd2\x23\xd2\x93\x93\x7e\xe7\x64\xfc\xc9\x83\x57\x95\x04\x7e\x85\x3a\xf9\x7b\xfc\x2a\x2d\x2a\xfe\x5b\xf9\xe3\x57\xf6\x8f\xac\x8c\xd0\x84\x18\xb7\x00\x9f\xba\xba\x3a\x71\x09\x89\xeb\xd7\xaf\x0b\x08\x08\x70\x70\x70\x24\x25\x25\x95\x95\x95\x71\xf1\x72\x05\x04\x04\xd0\xd0\xd0\xd0\xd3\xd3\x5f\xbd\x7a\xf5\xf6\xed\xdb\x9e\x9e\x9e\x5f\xbe\x7c\x31\x34\x34\xa4\x97\xa7\x17\x54\xe3\x17\xd1\x17\x92\x78\xff\x88\x4b\x82\x43\xda\x4a\x82\xeb\xd3\x2d\x81\x20\xce\x27\x61\x12\x80\x1f\xaa\x26\x15\x46\xd6\x88\x4f\x1e\xa3\x6e\xc1\x5b\x5f\xbf\xe3\x53\x24\xf3\xc4\x7b\x6e\xfa\x1b\xd0\xd0\xd0\xb8\x7a\xbd\x31\x34\xa1\x61\xd3\x75\x09\x2f\x1f\x3c\xa0\x52\xa9\x07\x6b\x53\x95\xc2\xd7\xa9\xff\x47\xce\xc9\xff\xbe\x31\xea\xff\x83\x09\xea\x3d\x71\xe3\xd6\x36\x6a\xa4\xdc\x0b\xa0\xff\x6f\x3f\x97\xc9\x0a\x76\x32\x0d\x8d\x34\x9f\xce\xab\x17\x6f\xfd\x68\x8f\x3a\xbf\xfe\x4f\xfd\xc1\xbf\x2e\x8b\x39\x8b\xaf\xfc\xff\x5b\xa0\xf9\xb4\x33\xf8\x45\xcf\xfb\x56\x78\xde\x21\x47\xd9\x1d\xf1\xd4\xc5\x0e\x6f\x0a\x55\x43\x54\x8e\xeb\x7e\xcf\xf6\xd2\x9a\x3c\x0d\x64\x99\xe1\xdb\x25\x51\xe1\x51\x01\x6e\x5f\xee\xc1\xae\x2c\x87\x8d\x5f\x06\xfe\xe9\xec\x13\x93\xe4\xa1\xaf\xe7\xba\x8a\x2f\x55\xca\xa7\xab\x96\xdb\x74\x2c\x12\x1c\xda\x57\x14\x82\x69\xee\x13\xd7\x58\xbb\x23\xeb\x6c\xa7\xaa\x7e\x68\xf5\x6e\x01\xbf\xfa\xbf\x0c\x24\x9b\xff\xa1\xd6\x98\x0c\xa5\x15\x95\xca\xfc\xbb\xf6\xe8\xf5\xa2\x23\x4b\x5d\xe3\x7c\xdf\x95\xcc\xe4\x47\x32\xde\xd7\x52\xba\xba\x7d\x85\x21\xc3\x09\xe0\x2a\x20\x5f\xd9\xfd\x4d\x02\xe3\x2e\xe2\x31\xe4\x71\x3d\xc1\xd8\xe6\x33\x7d\x10\xf7\x07\x01\x54\xf5\xf5\x40\x63\x24\x98\xf2\x24\xc9\x9f\x57\x50\xf5\x2e\xbb\x7f\xd2\x71\x99\x60\x01\xec\xdc\xf0\xce\x2e\x5a\x59\x10\x81\x0f\x8e\x96\x5b\x39\xdc\xbc\x85\xef\x99\x8b\xa0\x79\x0d\xbf\xe8\x41\xc2\x23\x72\x12\xb2\xe8\xe8\x11\xec\x4a\xb4\xc1\x3f\x60\xe2\x57\x4a\x5a\x37\x12\xf9\x3b\xee\x73\x89\xc7\xc3\xa9\x9c\x6f\xfb\xc8\x6d\xb4\x8e\xe8\x9e\x40\x26\x1a\x27\xff\xbc\x53\x4e\xd6\x7b\xef\x0d\xf2\x09\xe5\x55\x30\xdc\x1b\xaa\xd9\xd7\x02\xcd\x6b\x29\xcf\x8b\x02\x3b\xa7\xa4\x6f\xcf\x9b\x9f\x3e\x05\xb4\x05\x8b\x9c\x9b\x9e\xee\x34\xe3\x1c\xcf\x50\x05\x41\xf3\x51\xaf\xec\x58\xfb\x19\x12\x63\xbe\xf8\x06\x0f\x30\x63\x52\xa8\xe4\x3f\xed\x8e\xc4\xe7\xe4\xb3\xcd\x7c\xf0\xfd\xd8\x81\xf4\x48\xc7\xe9\xec\xd5\xd2\xfd\xb4\x29\x26\xb4\x19\x3a\x1d\x68\x35\xfc\xad\xe9\x1d\x17\x4d\x07\xe1\x4f\x4a\xe1\xf3\x49\x7c\xd5\x83\x6c\x13\x0e\xfb\xc6\x61\x02\x15\x36\x11\x73\x8b\x47\xb7\x52\x45\x5c\x10\xb3\xb5\x15\x03\x7a\xa6\x3f\x54\x4f\x7f\xa4\xc9\x9a\xf9\x2a\xfe\x33\xdb\x8d\xfc\x11\x39\x9a\xab\x29\x71\x35\xab\x93\x02\x24\x58\x7e\x89\x0a\x4f\x37\x0d\x8f\x1b\x3d\x4d\xd5\x1d\xd1\xde\x7f\x67\x41\x63\xd8\x65\xd7\xda\x12\x89\x43\x21\xfd\xeb\xe5\xcd\x8d\x47\x36\xaa\xdf\x9c\x9d\xd5\x20\x4d\xbe\x47\xd4\x58\xff\xa1\x6c\x36\x6f\x9a\x0f\xb7\x82\x3f\xed\x9f\x1f\xbf\x31\x69\xa0\xa1\xb9\xc9\xef\xc6\x3b\x15\xe7\x99\xe7\x95\x10\xe2\xd4\x9c\x73\xb5\x25\xa6\x72\xe9\x9c\xdd\x71\x8b\x17\xfd\x7c\x2e\x38\x72\x50\xb6\xc8\xbf\xad\xbc\xd8\xaf\xb2\x94\xe9\xa3\x88\xe3\xa9\x9a\xe7\x0f\x17\x8d\x24\x19\x7d\xe0\xd7\x1f\xe0\xc7\xcb\x08\xb5\xf3\x14\x19\x70\xab\x46\x41\xf2\x3b\xb3\xd1\xa1\x3f\x68\x6e\x9e\xac\x91\xb1\xd1\x51\x69\xa9\xca\x0a\x19\x05\x5a\x5b\x4d\x46\x99\x86\xce\x5b\x8b\x0d\x8b\x5f\x34\xae\x6b\xfa\xa6\xfd\x24\x63\xb1\xd8\x08\x6d\xc7\x3f\x34\x60\x99\x54\x07\x0d\x32\xa3\x7d\x56\x3b\x26\x9e\xe8\x54\x2a\xb1\xc6\x16\xde\xbf\xe4\x37\xb1\xbe\x39\xe5\xfd\x3c\xf7\x8f\x75\x2c\xa3\x71\x12\x93\x9d\xd9\x22\xf5\xb5\x5e\x92\xb6\xbb\x1e\x57\xfe\xb7\x64\xdb\x8d\x5b\x63\x43\x07\x07\x17\xc6\xe9\xe6\x6d\xba\x39\x97\x2c\xfe\xf5\x81\x37\xb5\xfd\x96\xe4\x5e\xdb\x45\xac\x5d\xb6\x4d\xa7\x78\x4a\xf0\x6b\x87\xfe\x7c\x3e\xd5\x3a\xec\xf3\xa6\xc0\x7b\x8a\x85\x35\xbf\xd5\xb0\xf4\x47\xec\xae\x1f\xe8\x7d\x29\x83\xd9\x44\x90\xa7\x16\x57\xc5\x05\x65\x1b\xc6\xe7\x8c\x7c\x3a\x55\x50\x28\x38\xb0\x0f\x85\x2f\x5f\x95\x69\x88\x54\xd4\x7c\x0f\xb1\x8d\xb7\x51\xe7\x91\xf6\x1f\xf8\xcd\xb0\x39\xf9\xa0\x31\x73\x13\x02\x16\x87\x04\x0e\x5e\x87\xad\x51\xd4\x64\xd2\xcf\x83\x0b\xd0\x3c\x27\x4a\x37\x8b\xfc\x1f\x06\x27\x39\x89\x9a\x21\xaf\x87\xc4\x78\x0d\x45\x46\x97\x14\x9a\x71\xf7\x4a\xcb\x36\x9e\xdc\x43\x12\x96\xad\x6c\x8e\xc8\xed\x8b\xed\xf1\x03\xc3\x64\x7c\x86\x6e\x7c\x33\xed\x58\xc8\x13\xf3\x18\x21\xd2\xf6\x3e\xe8\xec\x69\x5f\x3e\xd7\xb7\xf3\x5b\x4e\xc0\x9d\xce\x26\xfd\x9b\x10\xea\x5c\xbc\xcc\x16\xab\x57\xc8\xd0\x64\x4a\xd0\x45\xd1\xc7\x70\xaf\xdb\x6e\x9e\xd3\x13\x29\x2d\x9c\x88\xe7\xce\xf3\x72\x88\xc9\x04\x0e\xd2\x7f\xe7\x22\x7e\x99\x8f\xc2\xc1\xff\x9d\x9d\x4d\x0e\x4d\x5b\x3e\x60\xbd\x84\xce\x28\x99\xc7\x38\xf9\xb5\x39\x1a\xae\x34\x29\xb3\x7d\x39\x7d\xb0\x2f\x29\x62\x9a\x3f\xcd\x22\x88\xfc\xa5\x7a\x3b\xa0\x51\x8d\xca\x25\xc6\x2d\x18\x34\x5c\x58\x02\x66\x8c\xb0\x0a\xda\x27\x3c\x84\x15\x39\x0f\x73\x69\x4b\x19\x3e\xf5\x18\x4a\x5f\x54\x6d\x8b\x7b\x85\x0d\x87\x0c\xb7\x4b\xd5\x50\x9e\xb6\x41\xa1\x50\x08\xe2\x87\xa6\xf1\xdc\x91\xb0\xf3\x81\x4b\xf9\x48\x9b\xf7\x87\xcc\xe4\x05\x96\x71\x21\xff\xfd\xbb\x4d\xb0\x55\xf6\x4b\xc8\xe5\x4f\xcf\xeb\x91\x62\xde\x3b\x2b\x65\x36\xe7\x54\xc7\x06\xba\x9d\xda\xea\x4b\xa6\xb5\x64\xe4\xd1\xac\x8e\xc3\xd3\x18\xfe\xd7\xe6\xb6\x7e\xf3\x3f\x4e\xea\xab\x15\xea\xed\x84\xe3\xf9\xfb\xe4\x8f\xe8\xee\x27\xda\xd1\x07\xdf\x42\x42\xa9\xe0\x09\x4f\x0a\x1b\x0c\x18\x6c\x20\xeb\x18\xf0\xb0\xc3\x7d\xc7\x7b\xb7\xaf\x89\x70\x90\x37\xae\xe5\x3d\xb7\xfb\xd6\xb4\xe9\x17\x9a\x7e\xec\x44\xa1\x48\x70\x03\x24\xbd\x47\x60\xb0\xe6\xea\x71\xe7\xbb\xa6\x0f\x48\x0c\xc5\xd8\x2a\x76\x8c\x21\xbf\x2d\xca\xdd\xc4\xc4\x5c\xa8\x55\xd2\xef\x16\x16\x52\x32\x3d\xf2\x8d\xcd\x2e\x83\xc8\x54\xbf\xf2\xc0\x9b\x25\x19\x33\x88\xf6\x06\x53\x21\xa4\x54\x6f\x8b\xbc\x21\x0e\xee\xf7\xe5\x4c\x01\x58\x61\x7e\xeb\x81\x86\x08\x77\x38\x64\x20\x38\x4a\x3b\xc9\x2e\xe5\xc6\xe6\xa3\xa3\x43\x95\x4f\x16\x37\x2a\x56\xf5\xe8\xa9\xeb\xd9\x96\x25\xd3\x37\x87\xae\x41\x1d\xf6\xa9\x13\x7e\x93\x8e\xb3\x15\x71\xd3\x09\x4e\x33\x49\x0e\xe3\x57\xa5\x2d\xb1\x61\x47\x79\xa7\x20\xd6\x82\x88\x30\x35\x0e\x5f\x29\x98\xbe\x56\xe9\xfa\xc7\x27\x89\x0a\xcf\xb7\x9f\xc6\x3e\x3c\x6f\x14\x44\xbf\x8d\xaf\x5e\x4e\xf3\x1a\x99\xba\x8b\xd6\xde\xd4\xe1\x3c\x0f\xc0\xf3\x6a\x33\xf4\x81\x0d\x87\x67\x99\x49\x67\x69\x99\x87\x85\xb9\xa3\xb7\x8c\x28\x42\xb8\x04\xde\x02\xb0\x48\x67\xd9\x9e\x9b\x03\xc1\x78\x6b\xd8\xe4\x0c\x6d\x68\x95\x42\x48\xc0\xd7\xfa\xf7\x68\xb0\x52\x59\xbb\x01\xc0\x08\x34\x7a\xb3\x7b\xf3\x16\x2e\x9b\xb9\x60\x2a\x27\x62\xe9\x43\x9e\x68\xf2\x28\x3d\x8e\x71\x4b\x60\x8d\x2d\xf1\xaa\x14\x63\xc4\x95\xec\xc1\x01\x06\xb8\x5c\x57\xd1\x6a\xf3\x77\x6a\x3e\x2f\x11\xd6\x56\x74\xc4\x8f\xe2\x24\x8a\xaa\x9f\x0a\x81\x1d\x2e\xf0\x7a\xae\x3d\x71\x26\x47\x81\xef\x8e\xd6\x12\x56\x73\xbe\x82\xfa\xb1\xb3\x95\xe6\x48\x1a\xe7\x0c\x89\x4f\x08\x50\xc4\x48\xb2\xd5\x5e\x6a\x9b\xd2\x8a\x81\x12\x3b\x6b\x40\xe3\x1d\x75\xcc\x5d\x6a\x5f\xc4\x50\x92\xc5\xe1\xc3\x1d\x7c\x0e\x3e\xdb\xb4\xfd\x92\x2b\x60\xc6\x2b\x39\x2e\x11\xfa\x8a\xb4\x5f\x3c\x1e\x37\x4a\x0d\x82\x4d\xfd\x1b\xb6\xff\x05\x93\xb8\xb0\x71\x88\x5d\x17\xe8\xcd\xb4\xe0\xbf\xaa\xcf\x16\x46\x5c\x25\x04\x2e\xd3\x95\x2b\xc0\x02\xe4\xc5\x11\xea\x25\x58\x0e\x80\xed\x46\xba\x29\x20\xa5\x4d\xda\x6b\x04\x33\xb1\x8e\xa1\x0c\xb7\xd6\xe7\x7a\xee\xec\xb0\x8b\x33\xb0\x18\x5d\x85\x53\xf2\xbc\xa7\x04\xb8\xcb\xc1\xd9\x98\x0b\xe0\x06\x2d\x94\xb0\x99\x9d\xcb\x79\xb1\xe0\x68\xfe\x78\x53\x48\x6d\x5b\x24\xe7\xfc\x3e\x79\xc9\x81\xeb\xe4\x9e\x33\x32\x83\x67\x7f\xea\xae\xf2\xb7\x19\x94\xa7\xc3\x73\x45\x76\xdd\x57\x82\x9a\x1b\xda\x59\xc2\xd9\xb0\xdb\xa2\xc0\x5c\x6d\x3b\x93\xb1\x1b\x01\xa6\x16\xd8\xda\x80\xa6\xd9\x26\xe1\xa6\xf1\xb4\x58\xa5\x4a\xf2\x52\x5a\x65\xce\x25\x70\xff\x5d\x97\xcb\xa4\xff\x47\x10\xe5\x38\x64\xfc\xdd\x68\xc2\x0a\x7f\x87\x9c\x8b\xd5\xc9\xbf\x5d\xa8\x96\x34\x28\xf7\x21\xe9\xa3\x9a\x1e\x50\x87\x6b\x79\x47\x03\x76\xf6\x32\x61\xcc\xbb\x9b\x97\x77\x59\x10\x3b\xb0\xb3\x50\x79\xe6\x1d\x50\x37\x68\xb2\xb7\xd0\xc2\xda\x7b\xc5\xe8\xdc\xb5\x74\x66\x3f\x82\x4e\x76\x51\xb5\xd4\x01\xf3\x4d\x5a\xa4\xdb\x36\xb4\xbb\xab\xbc\x00\x8b\xbe\x41\xb4\x8d\xac\x61\x45\x6c\x6c\xbf\x44\x63\x3c\x91\xce\x9d\x87\x0e\xbc\x5a\x60\x6b\x28\xd5\x92\xb8\x79\x37\xe0\xdd\x96\xdf\xf4\x8b\x55\x6f\xdc\xa2\x88\x87\xcb\xc0\xc7\xec\xd9\x6e\x17\x64\x51\xce\x68\xa2\x50\x8e\x0f\xc4\x64\xcf\x36\xfc\x70\x9b\x35\xff\xf3\xf0\xc6\xf7\xb5\xa9\x33\x8f\xcc\xe4\x4d\x4e\x69\xac\xa5\x61\xf5\xc6\xc0\x4d\x23\x0c\xf5\x2c\xdb\x0f\x6a\x01\x98\x37\x91\x1b\x26\x15\x09\xce\xb0\x5a\x25\xb0\x28\xef\x9e\x17\x72\xe4\xd1\xad\x74\x73\xe2\x5f\x74\xe0\x94\x56\xab\x11\xec\x51\x4c\xa6\x11\x50\x36\x6d\x1e\x5c\xfb\x37\x41\x7a\x38\xba\x68\x8e\x19\x82\xd5\x2c\xd0\x6d\x9d\x9f\x53\x20\xb8\x50\xc8\x7b\x71\x6a\xd6\x9c\x42\x96\x53\x34\x88\xd6\x0f\xb9\x40\x18\xc8\xfb\x1e\x90\xff\x44\x3e\x5e\x94\x42\x73\xd8\xca\xa0\x92\xc4\xc2\xfe\xf0\xda\xe3\xc5\x3f\x46\x79\x75\xaa\x15\x07\x2a\x0a\xe2\xc4\x31\xe9\xc7\xcb\x1b\xa9\xc3\x23\x08\x29\xbf\xd5\x30\x87\x43\x32\x2f\xa8\xa0\x41\x67\x6e\x52\x69\xe2\x3e\x9c\x9e\x17\x54\xc7\xaa\x3a\x96\x2c\x83\x56\x0c\x51\x14\x4b\xea\x7d\xc2\x99\x0b\xd5\x0d\xa0\xb4\x54\xae\x87\x88\x83\xb2\xef\xc2\xbe\x6f\x16\x03\x2d\x8f\xda\x2b\xfd\x27\x0f\x95\x67\x47\xd6\x04\xad\x9c\x18\xb6\xff\x2c\x28\xfb\xb8\xf3\x0f\xaa\xfc\x06\xeb\xc9\x5f\x27\xe7\xcc\x61\xbf\xb9\xdf\xc3\x59\x3f\xe8\xbc\x66\x9f\xf1\xfb\xd3\x60\xa7\xa9\x17\xbc\x7b\x78\xf5\x50\x63\xc2\x17\xac\x7f\x81\x06\x7f\x86\x39\x57\xe0\x7b\xb3\x03\x37\xda\x43\x46\x7f\x92\x6c\x73\xe7\x1e\xff\x3e\x8c\x8b\xce\x5c\x8a\xb7\x8b\x5b\x71\x38\x57\x4d\x87\xa6\x79\x75\xda\x56\x14\xea\xe4\xa6\xa9\xa9\xd6\xd9\xe3\xe2\x12\x77\x59\xd4\xf7\xe9\x86\x49\x04\xfd\xaf\x54\x13\x8c\x3d\x76\xb5\x6e\x31\xd9\x4a\x7c\xef\x4f\x4b\xc6\x94\x9e\xe8\xb5\xcb\x63\xa1\xc7\xbb\x50\xe4\x6f\xde\xa3\x58\x23\x43\xdf\x75\xff\xda\xe4\x9b\x81\x8f\xeb\x6a\xfc\xc2\xb7\xc1\x22\x49\x5d\x47\xc0\x2d\x45\xcd\xd5\xf6\x9e\xc6\xb9\x91\x4b\xd8\x89\xd6\x6c\x5b\x85\x2f\x29\x0a\xaf\x72\x32\x06\x55\x93\xd8\xba\x22\xb5\xec\x7f\xc8\x00\x96\xba\x41\x3a\xbb\xbb\xad\x3f\x96\xc2\x8b\xa6\x89\xff\x54\xfd\xc1\x2b\x4a\x90\x41\xea\xd9\x9b\xd6\xbd\x28\x07\x7d\x04\x37\x49\x53\xb0\x14\x1a\x95\x26\x6c\xa4\x40\x80\x59\x5e\x01\x3e\x53\x9f\x49\x1f\x19\xaa\xa2\xe2\xe3\xca\x94\xc6\x63\x66\x6f\xd7\x53\x76\x62\x26\x95\x56\xce\xba\x9a\xac\x90\xd3\xd9\x98\xf4\x72\x69\x91\x98\x63\x9b\x09\x3a\x75\x3a\x9a\x3c\x6b\xf3\x1b\xe7\x62\x18\xa0\x18\x0c\x72\x0a\xc8\x20\x09\x60\x22\xa3\xd2\x22\x2f\x5a\x32\x76\x41\x86\xba\xef\x3d\x28\x6f\x6e\x10\xdf\x05\x1c\xcb\xef\xdb\xbf\x42\xd3\x8c\x6a\xec\xc5\xc6\x93\xe8\x71\x1b\x9f\x08\xd7\x7c\x1b\x5d\x2f\xc4\xd5\x25\x4e\x3a\xd3\x58\x82\x74\x76\x55\xcf\x86\x1b\x2a\xf1\x93\xa4\xf5\xbf\xee\xeb\x9c\x4a\xfd\xfb\x9c\x22\x08\x61\x8f\x57\xfb\xd6\x45\x7b\x37\x18\x02\xd0\xd2\xd1\x5e\x88\x17\xd5\xd9\xb9\x6a\x77\x55\x02\xa5\x0d\x67\xd5\x39\xe5\x8d\x36\x19\xb6\x85\x4a\x56\xbf\x51\xc7\x86\x06\x0f\xf3\x6d\xab\x2b\x13\x48\xe0\xb6\x51\xaa\x47\x62\x8d\xfb\x4f\xa5\x5b\xa4\xdd\x67\xca\x48\x01\x1f\xc9\xe6\xb0\xf5\xe4\x3d\xd9\xf9\xaf\xe8\xc0\xff\x86\xf6\x17\x88\xe2\x43\xe6\xd5\x09\x45\xbb\x3a\xca\xc9\x4f\x65\x58\xb3\x86\xdd\x0a\x2e\xb4\xd9\xc7\xb6\x57\xf8\xf0\x82\xf3\x17\x6d\x8b\x77\xa9\xa8\xa5\x25\x20\x4f\x28\x73\x0e\x2b\xcb\xfe\xdd\x49\x4d\x29\x9c\x00\x3b\xfd\x2f\xf1\x2c\x6d\x0e\xfb\x49\x65\xf8\x0b\x86\x1c\xd6\x4a\xf8\xe7\x5c\xde\xf0\x0e\xb2\xcd\xf4\x8a\xff\x73\xf1\x74\xce\xca\x1b\x67\x83\x09\xd1\x9e\x9f\x7b\x4c\x5a\xe5\x8d\x2e\xec\xb1\xab\x8a\x47\xf0\x39\xa6\x7a\x6e\x47\x84\x55\x65\x16\x17\xee\xf0\x63\x33\x23\x03\xc2\x4d\x6f\x4b\x78\x48\xf6\x84\x6b\x42\x85\x9b\xed\xc1\x37\xd3\x96\x94\x9c\x2f\x4b\xbd\xa5\x76\xae\x2b\xb2\xa5\x23\x84\x0a\xf4\x6c\xc6\xaa\xec\x49\x38\x64\x77\x86\xaa\xbb\x9a\xd6\x33\xf2\x7c\xb3\xfb\x6b\x9c\x4b\xb9\xcb\x24\x1a\xd4\x33\x92\x58\x31\xf1\x75\x4c\x06\xc0\x15\xc3\x27\xba\x1f\x2b\xde\xb5\x42\x60\x69\x19\x57\xc8\x47\x77\x1c\x8c\xaf\x36\xce\xd6\x1d\x47\x2a\x70\xb6\x5a\xd6\xf8\x2d\xb6\xa3\xc4\x75\x00\x82\x7c\x65\xb3\x23\x0c\x39\xaf\x61\xc8\xf7\xca\xbd\x0b\xbb\xc2\xad\x46\x24\x7b\x8b\x03\xfe\xb1\x7c\x81\xea\xf5\xfa\xab\x68\x26\x75\x2b\x43\x22\xf9\xfb\xf8\xe6\xc6\x96\xb5\x1c\x50\x95\x75\xe4\xaa\x34\x74\x7a\xdb\x2a\x83\xda\x6d\x5c\x49\x6c\xe8\x06\xe3\x28\x2c\x23\xd4\xb7\x15\xe9\x6e\xfe\x1b\xcc\xea\x95\x73\x3e\x9c\x22\x97\x7f\x3e\xf6\xbb\x47\x9f\xb9\x9f\xdb\x0d\x94\xee\x31\x6c\x56\x4f\x2a\x5d\x6a\x0a\x36\x1b\xe1\x3f\x1f\x8a\x4c\x73\xa1\x4f\xb9\x36\xaa\x65\x4e\x46\x10\x10\x6a\x4c\x54\x1a\xca\xd5\x4c\xda\x2b\xe8\xba\xd5\x29\x18\x98\x8b\x9e\x35\xdd\x2f\xcb\xa2\x13\xc0\x5d\x56\xbf\x68\x6f\x01\x49\xa2\x55\x38\xbc\x44\xd8\x94\xe0\xcb\x89\x7d\x1d\x8c\x59\x9f\x62\xb7\xca\x85\xe8\x96\x7e\x28\x81\xbf\x03\xc6\x4a\xe1\x64\x75\x98\xfe\x79\xa2\x43\xce\x63\x87\xf0\xa7\x27\x11\xe6\x97\x8b\x3b\xea\x47\x36\x83\xe2\x14\xd6\xb5\xd2\x9e\x39\x76\xfa\x78\x2c\x23\x15\xbb\x0a\xda\x18\x8a\x8b\x1b\x1c\xe8\x73\x1d\x30\x7f\x36\xc5\x5a\xfe\x74\xc3\x53\x19\x43\x27\x96\xc5\x10\xc2\x49\x4a\x1f\x76\xcf\x92\x11\xe8\x9c\xf7\x91\x0d\xf7\xca\x8a\xcf\x36\x73\x7c\x9a\x6d\xf9\x00\x2c\xed\x30\xf5\x50\x07\x72\xb6\x39\xdd\x4d\x8a\x8d\x5b\xfb\x8f\x33\x48\x40\x44\x7d\xab\xc8\x54\x95\x3a\x93\xe7\xb5\x5c\xb4\x57\x64\x4a\x9e\x3b\x19\xa3\xb8\x7f\xb8\xeb\x6d\xf8\x43\x74\x60\x62\xa2\xb3\xd3\x62\xdf\xc3\xa1\x42\x4f\x29\x2f\x39\xdb\x43\xfc\xaf\xe2\x5d\x21\xe6\x4e\xde\x4f\x5d\x92\x17\xc3\x9a\x4f\x19\xa1\x69\x05\x8d\x15\x23\x7b\xf6\x52\xc2\xb2\xad\x74\xd8\x7e\xa3\x91\x47\x41\x8f\x0a\x3b\x46\x8c\xc1\x6b\x21\x99\x77\x2f\x18\x29\xd5\x15\xcd\xf5\xcb\x92\xd8\x4a\x80\x6b\x7b\x95\x20\x87\x8b\x52\x24\xa7\x12\x41\xee\x2c\x30\xb7\xf4\x32\x79\x5c\xb8\x17\xf1\xba\x37\xfa\xe1\x49\x8d\x7d\x04\x25\xd6\xa4\x43\x72\x0b\xf4\x54\xbc\x56\xd6\x9f\x7b\xe4\x56\xeb\xe9\x6e\xf7\x6a\xc5\x9c\xfb\xd8\x17\x8c\x3d\xb6\x21\x80\xc1\xe4\x04\x95\x96\x8a\xfc\xbb\x61\x7a\xb4\x04\x18\x05\x54\x26\x8b\x23\x9c\x77\x6f\x80\x06\xe4\xad\x7e\xfa\xda\x15\xf8\x24\x1b\x36\xf4\x33\xd7\xb7\xd2\x76\x0c\x5e\x17\x45\xfe\xb5\xa0\xf7\x5a\xd2\x2d\xfc\x35\x6a\x17\xc5\x29\x7d\x85\x7a\x67\x6c\x8a\x5c\xa0\x6e\x46\x60\xdd\x1e\xb0\x38\x1e\xde\x99\x8b\x61\x80\x5a\x3c\x4e\xd5\xb3\x99\x5b\x69\xaf\x9f\xa7\xd6\x9a\xb2\x97\xe0\x40\xd7\x98\x91\x43\xac\x85\x46\xe5\x43\x2e\x9c\x2d\x86\x3e\x28\x8b\x18\x42\x97\xfe\xd3\x2a\xcb\x74\xda\x6e\x95\xcd\x1b\x00\x91\x1a\x33\xa7\x26\xe5\x75\x6e\x47\x40\x7a\x0b\x23\x54\x37\xca\x81\xad\xe6\xfc\x5b\xfb\x6c\x0b\xf4\xd7\x6e\x2c\x10\xe1\xed\x9a\xaa\x2b\x6c\xfb\x4f\x7c\x25\xeb\x74\xc7\x8e\xf3\xd4\xa3\x22\x13\x8f\x64\xf7\xcc\xd6\xb0\xfb\x0e\xe4\xc6\x44\x73\xf8\x6d\x4d\x3d\x4e\x5e\xee\x79\x6c\x2f\x73\xdc\x88\xc4\xbb\x2c\xc1\x91\x2d\xa8\x30\xd7\x73\xac\x9d\xf9\x5e\x01\x95\xcc\x30\x84\x3d\x2f\x2c\x2e\xa3\xe6\xa5\x25\xe7\x9a\x72\xb4\xba\xf6\x44\xdc\xf0\xf5\xbd\x46\x39\x4a\x18\x9a\xac\x67\x6c\x87\x4c\x03\xc0\x8f\x66\xdd\x64\xeb\x48\xcb\x59\x47\xbd\x75\xd9\x75\xac\x6f\x2b\x3d\x1e\x32\x59\x36\x0d\x37\xfd\x80\xf7\x79\x20\xe4\x6b\xb8\xc8\x3e\xec\x95\x56\xd7\x6c\x1e\x1c\x7c\x26\x40\x7d\xe0\xd4\x27\x04\x4d\xd0\xb0\x27\xdc\xaf\xc6\x2f\x59\x8a\x48\xe9\x4b\xdd\x00\xb4\x3a\x33\x21\x2f\xde\x7c\xdb\xd9\x0e\xc0\x50\x15\x49\x2a\x81\x08\xaf\x00\xfb\x51\xbc\x4d\x3f\xd6\x02\x41\xb5\xd2\xf7\x02\xe0\x37\x83\x72\x59\x5f\xc0\xc7\x76\x6d\xb4\x0f\x93\xec\x6b\x10\x3f\xf5\x8b\x7c\x97\xdf\x2e\x6f\x68\xfe\xf9\x2d\x2e\xe0\xff\xa1\x8d\x96\x51\xc8\xcf\x35\xfb\x7e\x16\xc3\x58\xa7\xe4\xfe\xb7\xb4\x95\x31\x43\xb3\x0b\xfc\x0b\xea\xe9\x4c\xf3\xe5\x89\xb3\xcf\x9a\x8e\xc3\x61\x7f\x39\x34\xea\x39\x3b\x07\x6d\x46\x17\x9c\xfc\xde\x3e\x67\x74\x57\xe6\xdc\xb0\xc5\xe0\xc9\xdf\xfe\x29\x75\xa5\xea\x42\x42\x0d\x39\xbd\x1f\xed\xfe\x1d\xf6\xda\xe1\x41\x9f\xa0\xf7\x7c\xcc\xb2\xe1\x1f\xc6\x51\x89\x18\xa6\x26\xa5\x2d\xb1\xb4\x31\xcf\xa7\x3b\x75\xc4\xa9\x55\xdf\x61\xd6\x53\x5d\x07\xdd\x76\x17\x14\xf2\x35\x6a\xbf\x6a\x78\x3f\xfe\x5d\x1b\x5d\x20\xda\xb1\x29\xd9\xae\xff\x8d\xe0\xe9\x37\x29\x3a\x2b\x1f\xa7\xc8\x96\x5b\x4d\x37\x02\x8e\x4f\x1c\xc1\x63\x82\x02\x6f\x56\x9b\xe1\x2f\x24\x43\xba\x32\x5e\x8e\x2e\x03\x88\x4d\xca\x00\x35\x18\x3d\x49\xce\xe7\xda\x55\xab\xfd\xfb\xf9\x82\x5b\x33\xa3\x32\x68\xb7\xea\x25\xf5\x75\x5f\x05\x54\x5d\x88\xeb\x57\x04\xa6\xf7\xc8\xf2\x28\x65\xaa\xc2\xef\xa0\xeb\x46\x21\xe6\xfc\x9d\x08\xd9\x42\xeb\xd2\xef\xef\x49\x55\xd3\xa6\x87\xc2\x8f\x5f\xe7\xd5\x2a\x11\x48\x1f\x8b\x23\x6f\x59\x3e\x0b\x06\x4f\x92\x97\x13\x07\x5c\x11\xaf\x2f\x4f\xc9\x4d\x4e\x17\xb2\x9a\x76\x42\x03\xf7\x43\xd3\x64\x60\x66\xe6\x5d\x00\x96\xf3\x83\xf0\xcb\xaa\xea\x3a\xbf\x1c\x86\xe5\xb1\x49\x90\xcb\x41\x80\x16\xca\x1c\x92\x2b\xa2\x79\xe4\x1f\xb4\x6a\x29\xe1\x42\xa5\xbd\x34\x8f\xa9\x8e\x53\xb4\x3d\xf6\x76\x9d\x97\x6c\x81\xfc\xf3\x5f\xc3\xa8\x55\xaf\xfd\x90\x35\x33\x3b\xce\xeb\x79\x7d\x34\x70\xef\x3a\x73\xa3\x98\xe0\x93\xd6\xcf\x71\x7b\xb3\xa3\x81\xb6\x8b\x61\x6d\xd3\x69\xf2\xa8\xb3\x2e\xd2\xec\xac\xf3\xa3\x36\x1c\x65\x78\x7e\xda\x52\xfa\xe3\xd8\x8c\x3d\x5a\x42\x3d\x62\xe9\xb7\x76\xb0\x09\xda\xcf\xea\x87\x08\xab\xa7\x33\xe8\x33\x76\xf8\x73\x9f\x14\x85\xa7\x43\x49\x0a\x22\x7a\x7d\x0e\xda\x07\x17\x21\x10\xa6\xa6\x8d\xfe\x78\x32\x4f\x3d\x6f\xc0\xc7\x78\xbd\x22\x3f\xf2\xe1\xdb\x7c\x7f\x69\x8e\x2e\x3f\x24\x38\x01\x54\x60\x1f\x73\xf9\x26\xc2\xac\x9c\x61\x88\x76\x6b\xc0\x17\x3e\xd0\xec\xa8\xe9\x1a\x6b\x53\xc1\x33\xd4\x4b\xce\xba\x7f\xde\x4d\xf9\xcb\x2e\xfc\x7d\x7e\xeb\x23\xec\x3f\x54\xf1\x48\xc7\xe8\x21\xf5\xad\xd6\xde\x6a\x9b\xd5\xde\x65\x87\xde\x0a\x35\xf8\x8a\x68\xf1\xb6\xa2\xc9\xaa\xb0\xa5\xe1\x11\x2e\xab\x53\xff\xf8\xb3\x51\xd7\xa7\x5f\x1e\xef\xf4\x48\x90\x95\xf2\xb9\x45\x24\x5b\xcd\x99\x49\xd0\xb6\xdf\x14\x34\x28\xe8\xad\x02\xf2\x6a\x4b\x6f\x21\x69\xa5\x47\x30\xba\xd0\x72\x2f\xe3\xdf\xc8\x87\xaa\x08\xb6\xd5\xc8\x9a\x13\xa2\x35\x0b\x7f\x42\x8d\x97\x7c\x44\xeb\x2f\xe0\xe8\x7f\x35\x6a\x56\xb5\x99\xa2\x39\x68\xbf\x47\x50\xd0\x3a\xa2\xca\x62\xa9\xef\xf1\x57\xba\x39\x3d\x00\x4b\x9f\x09\x3d\xf1\xb7\x60\x68\x70\xe9\xf2\x2f\x42\x0b\xb0\x1b\x1a\x9f\x45\xa1\xe4\xd9\x2c\xfa\xde\x20\x1b\x9e\xf9\xf9\x4e\x8d\x4a\x7f\x4b\xce\xd6\x9e\x67\x13\xa3\xa0\x54\xba\xda\x5e\x46\x2c\x5d\x80\x34\x23\x5f\x23\xf7\xf4\x93\x36\xaa\xfa\x5c\x59\x03\xcd\x13\x54\x5a\xfc\x3d\x58\xef\xed\xcf\x77\xd3\x25\xe4\xc9\xc6\x32\x2b\xb8\x23\x64\x66\x35\x02\x55\x1d\x03\x7e\xb2\x73\xc8\x47\x0c\xbd\x62\xa6\x1c\xce\xdc\x80\x53\x92\xee\x5d\x87\xaf\xa9\x3e\xe4\xfa\xf6\x82\x90\x3f\x5f\x80\x0f\x9c\xb4\x96\x16\xb3\x32\x25\x0d\xae\x1f\xff\x41\x34\x07\xde\xeb\xc9\xec\xb2\x34\xa9\x0c\xb8\x9a\xe3\x93\x2c\xc0\x03\x12\x82\x96\xbc\xd8\x9c\x1f\x94\x16\xe9\xbf\xc2\x9c\x95\xa0\x40\x77\xc8\xf1\x92\x1c\xdb\x43\x5e\x5e\xdb\x60\xe1\x09\xf4\x10\xc6\x35\xde\x32\x67\x03\x71\x9c\x6c\x99\xc9\x4e\xdf\xbc\x94\x38\x38\x1b\xf1\xaf\x28\x97\x46\xbd\x04\xa7\x45\x3f\x6f\xc3\x1b\x1f\x0b\x1f\x3f\x43\xbb\x56\xe7\xfb\x45\xd7\xf9\xd5\x04\x35\x8d\x4a\x82\xc7\x33\x80\xbc\xdf\x25\xd6\xdd\xb3\x00\x7d\x36\xf6\x02\x5b\x27\xfc\x52\xc7\x41\x83\x2e\xcc\xb2\x7c\x42\xc0\xb5\xf3\xf8\x63\x75\x5a\x78\x88\xed\xdf\x4f\xaa\xbd\xf5\x72\xf3\x12\xca\x37\x51\x1d\x63\x5a\x97\x2b\xbf\xf6\x2c\x1e\x78\x6c\x84\xac\xec\xda\xd8\x7a\xe2\xe7\x62\x4e\x27\xe3\xcb\xe2\xcf\x4f\xd7\x8f\x39\xc8\x98\xe3\x15\x8c\xb1\x06\x49\xa4\xc9\xcd\x99\x10\xc1\xaa\x7c\x50\x19\xa8\x04\xb9\x2b\xc0\xeb\xef\xc1\x39\xe0\xd2\xcc\xbd\x64\x70\xe9\xe2\xee\x31\x31\x7c\xde\x28\x03\xf7\x4f\x1e\x98\xd7\x35\x69\x19\xe9\x11\xc8\x0f\x81\x96\x85\xf7\xa8\x47\x49\xaf\x87\x7a\xf9\xd7\x3c\x39\xf8\x0c\x31\x6b\x66\xf6\x1f\x3f\x84\xa8\x4b\x3b\x35\xfc\xf0\xf2\x34\xfd\x38\xbd\xa2\x58\x94\xa2\xd0\x77\xd1\x79\x6c\x65\xa5\x6a\x84\xb2\x9c\x9a\x47\xd0\x58\xa1\x20\x5d\x56\xfd\xef\xa4\x53\x9d\x05\xfc\x48\xa1\x21\x9b\x1b\x3d\xa7\xb2\x95\xe7\xe7\x79\xa8\x0f\xd8\x87\x3d\x63\x9c\x40\x43\x91\x9b\x5a\x74\xa6\x64\x91\x7a\xf2\xf0\xcf\xb4\x2b\x0f\xb7\xb8\xc3\xd7\x1f\xf6\xfa\x19\xe5\x3d\xc9\x19\xf9\x07\xfa\xaf\x9f\xb9\x61\x3c\x04\xe4\x26\x8c\xc7\x4e\xc1\xda\xca\x1e\x41\x5c\xc1\x35\x35\xbe\xc4\x66\x8b\x95\xbd\xcb\x39\x9e\x23\x62\xc9\xc3\x19\xfb\x75\x1e\xcb\xe3\xaa\x06\x63\x92\x90\x86\x92\x61\x6a\x26\x4e\xec\x66\xef\x37\x1a\x61\xa6\x3e\x0f\x44\x72\x4c\x4c\x94\xab\x35\x96\x6d\x81\x98\x8e\x77\xf3\xf0\xf0\x10\x37\x0d\x68\xa5\x65\x56\xb1\xb5\x50\xc2\x84\x87\xbd\x80\xc9\x47\x13\xb3\x4f\x89\x1e\xd3\x68\xbc\x46\x2e\xf6\x96\xd2\x14\xe3\x78\xcb\xb4\xba\x78\x1a\x3d\xb8\x02\x1b\x86\xf0\xfa\x20\x99\x26\xde\x32\x36\x07\xfe\xc0\xdf\xd5\x36\x97\xb8\x1d\x7f\x6c\x40\xfd\x9a\xbd\x39\x06\x3f\x4a\xe8\x16\x0f\x7c\xe4\xd5\x2d\xc1\x9f\xab\x7e\x35\x1d\x37\x1e\xdd\x48\x5c\x30\xc0\x09\x44\x67\x23\x02\x73\x13\x86\x16\x7f\x2f\x48\x10\x3f\xf0\xfc\x2c\x7b\xfd\x93\x45\x24\xdf\x78\xdc\xc5\x20\xa3\x7c\x62\xbb\x6a\x89\x5d\x39\x1f\x67\x25\x4a\xf4\xa3\xe4\xe0\xdc\x0b\xde\x98\x01\x0f\x7d\x6e\x99\xed\xf1\x7c\x9e\xd7\x3b\x84\x5b\xc3\x56\x0a\xf9\x81\xac\xe1\x1f\x96\x79\x7b\x14\x06\xdb\xe1\x39\xbb\xf8\xe7\x27\x3b\x16\x09\x1e\x4a\xe8\x3f\xac\xd6\x7a\x27\x76\xe1\x7e\xf3\x98\xc8\xe4\x57\x15\xd3\xd6\x1c\xa4\x2a\x72\x05\x2a\xce\xf2\xa2\x03\xcd\xaf\xd4\xa1\x00\x36\xca\xc8\xe9\xc2\x65\xb7\x11\x19\xb6\x8c\xde\xdc\xf0\xb9\xf0\x05\xcb\xd0\xfc\x33\xfc\x4f\xc2\x2d\xb6\x3f\xc4\x50\x81\x98\x04\x5c\xf4\x1d\x8c\x76\x40\xed\x08\x57\x04\x3d\xa4\xa2\x97\xa8\xa7\xab\x87\x84\x42\x2a\x24\xc1\x6f\x39\x2b\x79\xa5\x0e\x37\x12\x73\x71\xee\x6d\x74\x14\xf0\x99\x2e\x08\xd2\x5d\xb8\xe3\x22\x7d\xf2\x63\x48\x53\x25\x9e\x8d\xa2\x31\x9c\xc2\x4c\xb8\xc1\xbd\x6e\xa4\x73\xb4\x89\x16\x12\x3f\xcb\x70\xdd\xab\xae\x63\x7d\x05\xd6\x60\x05\x50\x3e\xa6\x2b\xfc\x14\x16\x20\xdd\xac\x08\x4f\x63\xbc\xd6\x68\xdc\x2c\xe1\x55\x2e\x80\x6a\xad\xb5\xed\x06\x8f\xfa\x56\xb7\x3b\xf2\x55\xe1\x91\x23\xbc\x3e\x95\x76\xe3\xe8\x1a\x3f\xcf\x5c\xd9\x37\x00\x01\x66\x4a\x85\x38\x11\x15\xa3\xea\x2c\xb8\xf1\x7c\xfe\x4f\x38\x71\xfc\xf4\xf2\x89\xb0\xda\x0d\xed\x1e\x7e\x73\xb3\x83\x7c\x31\x98\xc2\xcd\x9a\x81\xdf\x0b\x3f\xd0\x7f\x62\xb3\x69\x7a\x64\x21\x02\x21\x1d\x5d\xdb\x71\x9e\xe5\xcf\xb8\x47\x36\x0c\x15\x22\x76\x0b\x42\x56\x5c\xaf\x22\x32\x59\x77\x5b\x6b\x4d\x13\xcb\x71\xd9\x10\x29\xaf\x54\xdd\x74\x0d\xa8\x33\xe6\xef\xb2\x0e\x2a\x93\xa8\xb5\x7f\x71\xc4\x87\xbf\x89\x9a\x0c\x32\x46\x4c\x40\xdc\x4d\x65\xd6\x35\x42\x04\xcf\x99\xe1\xb6\x50\xf9\x24\xc9\x96\x69\x43\x65\x54\xd4\x05\x68\x5d\x29\x79\x27\x74\xce\x77\x42\x8f\x3c\x08\x16\x4e\x5b\x0f\xa5\x2e\x94\xde\x42\xbf\x14\x0f\x6e\x8d\x1c\x6f\x79\x71\x48\x0a\xb1\x58\x64\x56\xaa\x69\xce\xae\xe4\x43\xe9\xa1\xda\xd5\x97\xf0\xa9\x74\xf3\x4d\xcb\xcf\x22\x57\x33\xba\xf4\x56\x7a\x1a\x9d\xcf\x9a\xbf\x99\x42\x70\xbf\x45\x21\x8a\xfa\xfe\x2e\x3a\x77\xec\x8d\x79\x22\xe8\x54\x7e\x30\xd7\x87\x6e\xd2\xb4\x1f\x06\xb6\xe6\x16\x2c\x65\x06\xf7\x7d\x20\xa3\x1b\xfe\x0d\xf9\xfd\x8b\x71\x26\x98\x3c\xe5\xc5\x9b\xe7\xfd\x98\x7c\x81\xae\x0d\x40\x2c\xa9\xc4\x13\x91\x4c\xb6\xf3\x75\x84\xb4\xba\x00\x33\x9b\xd9\x8a\x9e\x34\x1d\xe9\x1f\x2a\xb2\x3b\x45\x84\x60\x18\x6c\x09\x75\x46\x12\xb1\x3a\xfb\x7e\x67\xcc\x24\x87\x2d\xeb\x4d\x73\x1b\xbb\x7b\x68\x9d\x78\x2c\x7b\x31\x82\x0b\x2b\xbb\x26\x68\xec\xcf\xce\xac\xf4\x00\xa8\x7f\xfa\x70\x3a\xce\x9d\xfe\xeb\x14\x8b\x7d\xfe\xf1\xc3\xe7\xea\x67\x5a\xf9\x27\xf5\x42\x5e\xdf\x7d\x55\x88\x26\xbb\xba\x5a\x41\x66\xfb\x87\x82\x9f\xf1\xf1\x3e\x75\xf8\x55\xf9\x93\xb1\xd2\x37\x41\x06\x5a\x97\x78\xb9\x95\x2c\x5b\xe7\xc0\x99\x4e\x61\xe1\xf9\xee\xcc\x29\xab\x1a\xbb\xbe\x00\x2d\x0b\x41\xac\x34\xcf\x7d\x8b\xed\x6b\x9d\x1b\x30\x2e\x25\x2c\xba\xf6\xbb\x6a\x64\x7b\x53\xdf\x8a\x78\x96\x3d\x47\xc8\xb5\x23\x45\xf5\x48\x70\xe0\xc3\xaf\x3c\xb6\x5f\x35\x43\xd1\xce\x6a\x50\x75\xd1\xad\xc5\x05\x2a\x75\xb7\xdc\x09\x22\xf9\x85\xa3\x56\x4c\x89\x2d\x7e\x7b\x0b\xd2\x5c\xd2\x8e\x4c\xfa\xa2\x58\xda\x84\x77\x37\x31\xda\x5c\xa5\x05\xc4\x12\xb9\x69\x27\xdb\x9b\x5a\x95\x82\xf2\xdd\x11\xc9\x43\x58\x01\xb7\xce\x72\x03\xd6\xa0\x30\xc4\x25\x2f\x1b\x5c\xd8\x7f\xa5\xbf\x55\x35\xda\x60\x24\xd5\x63\x1a\xe7\x5b\x69\x15\x97\x97\x2f\x99\x43\x31\xa3\x57\xe6\x04\x98\x25\xaf\x83\x3f\xb5\x32\x6c\xb6\x5c\x01\xef\x40\xef\xbd\xf9\x66\xb2\x67\x14\x11\x86\x76\x03\xd5\xa6\x7c\x1b\x73\xf2\x45\x57\xd4\x05\xb5\xa3\xc8\x73\x8b\x81\x93\xda\xc7\x11\xd2\xe7\x30\x4f\x0f\xcf\x3f\xcd\xa1\x84\x90\xa1\x21\x59\xf8\xfa\xe1\x3f\xcf\x3c\x06\xb5\xd5\x4a\x21\xcc\x73\x41\xe0\x41\x22\xb1\xa4\x5e\xed\xa3\x1f\x65\x87\xb7\x83\x97\x48\xc4\x93\xbd\x32\xf6\xd2\xcf\x7f\x4f\xa7\x9d\x54\x9c\x87\x9f\x76\xdd\xbb\x43\xd4\x61\x57\xcd\x9d\xae\x3f\x4c\xb3\xf5\xba\xe2\x33\x2b\x61\xc0\xea\x31\xbc\xd7\x24\xda\x6a\x27\xb8\x04\x32\x55\xe8\xf4\x46\xef\x2c\xb7\x87\x2f\x20\xfe\xb3\xbe\xa7\xe4\xf5\x8e\x70\xd5\xf2\xac\x6a\xa9\xab\x4c\x84\x33\xdf\xa4\x2b\x3f\x90\x27\x4c\x4a\x6f\x09\x48\xc5\xfc\x5e\x37\x80\xa8\xc0\xb3\x1f\xff\x61\x04\x8f\xbc\xeb\xeb\xfa\xc8\x61\xaf\x78\x08\x9e\xe3\xbf\xeb\x15\xb2\x5e\x67\xf5\xb2\xb2\x7c\x0e\x68\x7b\x66\x28\x3f\x1b\xe6\x2b\xaa\xe8\xe4\xc3\xd5\xbe\x74\xd5\x79\x49\x3b\x42\x1b\x67\x1b\x50\xc2\x4c\x16\xcb\xda\x00\x5a\xe0\xdf\x36\xea\x35\x18\x55\xbb\x3d\x75\x0b\x32\x38\x16\x57\x38\x7a\xbc\x4a\x29\xc7\xca\xba\x16\x86\x06\xad\xa9\xb0\x01\x67\xf7\xce\xc3\x46\x56\x61\xe7\xe1\x27\xf6\x1b\xb0\x6c\xbd\x3a\xac\x9c\xba\x2e\xc4\xff\xdb\xe3\xa2\x33\x83\x82\xe6\xdb\xaa\xa2\x1f\xec\x4f\x34\xd7\x42\xb4\x56\xf9\xf4\x06\x12\x8a\x64\x51\x2a\x0b\xd6\xaa\x7d\x3e\x93\x1a\x25\x23\x17\xed\x27\x21\x39\xc0\x03\xdc\x29\x6e\x71\x77\xeb\x78\x8b\x4f\xd9\xa3\x49\x3f\x64\xef\x85\x91\xd8\xcc\xf3\x0d\x6b\x32\xf1\x8d\x82\x65\x86\x9e\x33\xfe\xdf\xe2\xce\x3f\x4a\x83\xc8\x85\x78\x5b\x6e\x55\x56\x29\xca\x19\xaf\xef\x68\x73\x20\xf9\x1b\x9a\xfb\xde\x37\x95\x29\xc4\x78\xb7\x87\x5c\xc9\x39\x9f\xb0\x17\x68\x44\x3e\x9e\xf2\x1b\xdc\x9d\x8d\x0f\x6c\x2f\x2a\xbd\xd9\xf4\xe6\x16\x61\x4f\x29\x50\x19\xa0\xc6\xcc\xcf\xb9\x35\xf8\x4e\xed\x08\xd1\xf0\xf1\xb9\xac\x10\xa0\xe8\xd4\x4c\x27\xc8\xa9\xda\xa7\x2a\x10\x4b\x89\x3a\x91\x51\xde\xc1\x62\x85\xc5\x09\x55\xb8\x1a\x43\xc8\xf7\x62\x41\xe9\x18\xd3\x1a\xb0\x6b\xad\xcf\x9e\x6f\xc4\x5a\x4f\xc0\xc4\x5c\x5e\x32\x42\x1b\xdf\x42\x73\xf2\x49\x90\x63\x6f\x5f\xdd\xfc\x5e\xc7\x2c\xc4\x93\x9f\x85\xd9\x42\x6c\x58\xb8\x4e\x7b\x30\x64\x35\xfe\xc0\xdb\x72\xb5\x1c\x3c\x7a\x0b\xd4\xa1\xde\x9a\x78\xef\x5d\x9d\x87\xd9\x85\xe2\x52\xc9\x19\xab\xcf\xc7\x30\x56\x8d\xde\x4d\xe2\x48\x82\x27\xf6\x7d\x85\xc9\xc7\xc8\xf7\x2d\xa3\xa3\x06\x86\x90\x62\xe5\xba\xf7\xbe\x29\x4b\xcf\xd6\xc3\xd4\xdf\xdb\xc8\xde\x58\x91\xf0\x7c\x74\x64\xf9\xe4\xc1\xe9\x9f\xa0\x11\x23\x19\x9a\xe3\x26\x11\x40\xc8\x18\xd4\xef\x5b\xea\x64\x0e\xc3\x5c\xd7\xf8\x36\xc3\xa3\x3b\x93\xfe\x71\x39\x3c\x26\x07\x3f\xfd\xfe\x52\x5b\xe9\xf1\xa5\xb3\x38\x72\x1f\x57\x4e\x4b\x1f\xb0\xf4\x13\xd0\xfc\x98\x33\xda\x6e\xae\x0e\x9a\x57\x76\x69\x3e\xe5\x9d\xca\x08\xb8\x06\xdf\xf0\xae\x10\xa3\x18\xc4\x56\x2c\xf8\x1a\xb7\x7d\x51\x89\x15\x14\xaf\x67\x3c\xc9\x16\xaa\xea\x72\x5d\xc2\xab\x8e\xf5\xe9\xff\xb7\x20\x3f\x6e\xdd\xd6\xd0\x50\x69\x39\x16\xa7\x95\x13\xed\x1c\x44\x29\x99\x45\x83\x41\xe8\xe1\x56\x2a\xf1\x3a\xc9\x74\x83\xac\x50\xe4\x25\x2c\x8e\x68\xfc\xfb\xf7\xe7\xe4\x49\x8f\xee\xcb\x83\xf2\x0d\x64\x49\x44\x6d\xd8\x15\xa3\xb8\x26\xa0\x41\xa3\x5d\x14\xca\xba\xff\x26\x40\xff\x66\xce\x7d\x9f\xbb\x33\xb0\x19\x44\xab\xe2\x03\xa5\xad\x79\x35\x89\x7a\xec\x11\xc8\xa8\xa9\xad\x26\x62\x9d\x85\xc7\x74\xff\xc3\xa6\xf7\x06\xb6\xe9\x65\x79\x3b\x7e\xbc\x4d\xd2\x0e\x0d\x50\x57\xeb\xed\x85\x61\x0f\xf2\xa1\x1c\x4d\x96\x87\x65\x28\x87\x49\xbb\x3e\x9e\xdd\x41\x7a\x9f\x52\x8b\x91\xb9\xc3\x78\x5a\xd6\x48\x2e\x2b\xf4\xf0\xb5\x44\xd3\x1d\xe6\x45\xa9\xa3\x8d\x96\x4f\x2d\xf3\x8d\x85\x36\x50\x8a\x12\x42\xb1\x96\x31\x5e\x50\x69\x65\xe2\x76\xd7\x58\x32\x6f\xcc\xd4\xfe\x32\xcd\x4a\x2d\xe4\x5a\x4d\x46\xd9\x9d\x66\xa8\xeb\x7e\xc9\xb3\x67\x81\xd4\x7d\x9d\x2e\xed\x7a\x18\x16\x23\x6a\xf1\xef\x13\xf1\x40\x42\xda\xc8\x71\xd3\xbb\x1c\x4e\x33\xa6\x77\x3e\xf3\x27\xbc\xef\xa1\xf3\xc9\x5b\x8f\x65\x2c\x13\x9f\x08\xd3\x1e\x9b\xff\x0d\x77\xda\xfe\xbb\xf2\x1d\x12\x1e\x5d\x9d\xde\x0b\x4c\x44\x36\x25\x6d\x4e\xed\x78\xd4\x23\xc9\x25\xc0\xcb\x54\xdb\x34\xb0\xbc\xcf\xe4\xb5\xa6\xce\x1c\xeb\x0d\x47\xb2\xa4\x0b\x1a\xdc\xb6\x92\x9e\x8e\x80\xcd\x1e\x85\x32\x28\xcf\x8b\xcf\xfc\x4d\x5d\x0d\xda\xc3\xf9\xd6\x7c\x32\x32\x54\xa3\xb4\x7e\xac\xb3\xdc\x26\x5a\x4b\x77\x08\xee\xe3\x36\x28\x5c\xfc\x09\xc8\x0e\xa9\x02\xd0\xbc\xaf\x69\xcc\x56\x33\x63\x9e\xd6\x07\x3b\x76\xc5\xf5\x9e\xf4\xbf\x77\xb7\x9b\xad\x6a\x7c\xe7\xdf\xd5\xef\x2e\x06\x36\x28\xb9\x67\xf3\x14\x9f\xfa\xa7\xcf\xd8\x31\x43\xe6\x29\x6e\x0d\xc0\x7e\x4e\x34\x1c\x70\x30\xcd\x4c\x2e\x84\xeb\xce\x38\x15\x2f\x97\x78\x2b\x5f\xbe\x0f\x3f\x29\x3b\x4b\x1d\xa0\xb3\xc4\x80\x90\xf9\xe2\xd6\x09\x4d\x11\xdd\x7e\x08\xb2\x2e\x63\xc0\xa3\xbd\xc9\xa9\x4e\xc6\x4f\xaf\xd1\x7e\xf4\x62\x6d\xaf\x61\x01\x8b\x75\xe7\x3c\x7b\x15\x9b\x67\xcd\x6d\xb6\x0a\x07\xa6\xfe\x74\xc8\x9f\xd3\x23\x1f\x34\xa2\x80\xa5\xb9\x93\x06\x9c\xb2\x04\x38\x80\xa5\x1a\x00\x25\x39\xb6\xc9\x69\x6f\x9a\x2a\x0f\x31\x10\x65\xe2\xb4\x0f\xd6\x4f\x7e\x7f\x05\xfd\xb1\x22\x28\xb6\x5e\xc9\x6a\xe2\xbc\xf8\x00\x4c\x30\xc4\x1f\x06\xd7\x8b\xa3\x9a\xa3\x8b\x1c\xeb\x43\xd4\x7c\xfc\x7a\x58\x3a\xb4\x0b\xd5\x14\x51\xdf\x78\xe3\x40\xa5\x02\x57\x80\xab\x59\xba\xac\x41\x46\x71\x55\xb6\x23\x9f\xe4\x6a\xd1\xec\x97\xd6\xbe\xb0\xc7\x26\xba\x21\x97\x53\x7d\x45\x8b\xb4\xd9\x47\xdc\x5c\xc0\xa6\x5c\x0c\xf3\xfe\xef\xe9\x98\xc7\x61\x71\xd7\xd6\x2c\xff\xf9\x5e\xba\x45\xae\x4c\x53\x35\x77\xaf\x73\x46\xa2\x95\x47\x6f\x45\x6a\x6f\x2b\x7f\x62\x35\x62\x9d\x58\xe7\x1f\xc3\x60\xe0\xca\x23\x1d\xda\x1b\x0b\x0b\xda\x42\xd4\x7f\x39\x1a\x1b\x9e\xaf\xe0\x07\x89\xd9\x08\x44\x12\x2d\xb9\x86\xab\x72\x01\x0e\xcb\x30\x48\xb3\x96\x0d\x6f\x32\x74\x70\x65\xd8\x36\x74\x82\x38\xda\xa4\x35\x48\xbd\xcf\xcc\x39\xb5\xbc\xbb\xd6\x66\x76\x8b\xc6\x62\xad\xdd\x4a\x4e\x2d\xbe\x55\xf0\xfb\x14\x38\xe9\x8d\x31\xb3\x5f\xcc\xe9\xe0\xd9\xf4\x40\xb5\x53\xc0\x13\x7e\x6a\x7e\xcc\xe8\x71\x4b\xdc\xc7\x5f\x71\xa5\x5e\xaf\x8c\xde\x95\x2e\x3c\xc2\xb8\xe6\x49\xd7\xf4\x7f\x3c\xc7\x5d\xa6\x59\xe3\x45\xff\x72\x5a\xb7\xbe\xdb\xf3\x55\x1e\xca\x1c\x28\x4e\xd0\x3e\x67\xf9\x27\x3e\xd7\x67\xc9\xb7\xf2\x34\x86\xfe\x77\xa5\x03\x83\xaa\x2b\x6b\xa8\x05\x53\xf6\x0e\xfe\x79\xb9\xc2\x71\x55\xe1\x51\xfe\xe6\xaf\xf5\xab\x11\x11\xf7\x97\x96\x1d\x8b\x76\xde\x53\xc2\xd4\x32\x66\xb8\xa3\xe0\xf0\xa1\x2f\x38\x17\xe6\x3d\x67\xe7\x98\x06\x95\x62\x27\x11\x38\x77\x57\x4e\x2e\xa6\x47\xbc\xa3\x52\xe2\x75\x79\xc3\x93\x71\x83\x50\x87\x34\xf0\x22\xe8\xde\x2c\xf9\x77\xdf\xc4\x1d\xca\x83\xa8\xac\x0a\xf6\xec\xa3\x5f\x09\x03\x03\x2a\x8f\x2f\xb2\xf9\x05\x22\x0c\x69\xa6\xee\x4e\x64\x1a\x90\x47\xc6\x92\x88\xee\x69\x95\xfb\x4d\xe7\x45\x35\xd4\x32\xf6\xf6\x5a\x37\xd9\xb0\x98\x1f\x7c\x0b\x70\x16\xfc\xb9\x1c\xf8\xbf\xb4\xc7\x53\x43\x43\x7b\xa7\x2a\xa7\xc6\xac\x33\xdd\x52\xed\x26\xce\x72\xae\x7d\xac\x29\x36\x11\xe8\x24\x75\xb9\xa2\xc0\xa8\x24\xde\x38\x1e\x4b\x47\x46\x62\x70\xe0\x54\x77\x03\x52\xe8\xed\xec\x2d\x07\x86\x36\x06\xaf\x96\x7b\x7b\xbd\x16\x56\x16\x63\x63\xef\x22\x47\x07\x24\x26\xc8\xa8\xcb\x97\x37\x65\xc9\xd4\x81\x8b\x42\xc1\xce\x1c\xb1\x5c\x98\xed\x5f\xa9\x62\xd1\xcb\x8b\xee\x69\x0d\x0e\x51\xbc\x81\xbe\x47\xed\x59\x37\x8d\x5a\x70\x95\x31\xe6\x3b\x70\xf3\x95\x47\x5e\xdf\xd6\x37\xd0\x4f\x50\xdf\x45\x3d\x4a\xac\x81\x61\xcc\xb1\xf9\x2f\xc6\x35\x5f\x78\xaf\x37\xd6\x30\x80\x1c\xfb\x2c\xcd\x1e\x9c\xd6\x5d\x33\xbe\x2e\x23\x61\x38\xd2\x46\x75\xa9\x82\x4d\x1f\x05\xdd\x72\xfe\xcc\x29\xa8\xac\xc7\x6a\xda\xa4\xef\x91\x96\x46\xae\x0f\x25\xbc\x7d\x7e\x48\xd7\x3b\xd7\x77\x3f\xab\x28\xa7\xfa\x6b\xd0\x8d\x00\x97\x26\x16\x73\x5e\x86\xc8\x16\x11\x30\xbb\xcd\x5b\x6b\x53\xc2\x59\x7a\x9c\x6f\x53\x59\xa7\x14\xee\x36\x49\x42\xd4\xab\xb9\x57\x7a\x6e\xf6\xf7\xf8\x3b\xef\x75\x32\xe1\x3a\x59\x1a\x93\xcc\x58\x1a\xfc\xa6\xbd\x76\xf2\x65\x53\x7e\x59\xf7\xf4\xae\xfc\xea\x15\x81\x26\x34\x49\x6a\x4f\x0c\x4e\xfc\xfe\xef\xa1\x77\x41\x61\x82\x76\x43\x3e\xcb\x64\x84\x88\xea\x6e\xfb\x41\xa9\x00\x54\x0f\x7e\x2c\x57\xf9\x26\x1c\xb5\xf3\x59\xff\x07\xbb\x32\xff\xb9\x8a\x2b\x79\xc3\xf5\x7e\xd8\x6c\x30\x94\xf4\xf4\x13\x2c\x4c\xe6\x78\x7f\x2c\x68\x67\xfb\xc5\xd1\x85\x0f\xac\x78\x4a\xe9\x67\x7c\x4b\x50\x81\x1c\xb7\x3a\xec\xa4\xde\x5a\x36\xee\x1e\x57\x1e\xe7\x85\xdd\x40\xb1\x48\x93\x10\x86\xa3\x45\x4c\xae\xe2\x50\xbb\xd0\x36\xb2\x4f\x60\x37\x7f\xa7\x3d\x9d\x03\x37\x51\x6a\x1a\xd9\x95\xf9\x9d\x48\x17\xd4\x0c\xc6\x79\x3d\x18\x7a\x23\xea\xc9\x5b\x55\x33\x7f\xe9\xfa\x5e\xa0\xfb\x47\xd9\x9d\xf1\x34\x01\x1f\x4f\x37\xf5\xa6\x21\x8d\x77\x12\xe2\x45\xec\x2b\x13\xf4\x8f\x5e\xde\x6e\x7b\xb3\x66\xc4\xd1\x69\xe0\xb1\xbf\x67\xd3\xe8\x76\xa8\x5e\x30\xa0\xb4\x8f\x0d\x7a\x91\x70\x98\x95\x98\xb9\x27\x2b\xac\x96\x3b\xf4\x00\x23\xae\x7f\x31\x68\x59\x1a\x5c\x67\x4b\xda\x02\xa8\xf7\x7d\x56\xb7\xa1\x12\xef\xa7\xe5\x0e\x4a\xf8\x96\x53\xd8\xd6\x08\xc2\x67\xdb\xbf\xc9\x5c\x16\x86\xa7\x4b\x5a\x21\x86\x4d\xb7\xaf\xe6\x5e\x05\x54\xd3\xc6\xd6\xb2\x5b\x36\x32\xcc\xdf\x56\x94\xdc\x5d\x08\xd0\xad\x54\x8d\xa8\xbb\xb9\x2b\x0f\xfa\x5d\x99\x80\x70\x1b\xdb\x5b\x97\x14\xaf\xe1\xb9\xd3\xbc\x94\xbd\xef\x14\x55\x68\xef\xbd\xc5\xe2\x53\x8c\x6b\xd4\x66\x1e\xc7\x21\xbb\xd8\xe4\x84\x8b\x8d\xb2\x96\xcc\x03\x24\x0d\xb2\xdf\x2b\x7d\xba\x77\xfd\xbb\xd2\x72\xae\x6f\xf3\x16\xfc\xde\xb3\x70\x0b\x09\xd1\x67\xdd\x15\xa1\x19\x45\x9d\xf9\x31\x3e\x37\x5c\xfc\xe2\x6c\x21\x80\xec\x67\x2e\x0a\xf3\x35\xad\xc0\xfb\x58\xbf\x3c\xdf\x27\xb5\x35\xe3\xc6\x55\x96\x0f\x8c\x7c\x64\xd4\x99\xbd\xc4\x7c\xa7\xae\x5f\x4e\x7a\x64\x16\x1b\xb5\xe6\x85\xe8\x72\xac\x3a\x20\x9a\x27\xbe\x3c\x3d\xff\x74\x2f\x7a\x07\x7d\x60\x9c\x20\xe2\xd8\xf7\xaa\x2d\x28\x8e\x2e\xa1\x89\x57\x6e\x39\x8a\xc2\x28\x38\x9c\xc8\x6c\x83\xbf\xf8\x73\x18\xf0\x1c\x96\x2d\xeb\x92\x30\x60\xe2\xee\x10\x81\x4b\x15\x1f\x27\xfb\xf2\x2d\xc6\x34\x37\x50\x3f\xcf\xfb\xc8\x08\xa8\x09\xf4\x6a\xdc\xcd\x26\x3d\x23\x52\xdc\xc4\xe1\x53\x69\xd3\xdb\xb4\xc0\x69\xb8\x5b\xad\x14\xee\xbf\x6e\x0d\x68\x36\x64\x43\xaf\xf4\xd1\xf7\x89\x21\x96\xa3\x7e\xb0\xc3\x9e\xaf\x52\x81\x50\x63\x39\x6c\xb2\x8a\x7f\xa9\x14\xbc\x4b\x2b\x5f\xdc\x5f\x38\x98\x35\x34\x89\x4f\xef\x51\xc4\xa4\x7a\xc4\xeb\x6a\xd6\xf7\x35\x14\xf7\x10\x01\x0e\x1b\x0c\xd5\xdb\xc3\xc8\xa7\x6b\x2a\x61\xfd\x44\x7d\x25\x6e\xe8\x8f\x13\x22\xf1\x44\xd9\x85\xb6\x6d\xb8\xed\xcc\xf8\x4b\x94\xf4\xf7\x78\xfb\xc2\xfc\xf7\x31\x2e\x99\xed\x72\x38\x15\x63\x4f\xb6\xd6\xce\x7a\xd7\xb2\xca\x59\xb5\x96\xb6\x44\xbd\xdd\xde\xc5\x8a\x77\x59\x99\xa9\xdd\x60\x56\x36\xe9\xb1\x4d\x8a\xb2\x0d\x59\x2f\x01\x66\xb4\xe9\xdf\xc0\x54\x0c\x7f\xb9\xc7\xaa\x9c\x29\xdb\x3c\xe4\xf0\x34\xca\xb2\xed\xed\x40\x2d\x2d\x6b\xf9\x1c\xaf\x34\x7d\xc5\xa4\x95\x23\x97\x31\xbd\x74\xed\x9b\xa1\x57\x4f\x13\xf5\x5a\xfe\x53\x34\x62\x5b\xdb\xd0\x0c\xd9\x1a\x21\x29\x73\x56\xfa\x05\x7e\xe4\x9a\xaf\xb9\x62\xe8\x23\x8b\xc5\xf4\xe7\xac\x14\x97\xbf\xc8\x1a\xde\x99\xe0\x7b\x2b\x80\xcd\xa0\x55\x59\xef\x21\x52\xf7\x18\xaa\x35\xb3\x49\x1e\xce\x57\x4f\x58\x5b\x8b\xee\xab\x48\x3b\x24\xaf\xd5\xb4\xd4\x35\x4c\x9c\x96\x05\xe4\xdf\x6f\x4a\x95\xe2\xc9\xed\x19\xff\x2d\xbf\x3d\xe0\x3e\x4e\x73\xe3\x8a\xb5\xb7\xaa\x0b\xd4\x47\xba\xcd\x79\x5a\xe1\x75\xfc\xe4\xdf\xf2\x89\x3e\xe1\x9c\xde\x5d\x6f\x87\x31\x6b\xb2\xbf\x98\xee\x85\xca\x43\xab\x1c\xc3\x65\x56\x17\x51\x15\xe1\x5e\x6a\x12\xb7\x2e\x24\x30\xfd\xc4\x83\x4e\xbc\x69\x88\x0e\xda\x56\x27\x0a\x6f\x68\xaf\xd6\xc6\x3a\xbe\x9d\x26\xeb\xb0\x89\xed\xdc\x79\xbf\x90\x19\x37\x1e\x0b\xe5\x5c\x4b\x44\x3e\x6c\xa4\x10\xf0\xe3\xa5\xc4\xfb\x17\x75\x33\x1f\xaf\x41\x5d\xe2\x44\x03\x07\xa3\x41\x33\xdd\x5d\x4b\x4f\xd9\x80\x29\xbb\xee\x26\xc0\xe1\x95\x8a\x05\x21\xe2\x18\x4c\xf3\xc0\x9b\xd0\x2b\xc9\xf7\x47\x00\x67\x32\xb9\xad\x37\x70\xf2\x59\xbc\xae\x06\x09\xba\x97\xea\x5c\xb7\xb8\x78\xa0\x85\x5b\x79\x8f\xb0\xe3\x99\xd4\xe3\x09\x57\xaf\x08\x00\xbf\x9d\xd8\x27\xc5\x12\xfb\x0e\x33\x38\x3e\xdb\x81\x21\x75\x5a\xeb\xae\xd9\x0c\x28\x5f\x13\x1a\x59\x4b\x85\x0a\x1b\xfb\xef\xa0\xb8\x9c\x9d\xd0\x9b\x2f\x37\x4c\xf8\x50\x67\x44\x7c\x8f\x7e\x2b\x8e\x58\xb4\x5c\xf0\x34\x71\xd8\x96\x20\x78\x73\x7c\x7b\xe9\xbc\x48\xc8\x0e\x57\x17\x94\x74\xf6\x41\x74\xb4\xca\xb1\xe0\x64\x27\xe7\x4a\xda\x45\x1d\x25\x6c\x5b\x7a\xe2\x41\xe4\xdc\xd2\xc4\xbf\xf9\xb4\xb0\xce\xa5\x00\xde\x48\xc0\x64\x29\x53\x2d\x74\xb5\xfd\x57\x55\xeb\x5a\xfb\xef\xec\x88\x84\x8d\xd7\x3c\x3d\xa9\x9c\xf3\x7a\xf3\x5c\x41\x4f\x5f\xcc\xeb\xd3\xc4\xce\x6f\x37\x07\xa6\x4a\x05\x67\x7c\xdf\x61\xfd\x52\x1c\x46\x6a\xdc\x1a\x52\x50\x8a\x00\x50\xda\x76\x2b\xad\x8c\x57\x9c\x08\xcf\xfa\xc4\xff\x78\x92\xce\x21\xc5\x55\x0c\x1b\xf6\xcb\x1d\x3b\xf4\x4e\xda\x2f\x5e\xbb\xbd\x9b\x39\xb3\xfa\xc9\x72\xa7\x86\xdd\xf6\x97\x46\x5c\xc2\x47\x21\xe4\xf0\xb5\x55\xd2\x6d\xd6\x55\xb3\xe3\x6b\xb4\xd2\x00\x2e\x9d\x72\x4e\xe2\x42\xc8\xbd\xad\xf4\x7d\x9c\x21\xb1\xb3\x23\x70\x87\xaf\x9f\xfa\x2f\x5a\x24\x7e\x98\x8e\xd1\x38\xea\x36\x34\xb0\xf8\xf9\xc3\x3f\x81\x15\xbe\x92\x81\x29\x71\x5b\xcc\xcf\x57\xcd\xd3\x79\x60\x15\xb7\x13\xb8\xc7\x24\xbd\xe2\x5d\x9a\x0e\x23\xe4\x0f\x02\xa7\xc4\xb2\x00\x46\x1f\xae\x1d\xac\x15\x3f\x4e\xe9\xb9\x92\xf8\xf4\x76\xf4\x87\x92\x71\x93\x5f\x1b\xce\xf7\x5a\x3c\xf4\x5a\x0f\x55\x0e\xf8\xd8\x37\xca\x62\x89\x2f\x77\x88\x52\x0f\x72\x32\xb9\x16\x8f\x6b\x86\xac\x38\xa4\x32\x91\x4b\x7b\x3c\xbd\x46\xa6\x71\xd0\xeb\xf7\x79\x7a\xaf\xf8\x31\x34\xc6\x54\x66\x8c\x9b\x05\xde\x39\x02\xa8\x4b\xdd\x2b\x98\xde\xfe\xd6\x6f\xe8\xbd\x75\xed\xdd\x86\x5a\xc9\x6c\x93\x3b\xc7\xcf\xc8\xf6\x6b\xcd\x5a\x17\x1f\x84\x72\xc2\x0c\xc7\x61\x76\x2a\x71\x7d\x5e\x0c\x3e\xaf\x2e\x9f\x34\x3d\xce\xa7\x88\x4c\x4e\xbd\xfd\xb3\x42\x7b\xbb\x8c\xc1\xa9\xad\xb7\xd0\xee\x1c\xa3\x8f\x8b\x56\x05\xed\xb9\xd1\x5b\x4c\x55\x3f\x62\xb0\xfc\xbc\xb0\x14\x22\x61\x3c\x6c\xe8\x09\xd4\xba\xc0\xdd\xb8\x2d\x18\x10\x16\x7f\xf4\xbb\x61\x5b\x8a\xb1\x3a\xbc\x11\x5b\x81\xc8\xb8\x42\x71\x14\x7d\xcb\x42\xcb\xaa\xfe\x74\x0a\xfd\xd5\x3f\x06\x7d\x5b\xa0\xf4\x40\xe5\xb3\x0b\x9a\x8e\x8f\xca\x77\xf5\x9a\xa0\xc2\x5f\xf1\x36\xa3\x15\x9f\x71\x32\xdb\x72\xc9\x8f\xd1\xbf\x9e\x4d\x95\x22\x93\xcd\xab\x2c\x8a\x13\xe7\x7b\x86\xc9\x68\xe7\x69\xdb\x7f\x3a\xb6\xc5\x67\x7f\x4b\xcd\x44\xef\x77\x9f\xdf\x1c\xd5\x18\x0c\x8a\xf4\x02\x6c\x94\x5f\xfc\x2e\x44\x85\xfa\xf2\x89\xca\x0e\x38\x06\x6e\xdd\xc0\x10\x4d\x0d\xf0\x4f\x78\x3b\x4f\x5f\xe3\xfe\x13\xa2\x60\x44\x8e\x0b\x84\xd4\x6c\x24\xdb\xc7\x95\xf6\x5c\x4e\x57\x0d\xfd\xd4\x41\x72\x59\xaa\x89\x45\x69\x33\x4f\x4e\x74\xcc\xc3\x38\x65\x16\x75\xd0\x1e\x2a\xbd\x04\x46\x23\xa4\x8d\xb2\x2c\x52\x22\x89\x8e\x0f\x77\x32\x26\xee\x99\x12\xfb\x57\x4c\x44\x41\xa4\x45\x2b\x28\x1f\x7e\xd5\x57\x4b\x35\xac\x88\x9a\x1f\x15\x30\x41\x13\x27\x15\xd0\x7a\x77\xd7\x21\x61\x29\x74\xec\x86\x6a\x72\x8e\xa9\x65\x60\xfd\x47\x17\xc0\x5a\x72\xc9\xcf\x5d\xda\x26\xf1\xa8\x0a\xcc\x71\x41\x00\xbd\x87\xe8\x42\xbe\xfe\xa3\xfb\xc8\x46\x26\x12\xe5\xa0\x39\xca\x59\xea\x24\x2e\xd6\xaf\x72\xdc\xdf\x40\xa0\x37\xb3\xa8\xcf\xaf\xaf\x73\x45\x0c\x1f\xec\xb3\x7f\x19\x1c\xb1\xdf\x4d\x88\xce\x92\x8a\xbc\x5a\x71\xa0\x32\x60\xb6\xfb\xfa\x38\x4d\xe9\xb1\x6e\xcd\xf8\xef\x57\xe0\x82\x7a\x60\x85\x2d\x62\xb0\xdf\x19\xa5\x8e\x70\x3d\xa5\x26\xb2\x27\x47\x45\x05\x2c\x0c\xa6\x89\x49\x87\x40\xd6\x3a\x43\x72\xb6\x6e\xb6\x9e\x3e\x56\x9b\x49\xd3\xf4\x30\xa5\x21\x38\xf1\x69\xba\xb4\x7a\x75\x22\x95\x0b\x0f\xe2\x7b\x3e\x25\x3a\xff\xad\x41\xb0\x4c\x3d\x27\x3c\x81\x15\xc7\xf9\xe5\xc9\x1e\x50\x17\xbd\xf3\xd4\x5f\xe5\x67\x82\x1c\xdc\x6d\xd6\x18\x82\x1b\x86\x35\x6c\xe6\xba\x55\xb6\x5b\x46\x3f\xf9\xbe\xe5\x76\x0c\x5d\x28\xf5\x30\xec\xd6\xe0\x80\xa4\x9c\xd5\x8d\x7a\xe7\xc7\xe5\x81\x0b\x10\x61\x66\x79\x92\xe8\x8f\x80\x0d\xb5\x85\x22\xb4\xec\x81\x89\x9c\x72\xe8\xa5\xcf\x4c\xc9\xbf\xe4\x95\xb3\x65\xcf\x15\x66\x62\xb7\x7b\x1b\x1b\xea\x03\xe9\x5f\xea\xdc\x74\xcb\x41\x4b\x44\x85\xc3\x4a\xe2\x91\xde\x23\x69\xa9\xd9\x11\x90\xfc\x78\x42\x73\x0b\x3e\xc0\x47\x0b\x31\x4d\xa2\xd7\xad\x9b\x60\x24\x79\xc1\x16\x6e\x6a\x86\x32\x80\xbd\x7f\xe4\xfe\xdc\x3b\xd6\x8f\xf1\xfe\x75\x1c\xb8\x5e\xc1\xd4\xea\xa6\x50\xd1\xf2\x47\x25\x87\xbc\x67\xec\x19\xf0\x05\xbc\xf9\xb6\x5d\xa9\x88\xd7\xfe\xd2\xbc\x92\xf0\x61\x09\xeb\x6e\x73\x02\x8a\x0f\x3c\x8a\xa9\xf9\xc5\x04\x14\x52\x3c\x06\x1b\x5d\x98\xad\xf0\x6f\xee\x35\xc2\x8f\x1e\xfa\x2e\x05\x3c\x43\xe6\xbb\xcb\xaf\xcf\xe1\x04\xc4\x49\xc9\x85\xee\xb6\x36\xf2\xa7\x2f\x2c\xbb\x03\xb9\xc7\x8a\x18\x5a\xbe\x44\xa6\xb0\x82\xec\x83\xe1\x60\x89\xd6\x20\xc5\x9e\xd0\x5d\xb2\xe1\xe6\xc2\x74\xb5\x2a\x68\xf1\x98\xc7\xe8\xe4\x26\xe7\x9d\x81\x91\xa5\x1a\xad\x1f\x7c\x4c\x93\x2e\xff\x36\xa7\xda\xde\xe0\xd2\x8f\x56\xae\x80\xb6\x02\x53\x23\x09\x7b\x10\x22\xe0\x92\xde\x78\x2c\x05\xad\xb6\xc0\x74\xe1\x03\xe5\xbf\x36\xeb\x9f\x2b\xa8\x62\x8b\xea\x16\x5c\x81\xc8\x2c\xa7\x1f\xa4\x38\xb4\xa4\xed\x25\x1e\xd6\x97\x2c\xb1\x74\x87\x75\x0b\xe4\xf2\x8f\x6b\x2a\x24\x1d\xb9\x80\x3b\x2d\x9f\xb5\xd9\x57\xa0\xf1\xac\x71\x52\x6a\xf0\x10\x48\x7d\xcf\x5d\x3c\x4f\xe9\xa5\xb1\x7d\x4f\x61\x08\x7f\x71\x2f\x3e\x35\x8e\x7e\xd1\x10\x57\x72\x05\x30\xc1\x8a\xe6\x1f\x04\xa4\xfc\x07\x89\x2a\x5f\x57\x4c\x6d\x97\xb2\xb3\xf3\x1a\xf5\x94\xf4\x2a\x35\x58\xe6\x29\xe8\xbc\x14\x5a\x59\x1a\x79\xf0\xd8\xff\xfb\xfa\xd6\x0b\xc6\xf6\x3e\x7f\xff\xc7\x9d\x95\xea\x3a\xc5\x65\xa7\xfd\x03\x80\x36\x89\x39\xc4\x33\x81\xf0\x2a\x0b\xba\x87\x2b\x99\x76\xb0\x5b\x0a\x2f\xf9\xf7\x4a\x3c\x2f\xd4\xe6\x51\x4d\x4c\x81\x1c\x01\x4f\x0e\x2c\x8e\x69\x81\x16\xf5\x0b\xfb\x6c\x17\x1a\x62\x26\xa9\x08\x01\x90\x46\xf1\x0c\x35\xf0\xed\x96\x56\x9c\xb7\x0e\x97\x0c\xc2\x8b\x37\x38\x78\xfc\xd5\xdc\xf4\x17\x55\x63\x1c\xe2\x61\x20\x03\xca\xc0\x8b\xc0\x07\x04\x1e\x52\xa7\x0e\x45\xeb\x27\xe2\x3e\x3e\xe7\x24\x02\x35\x99\x40\xdf\x38\x15\xc2\x6d\xda\xef\x24\xd0\xfb\x9e\x09\xf5\x0b\x24\x26\x1d\xf8\x58\xdc\x35\xd1\x96\x31\x53\xc8\x7a\x65\xad\xd6\x01\x60\x62\xf9\xe5\x3b\x60\xd7\x0e\xaa\x9f\xaa\x86\xaa\xaa\x3e\xd8\x78\x61\x15\x17\xf0\xe7\x42\x91\x63\xd3\xa1\x06\x75\x2a\x91\x87\xd2\xd1\x64\x5a\x99\xb3\xe6\xf8\xbb\x67\xfb\xc2\xe8\x6b\xce\x6d\xb5\xb3\x4e\x4b\xbc\xcc\xc2\xe1\x7a\x2e\x9d\xba\xee\x45\x58\x9e\x9e\x03\xf8\x60\x5b\xe4\x51\xf0\xe7\x6e\x6a\xdc\x5c\xec\x2a\xaf\x80\x31\xc0\xcd\x04\xda\xb0\x7c\x45\x7d\x35\x4c\xeb\xae\x07\x3b\x85\xa8\x50\x54\x45\x17\x5a\x51\x8a\x6e\x9b\x3c\x5d\x2d\x6f\xf3\xa4\xee\x56\x45\x98\xdf\x66\x44\xeb\x63\x5e\xfb\x70\x6f\x26\x4b\xc8\x08\x52\xb1\x97\xc9\xd9\xb8\x58\x12\x98\x64\xdd\xcf\x6c\xa4\xd5\x78\xe1\x22\x62\x0e\x52\xdd\x24\x7b\x41\x37\x22\x5a\x49\x15\x27\xdc\x69\x70\xe4\xab\x4a\x52\xfc\xbd\x3c\xb4\x95\xa9\x9b\x05\x5a\xfd\x9d\x57\x2c\x86\x45\x84\x8c\x60\x68\x5e\xc4\x18\x9a\x08\x58\x0a\xa4\x99\xd1\xa9\xaf\xd2\xe7\x60\x6e\xf8\x7d\x26\x40\xb4\x87\x1d\x70\x48\xd3\x38\xb4\xcf\x6a\x42\xfe\xd0\xfd\x27\xbf\x30\xf1\x87\xcf\x23\xa2\x4b\x3d\xf7\x8f\x6c\xcb\x2f\x5e\x6c\x46\xda\x11\xfa\x5a\xe0\x2c\x38\x2b\x5d\xe4\xa8\xbc\x83\x2c\x1d\x9a\x41\xcd\x96\x62\xb8\x9a\x8e\x6e\x50\x01\xc7\x99\xe2\x73\xcf\xf8\x4f\x06\x8f\xc9\x02\x4a\xb2\x2c\xf8\xa3\xcf\x7f\x0f\xeb\x2b\x03\xc7\xbe\x56\x40\x5b\x37\xc5\xfc\x3a\xe0\xd2\x9d\x52\x27\xce\xbf\xb3\x79\x71\xfa\x7e\xe7\x0a\x6e\xf3\x96\xab\x2f\x0c\xfd\xe0\x16\x3f\x82\x5b\x8e\xe8\xfa\x65\xd4\xdf\x36\xb5\x2c\x07\xd3\xb2\xba\x95\x7b\x4a\xc6\x63\x9e\x65\xe2\x6d\xdf\xb4\xcd\xec\x8a\x11\xdf\xbe\xce\xd9\xb9\xd2\x0e\x47\xc5\x64\xdb\x85\xbc\x7b\x76\x64\x7c\x14\xb4\xea\xac\xc7\xdf\x63\x7f\x3c\x5f\xd1\x4b\xb8\xdf\xde\x37\x81\xfb\x15\xf7\xd4\xe1\x90\x4f\xfa\xa1\x5a\x3d\x62\x46\x4e\x19\xb5\x6b\x1b\xab\xd8\xd9\xc3\x76\x42\x86\x40\x17\x8e\x42\x21\x61\x78\xb6\xf6\x0c\x1c\x08\x66\x8a\x64\xca\xf1\xdf\x7a\x92\x9d\x49\xf3\x46\x83\x9f\x09\xec\x6b\x8b\xf7\x2e\xf6\x74\x17\x1a\xd4\xe2\x39\xc9\x5e\x69\x0f\xae\x40\xa1\xb8\x7c\x22\xc5\xa8\xe4\xb6\x7f\x84\xa1\xdb\xa2\x01\x16\x2e\x84\x3c\xb3\xb6\x94\x65\xa7\xba\xa5\xeb\x52\x59\x5d\x26\xef\x23\xe7\xcc\x42\x3e\x80\xfa\xe2\xfc\x44\xb6\x9f\x9d\x64\xc7\xbd\xa1\x6a\x1f\xfb\x3e\x58\x98\xaa\xe5\x19\x39\x33\x30\xf4\x4a\xdf\xe5\xd9\xd6\x11\x33\x08\xdd\x5c\xca\x84\xfd\xd1\x42\xd7\xcc\xaa\x1c\x3c\x59\x75\x15\xde\xca\x6d\xba\x98\xdc\x77\xf2\x16\xfb\xb7\xc9\x9e\x35\xc0\x3d\x8d\x04\x35\x70\x58\xf6\xb9\x2f\x87\xf2\xa8\x19\x7f\x9c\x7e\x86\x95\x4e\xfe\xb7\x74\xf6\x52\xea\xe3\x38\x88\xfb\xd9\xc6\x9b\x5d\xcc\x79\x40\x8d\x15\xd8\xc7\xaf\xdf\xd4\xff\x22\x59\x5c\xf5\x74\xc9\xc9\x7c\xeb\x9c\x6d\x6f\x7c\xc4\xbf\x26\x4e\x99\x81\x7f\x75\xe5\x60\x6c\x3a\x85\xe7\xe7\x79\x45\x28\x7c\xa8\xdd\x26\x7d\xce\xb9\xfa\x83\x6a\x48\x4e\x8c\x55\xa4\xd5\x0a\xf0\x74\x66\x0c\x0e\x30\x91\xe4\x38\xb7\x7c\xfc\x1c\xa4\x77\x11\xea\x3b\x7b\x6d\x64\x33\x19\xa9\x60\xb5\x36\x92\x9e\x71\x2a\x10\x73\x3f\x1a\x32\x60\x65\x6c\xdc\x3f\x32\x51\x7a\x5e\x6a\x1a\x7c\xcd\x07\x41\xe3\x9f\x96\x13\x63\xa5\x06\x1f\x62\x15\x77\xaa\x95\x7b\x7c\xda\x97\xf4\xf5\xfe\xa1\xc5\x96\x48\x4b\x22\x9f\x30\x58\xbb\x90\x27\x77\x9e\x55\xfe\x5c\x7f\x81\x79\xb0\x97\xff\xe1\xd6\xad\x01\xcb\x21\xa0\xed\xcc\x75\xb8\x4a\xe4\xd1\x15\x69\x0f\x68\x58\xe1\x32\x68\x21\xc4\x85\x8b\x3b\x3b\xf1\x28\xc5\xe0\x1c\xfa\xc9\x48\x87\xc5\x4e\x16\x3e\x96\x82\x9c\x14\xf7\x3a\xaf\xfc\x70\x47\x17\x3c\x7e\x97\xf4\xfa\x36\x70\x9c\xe5\x02\x35\xdd\xe4\x0e\x11\x8e\x53\x1b\xec\x7d\xd4\x5a\x28\xd2\x02\x93\xd4\x05\x84\xd6\xce\x27\x24\x45\x9c\xbb\xdc\x34\xf3\x6a\xe2\x96\xbc\x0e\xba\x84\x6a\x20\x20\x58\x3c\xec\x80\xe5\xf3\x65\xc3\xac\x74\xe4\xd1\x7f\xbc\xb8\x62\x39\x3c\x68\x50\xd9\xd7\xfc\x2f\x59\x4c\x70\xde\xe4\x4d\x30\x6b\xa5\x2e\xf1\xca\x90\x8e\x87\xb3\xef\x3e\xae\x85\x80\x5e\x85\xdd\x82\x5c\x10\x19\x49\x17\x66\xa0\xbb\x10\x99\xa1\xbb\x6d\x4c\xc0\x4b\x4c\x7e\xd0\x6e\xe0\x44\x8b\xef\xf5\x2b\x02\x5f\xc3\xd8\x69\xf8\x76\xa2\x83\xc8\x72\xa0\x75\xd2\x63\xdb\xc0\xc3\xd0\x51\xe8\xa1\x52\xf3\x43\xb7\xf5\x10\xe6\x0e\x95\x2d\xbd\x2e\x4a\x59\xac\xd7\x0b\x18\x61\xb4\x8a\x59\xa9\xa8\xdd\x6d\x66\x48\x89\xe3\x0a\x78\x41\xf8\xea\x1d\x0a\xf7\x5a\x0f\x45\xf3\x6a\x7b\xbf\x03\x2b\x3d\xe4\xa7\xf0\x23\x8c\xd5\x5f\xf8\x03\x95\x79\x56\x5d\x3f\xc4\xfc\x70\x07\x67\x45\xe2\xfe\x5f\xae\x13\xfd\x53\x5f\x06\x48\x4f\xf3\x47\x74\xd5\xcd\xc9\x61\xdf\xae\x15\x5f\xcb\x09\xd1\xd6\x84\x36\x5e\xd2\x00\xd4\x69\xd0\xb6\xca\x94\xe5\x2e\xe5\xe9\x2a\x3a\x0e\x33\x14\xfd\xb8\xee\xe6\x86\xfb\x23\x66\xc4\xa0\xb8\x6b\x7c\xd6\x4b\x06\xb1\x67\x23\x3e\xa9\x99\x16\x60\x1e\x5c\x11\x65\x79\x29\xea\x04\x86\x0a\xd7\xca\x67\x53\x6f\x1e\xc4\x22\x3e\xee\x89\xdd\x09\xf8\x45\x47\x69\xaa\x77\x67\x28\x6d\x1b\xf0\xc7\xc6\x49\x62\xaf\xae\xa9\xb9\x78\x4d\xb5\x1e\x1d\xa2\xda\x72\xfc\xe9\x02\xb1\x40\x75\xd2\xbe\x18\x75\xa8\x8a\x09\x9e\x05\x18\xc5\xf2\xc1\x68\xe5\x1a\x4a\x13\xad\xed\xd4\xa9\xeb\xe4\xdd\x55\x00\x35\x7e\x4f\xbb\x24\x2b\xa0\x2d\x10\x73\xe9\x79\xce\x8d\x15\xdc\xda\xc5\x28\x7c\xf8\x0a\xac\x85\x70\x35\x04\x7c\x53\xa7\x57\x9e\x96\x9c\x40\xb7\x38\x00\x6c\x4c\x65\x8b\x3b\x58\xfb\xac\xb1\x2e\xcf\xb2\x51\x18\x87\xe9\x2f\xf7\x03\xc2\xbd\xcb\x4e\x0c\xfe\xb3\x2a\xfd\x5c\x0d\x69\x75\xb6\x69\x79\x72\xdb\xeb\x2f\xdc\x6a\xd7\x09\xdd\xff\xa9\x94\x77\xb3\x36\x78\x9c\xf5\x1f\xa8\x72\xc6\xe5\x80\xbd\xc7\x02\x08\xf6\xc9\x93\xbd\x2b\xaf\x38\xde\x13\xa7\x2b\xf0\x7b\x60\xcf\x78\x1b\x70\xeb\x62\xe7\x36\xc0\xd0\xd2\x82\x1b\xa3\xd4\x5f\xf1\xd6\x6f\x7c\xb1\x06\xa9\x55\x6b\xb5\xb9\xef\xc8\xa3\x96\x15\x87\xba\x59\x63\xad\x3d\x71\x28\x2d\xa8\xaa\xb6\x4d\xc5\x18\x00\xd6\x94\xd2\x6f\xe0\xa8\xdb\xa8\x58\x9f\xa9\x6b\xdf\x1e\x91\xde\xbf\x55\x7a\xeb\x7e\xe5\xb5\x5d\xb5\x78\x2c\x4c\xfc\x0a\xa7\x75\xe2\x11\x15\x76\xec\x27\x2f\xeb\x5b\x28\x24\xf4\xee\x0c\x1f\x7e\x2b\x8c\x61\x1c\x73\x1c\x6f\x3d\xf6\xa1\xf6\x9d\x24\x29\x0a\x49\x0b\x9b\xcf\x7a\x67\x90\xe4\xe8\xa4\x3b\xb8\x6f\x4f\x11\xe2\xd1\x4d\xb7\xa2\x7f\x49\x30\xf1\x59\xca\x3e\xcc\x47\xb6\x29\x14\xbe\xb7\x28\x47\x95\x98\x71\x41\x1f\xa5\x96\xb9\xb2\xc1\x9f\x13\x61\x2e\x92\x1e\xdf\x1e\xdf\xc8\xe7\x77\xb5\xcf\x12\xea\x31\xef\xeb\x2f\x3f\x76\xd5\x50\x31\xcd\x2d\xcf\x34\x61\xdf\x9a\x18\x21\xb8\x77\x2a\x33\xf8\x20\x1b\x67\x28\x7d\xe3\x14\x17\xc3\xb1\x29\x37\x3b\xd1\x7f\xbf\xe3\x01\xf2\x51\x63\xb3\x0c\x47\x6d\x09\x9c\x1e\x0c\xef\xe3\x27\x5f\xf6\xed\x15\x79\x0e\x8d\x2f\x29\x34\xc8\x48\x68\x7c\x3c\xd2\xe7\x17\xa6\x7e\xd9\xd4\xe7\x19\x91\x59\xe8\xdd\xea\xb9\xfb\xa1\xbf\x38\x41\x1c\x56\x20\x0c\x1d\xf8\x29\xe5\x49\x54\x8c\xc6\xb8\xdf\x3f\x01\x5f\x07\x7e\x94\xc3\x77\x0c\x0b\xb0\x85\xe9\x35\xec\xc4\x7d\x4f\x24\x56\x1c\x30\x02\xa5\xc0\x6d\x0e\x2f\x22\x8a\xa2\xcf\xa4\x5e\xc7\x66\x7f\xb2\xa2\x24\x83\xfe\x9b\x89\x46\xaf\xa9\x05\xb8\x8e\x17\xd8\xfd\x2e\xff\xa2\xc3\xc6\xe9\xa8\x17\x24\x45\x9c\x3d\x57\x8c\xf5\x3a\xf5\xfe\x4b\xfc\xc3\x33\x74\x93\x9f\x91\x71\x0c\xf7\x69\xae\x59\x4c\x47\xcf\x3e\x60\xe3\x3f\xb5\x4e\x4e\x9c\xb4\xf9\x86\x65\xb5\x03\x11\xec\xad\x8c\xfa\x0a\xb2\x6b\x4d\x1d\xd7\x1d\x4d\x87\x9f\xc6\x02\x3d\x7f\x6d\xbe\xd5\xed\x69\xc4\xda\xab\xb6\xd0\x66\x0f\x60\x3b\x08\x96\xee\x27\xea\xc7\x31\xd9\x98\xf2\x90\xf0\xb4\x09\x63\xed\x5f\x9f\x6d\x5a\x13\x6c\x03\xf4\xc9\xf3\x61\x0e\x90\x65\x29\x71\xe5\x68\x48\x6b\x4e\xc9\xa9\x32\x68\xb6\x8f\xbf\x60\x34\x9b\x66\x45\xd5\xf3\xfb\x4a\x1f\x37\x67\xd4\xa3\x80\xef\x2c\xfc\xb7\x89\xa0\x77\x4a\xfc\x9c\xdd\x5d\xef\xdb\x50\x0f\xb8\x7d\x2a\x7f\x65\xbe\xd5\x1c\x25\x29\xbe\x0d\x3a\xdc\x6c\xcc\xfe\xa2\x3f\x81\xed\x68\x7f\x7c\xf1\x81\xef\x37\x52\xff\x1c\x91\x10\x12\x9f\xdf\xf1\x8f\x14\x2c\xb0\x77\xe2\xbe\xa2\x69\x53\x6c\x22\xef\xac\x5e\xb9\xcb\xef\xdd\x01\x97\x46\x7a\x9b\xd6\xad\x23\xcf\x5f\x7f\x44\x2f\x7f\x9b\x73\x44\xf7\x97\x87\x68\x55\x37\x48\x8a\xfa\xd4\x83\x45\xf8\xdf\x5b\x09\xbe\xca\xe1\xe7\x77\xa6\x04\x1d\x47\x08\x96\x9e\x4a\xbe\xda\xe6\xbb\xed\x7e\xa6\x5f\x90\xf0\x44\x61\x56\xe5\x13\x8b\xd2\x8f\x96\xb7\xcd\x44\x97\xbd\x6e\x23\xf3\xef\x74\xab\x18\xf7\xf9\x1f\xa5\x67\xe5\xb6\xa7\xc3\x7b\x1f\xb0\xfa\xb0\xfd\xf0\xcb\x54\xac\x8e\x59\xe5\x9c\xb4\xf8\x20\x83\xa6\xba\x77\xeb\x55\xa6\x39\x4a\x13\xe1\x99\xec\x65\x94\xf7\x5d\xaf\x44\xc0\xc3\x52\xb1\x1e\x3d\xe3\xd6\xa0\x37\x15\x29\x97\x14\xf9\x49\x65\xb4\x74\x34\x25\x83\x93\x5f\x03\x20\x76\x40\x11\x98\x32\x16\x48\x71\x1f\x7c\x72\x13\x23\xd2\x74\xe9\x7d\x8d\x45\x8d\xf7\x65\xe5\x84\x9a\xb2\x6e\xd0\x8c\x63\xb7\xf4\xf7\x0b\x40\xac\x32\x07\x62\x4d\x74\x1f\xbe\x4b\x69\xe1\x57\x7e\xfe\x82\x2b\x3c\xf7\xc9\xf2\xca\x3d\x05\x0b\xda\x13\x8a\xf6\x93\xf5\x5c\xb3\x82\x1b\x5c\x19\x03\xc4\xf6\xb4\x38\xdd\x60\x12\xfe\xfb\x54\xaf\xa1\x7c\xc3\xfb\xce\xac\xc5\xa1\xd7\xca\xd2\xaa\xbd\xbe\xfa\x69\x61\x14\xd0\xb1\xc9\x4b\xd4\x67\x93\x7a\xbf\xd9\xf0\xbe\x3e\xd9\x91\x92\x34\x9f\xc1\xdc\x8a\xd9\xb3\x11\xa1\x0a\xe0\x58\x59\xad\xff\x6c\x5a\xc5\xde\x99\x5e\x3d\x60\x41\x2f\x26\xa5\x86\x8b\x1f\x27\xb8\x71\x57\xa9\xf0\x70\xa2\x6c\x9b\xc5\x20\xf5\x0c\xfb\x78\x2a\x42\x41\x78\xcc\x44\xa4\xb4\x8e\xdf\xa5\xce\xf4\x2f\x0e\xf9\x73\x17\x4e\x4b\xf3\x7f\xad\x31\xbe\x4c\xd3\xea\x67\xd2\x2c\x88\xfe\x9f\xa4\xa3\x69\xf8\xaa\xe2\xe5\xa7\x6f\xff\x5f\x00\x00\x00\xff\xff\xba\x79\x11\x67\x51\x31\x00\x00") func filesFaviconPngBytes() ([]byte, error) { return bindataRead( @@ -106,8 +106,8 @@ func filesFaviconPng() (*asset, error) { return nil, err } - info := bindataFileInfo{name: "files/favicon.png", size: 12881, mode: os.FileMode(0644), modTime: time.Unix(1587356420, 0)} - a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x97, 0x63, 0x6a, 0x6e, 0xcc, 0xe7, 0x5d, 0x3d, 0x1f, 0x1e, 0x41, 0xac, 0xdb, 0x44, 0xcb, 0x2b, 0xbe, 0x4d, 0xb4, 0xb0, 0x44, 0x82, 0xcd, 0x98, 0xb9, 0x3c, 0x63, 0xfe, 0x17, 0x6d, 0xc2, 0xb9}} + info := bindataFileInfo{name: "files/favicon.png", size: 12625, mode: os.FileMode(0644), modTime: time.Unix(1587356420, 0)} + a := &asset{bytes: bytes, info: info, digest: [32]uint8{0xe4, 0xbc, 0x84, 0x38, 0x69, 0x2f, 0x4a, 0xe7, 0x13, 0x77, 0x4c, 0x31, 0x76, 0xd3, 0x60, 0x97, 0xf9, 0xfb, 0x11, 0xcd, 0x30, 0x7b, 0x4d, 0xf1, 0x51, 0x34, 0x3b, 0x5, 0xab, 0xb6, 0xf1, 0xf2}} return a, nil } diff --git a/pkg/client/cli.go b/pkg/client/cli.go index a585c3fc5..ce292bd7a 100644 --- a/pkg/client/cli.go +++ b/pkg/client/cli.go @@ -38,6 +38,7 @@ func (c *Client) forceWriteWithExit(f string) error { c.Config.FileMode = logs.FileMode(rotatorr.FileMode) c.Config.Debug = false c.Config.Snapshot.Interval.Duration = mnd.HalfHour + c.Config.Services.Disabled = false configfile.ForceAllTmpl = true } diff --git a/pkg/client/client_other.go b/pkg/client/client_other.go index 7a8ce807a..1d86b264f 100644 --- a/pkg/client/client_other.go +++ b/pkg/client/client_other.go @@ -10,6 +10,7 @@ import ( ) func (c *Client) printUpdateMessage() {} +func (c *Client) setupMenus() {} // AutoWatchUpdate is not used on this OS. func (c *Client) AutoWatchUpdate() {} diff --git a/pkg/client/handlers.go b/pkg/client/handlers.go index 2fcec11fb..d186e0283 100644 --- a/pkg/client/handlers.go +++ b/pkg/client/handlers.go @@ -10,7 +10,6 @@ import ( "github.com/Notifiarr/notifiarr/pkg/bindata" "github.com/Notifiarr/notifiarr/pkg/notifiarr" - "github.com/Notifiarr/notifiarr/pkg/services" "github.com/Notifiarr/notifiarr/pkg/ui" "github.com/Notifiarr/notifiarr/pkg/update" "github.com/gorilla/mux" @@ -19,7 +18,7 @@ import ( // internalHandlers initializes "special" internal API paths. func (c *Client) internalHandlers() { - c.Config.HandleAPIpath("", "version", c.notifiarr.VersionHandler, "GET", "HEAD") + c.Config.HandleAPIpath("", "version", c.website.VersionHandler, "GET", "HEAD") c.Config.HandleAPIpath("", "trigger/{trigger:[0-9a-z-]+}", c.handleTrigger, "GET") c.Config.HandleAPIpath("", "trigger/{trigger:[0-9a-z-]+}/{content}", c.handleTrigger, "GET") @@ -30,12 +29,12 @@ func (c *Client) internalHandlers() { tokens := fmt.Sprintf("{token:%s|%s}", c.Config.Plex.Token, c.Config.Apps.APIKey) c.Config.Router.Handle("/plex", - http.HandlerFunc(c.notifiarr.PlexHandler)).Methods("POST").Queries("token", tokens) + http.HandlerFunc(c.website.PlexHandler)).Methods("POST").Queries("token", tokens) if c.Config.URLBase != "/" { // Allow plex to use the base url too. c.Config.Router.Handle(path.Join(c.Config.URLBase, "plex"), - http.HandlerFunc(c.notifiarr.PlexHandler)).Methods("POST").Queries("token", tokens) + http.HandlerFunc(c.website.PlexHandler)).Methods("POST").Queries("token", tokens) } } @@ -116,31 +115,25 @@ func (c *Client) handleTrigger(r *http.Request) (int, interface{}) { //nolint:cy trigger := mux.Vars(r)["trigger"] c.Debugf("Incoming API Trigger: %s", trigger) - const apiTrigger = "apitrigger" - switch trigger { case "cfsync": - c.notifiarr.Trigger.SyncCF(false) + c.website.Trigger.SyncCF(notifiarr.EventAPI) case "services": - if c.Config.Services.Disabled { - return http.StatusNotImplemented, "services not enabled" - } - - c.Config.Services.RunChecks(&services.Source{Name: apiTrigger, URL: notifiarr.ProdURL}) + c.Config.Services.RunChecks(notifiarr.EventAPI) case "sessions": if !c.Config.Plex.Configured() { return http.StatusNotImplemented, "sessions not enabled" } - c.notifiarr.Trigger.SendPlexSessions(apiTrigger) + c.website.Trigger.SendPlexSessions(notifiarr.EventAPI) case "stuckitems": - c.notifiarr.Trigger.SendFinishedQueueItems(c.notifiarr.BaseURL) + c.website.Trigger.SendStuckQueueItems(notifiarr.EventAPI) case "dashboard": - c.notifiarr.Trigger.GetState() + c.website.Trigger.SendDashboardState(notifiarr.EventAPI) case "snapshot": - c.notifiarr.Trigger.SendSnapshot(apiTrigger) + c.website.Trigger.SendSnapshot(notifiarr.EventAPI) case "gaps": - c.notifiarr.Trigger.SendGaps(apiTrigger) + c.website.Trigger.SendGaps(notifiarr.EventAPI) case "reload": c.sighup <- &update.Signal{Text: "reload http triggered"} case "notification": diff --git a/pkg/client/init.go b/pkg/client/init.go index 2a718efcb..33be24ce2 100644 --- a/pkg/client/init.go +++ b/pkg/client/init.go @@ -8,6 +8,7 @@ package client import ( "path" "strconv" + "strings" "github.com/Notifiarr/notifiarr/pkg/mnd" ) @@ -17,8 +18,14 @@ const disabled = "disabled" // PrintStartupInfo prints info about our startup config. // This runs once on startup, and again during reloads. func (c *Client) PrintStartupInfo() { + if hi, err := c.website.GetHostInfoUID(); err != nil { + c.Errorf("=> Unknown Host Info (this is bad): %v", err) + } else { + c.Printf("==> Unique ID: %s (%s)", hi.HostID, hi.Hostname) + } + c.Printf("==> %s <==", mnd.HelpLink) - c.Print("==> Startup Settings <==") + c.Printf("==> %s Startup Settings <==", strings.Title(strings.ToLower(c.Config.Mode))) c.printLidarr() c.printRadarr() c.printReadarr() @@ -26,6 +33,7 @@ func (c *Client) PrintStartupInfo() { c.printDeluge() c.printQbit() c.printPlex() + c.printTautulli() c.Printf(" => Timeout: %v, Quiet: %v", c.Config.Timeout, c.Config.Quiet) c.Printf(" => Trusted Upstream Networks: %v", c.Config.Allow) @@ -241,3 +249,16 @@ func (c *Client) printQbit() { i+1, f.Config.URL, f.User, f.Pass != "", f.Timeout, f.VerifySSL) } } + +// printTautulli is called on startup to print info about configured Tautulli instance(s). +func (c *Client) printTautulli() { + switch t := c.Config.Apps.Tautulli; { + case t == nil, t.URL == "": + c.Printf(" => Tautulli Config (enables name map): 0 servers") + case t.Name != "": + c.Printf(" => Tautulli Config (enables name map): 1 server: %s, timeout: %v, check interval: %v, name: %s", + t.URL, t.Timeout, t.Interval, t.Name) + default: + c.Printf(" => Tautulli Config (enables name map): 1 server: %s, timeout: %v", t.URL, t.Timeout) + } +} diff --git a/pkg/client/start.go b/pkg/client/start.go index c7545a220..2cb266ab8 100644 --- a/pkg/client/start.go +++ b/pkg/client/start.go @@ -11,32 +11,29 @@ import ( "fmt" "net/http" "os" + "strings" "time" - "github.com/Notifiarr/notifiarr/pkg/apps" "github.com/Notifiarr/notifiarr/pkg/configfile" "github.com/Notifiarr/notifiarr/pkg/logs" "github.com/Notifiarr/notifiarr/pkg/mnd" "github.com/Notifiarr/notifiarr/pkg/notifiarr" - "github.com/Notifiarr/notifiarr/pkg/services" - "github.com/Notifiarr/notifiarr/pkg/snapshot" "github.com/Notifiarr/notifiarr/pkg/ui" "github.com/Notifiarr/notifiarr/pkg/update" flag "github.com/spf13/pflag" - "golift.io/cnfg" "golift.io/version" ) // Client stores all the running data. type Client struct { *logs.Logger - Flags *configfile.Flags - Config *configfile.Config - server *http.Server - sigkil chan os.Signal - sighup chan os.Signal - menu map[string]ui.MenuItem - notifiarr *notifiarr.Config + Flags *configfile.Flags + Config *configfile.Config + server *http.Server + sigkil chan os.Signal + sighup chan os.Signal + menu map[string]ui.MenuItem + website *notifiarr.Config } // Errors returned by this package. @@ -53,27 +50,8 @@ func NewDefaults() *Client { sighup: make(chan os.Signal, 1), menu: make(map[string]ui.MenuItem), Logger: logger, - Config: &configfile.Config{ - Apps: &apps.Apps{ - URLBase: "/", - DebugLog: logger.DebugLog, - ErrorLog: logger.ErrorLog, - }, - Services: &services.Config{ - Interval: cnfg.Duration{Duration: services.DefaultSendInterval}, - Parallel: 1, - Logger: logger, - }, - BindAddr: mnd.DefaultBindAddr, - Snapshot: &snapshot.Config{ - Timeout: cnfg.Duration{Duration: snapshot.DefaultTimeout}, - }, - LogConfig: &logs.LogConfig{ - LogFiles: mnd.DefaultLogFiles, - LogFileMb: mnd.DefaultLogFileMb, - }, - Timeout: cnfg.Duration{Duration: mnd.DefaultTimeout}, - }, Flags: &configfile.Flags{ + Config: configfile.NewConfig(logger), + Flags: &configfile.Flags{ FlagSet: flag.NewFlagSet(mnd.DefaultName, flag.ExitOnError), ConfigFile: os.Getenv(mnd.DefaultEnvPrefix + "_CONFIG_FILE"), EnvPrefix: mnd.DefaultEnvPrefix, @@ -94,29 +72,50 @@ func Start() error { return printProcessList() case c.Flags.Curl != "": // curl a URL and exit. return curlURL(c.Flags.Curl, c.Flags.Headers) + default: + return c.start() } +} +func (c *Client) start() error { msg, newCon, err := c.loadConfiguration() switch { + case c.Flags.Write != "" && (err == nil || strings.Contains(err.Error(), "ip:port")): + c.Printf("==> %s", msg) + return c.forceWriteWithExit(c.Flags.Write) case err != nil: return fmt.Errorf("%s: %w", msg, err) case c.Flags.Restart: return nil - case c.Flags.Write != "": - c.Printf("==> %s", msg) - return c.forceWriteWithExit(c.Flags.Write) case c.Config.APIKey == "": return fmt.Errorf("%s: %w %s_API_KEY", msg, ErrNilAPIKey, c.Flags.EnvPrefix) - default: - c.Logger.SetupLogging(c.Config.LogConfig) - c.Printf("%s v%s-%s Starting! [PID: %v] %v", - c.Flags.Name(), version.Version, version.Revision, os.Getpid(), time.Now()) - c.Printf("==> %s", msg) - c.printUpdateMessage() } - return c.start(newCon) + c.Logger.SetupLogging(c.Config.LogConfig) + c.Printf("%s v%s-%s Starting! [PID: %v] %v", + c.Flags.Name(), version.Version, version.Revision, os.Getpid(), time.Now()) + c.Printf("==> %s", msg) + c.printUpdateMessage() + c.configureServices(notifiarr.EventStart) + + if newCon { + _, _ = c.Config.Write(c.Flags.ConfigFile) + _ = ui.OpenFile(c.Flags.ConfigFile) + _, _ = ui.Warning(mnd.Title, "A new configuration file was created @ "+ + c.Flags.ConfigFile+" - it should open in a text editor. "+ + "Please edit the file and reload this application using the tray menu.") + } else if c.Config.AutoUpdate != "" { + go c.AutoWatchUpdate() // do not run updater if there's a brand new config file. + } + + if ui.HasGUI() { + // This starts the web server and calls os.Exit() when done. + c.startTray() + return nil + } + + return c.Exit() } // loadConfiguration brings in, and sometimes creates, the initial running confguration. @@ -135,7 +134,7 @@ func (c *Client) loadConfiguration() (msg string, newCon bool, err error) { } // Parse the config file and environment variables. - c.notifiarr, err = c.Config.Get(c.Flags.ConfigFile, c.Flags.EnvPrefix, c.Logger) + c.website, err = c.Config.Get(c.Flags.ConfigFile, c.Flags.EnvPrefix) if err != nil { return msg, newCon, fmt.Errorf("getting config: %w", err) } @@ -143,36 +142,74 @@ func (c *Client) loadConfiguration() (msg string, newCon bool, err error) { return msg, newCon, nil } -// start runs from Start() after the configuration is loaded. -func (c *Client) start(newCon bool) error { - if err := c.configureServices(); err != nil { - return err - } else if ci, err := c.notifiarr.GetClientInfo(); err != nil { - c.Printf("==> [WARNING] API Key may be invalid: %v: %s", err, ci) - } else if ci != nil { - c.Printf("==> %s", ci) +// Load configuration from the website. +func (c *Client) loadSiteConfig(source notifiarr.EventType) { + ci, err := c.website.GetClientInfo(source) + if err != nil || ci == nil { + c.Printf("==> [WARNING] API Key may be invalid: %v, info: %s", err, ci) + return } - if newCon { - _, _ = c.Config.Write(c.Flags.ConfigFile) - _ = ui.OpenFile(c.Flags.ConfigFile) - _, _ = ui.Warning(mnd.Title, "A new configuration file was created @ "+ - c.Flags.ConfigFile+" - it should open in a text editor. "+ - "Please edit the file and reload this application using the tray menu.") - } else if c.Config.AutoUpdate != "" { - go c.AutoWatchUpdate() // do not run updater if there's a brand new config file. + c.Printf("==> %s", ci) + + if ci.Actions.Snapshot != nil { + c.website.Snap, c.Config.Snapshot = ci.Actions.Snapshot, ci.Actions.Snapshot } - if ui.HasGUI() { - // This starts the web server and calls os.Exit() when done. - c.startTray() + if ci.Actions.Plex != nil && c.Config.Plex != nil { + c.Config.Plex.Interval = ci.Actions.Plex.Interval + c.Config.Plex.Cooldown = ci.Actions.Plex.Cooldown + c.Config.Plex.MoviesPC = ci.Actions.Plex.MoviesPC + c.Config.Plex.SeriesPC = ci.Actions.Plex.SeriesPC + c.Config.Plex.NoActivity = ci.Actions.Plex.NoActivity + c.Config.Plex.Delay = ci.Actions.Plex.Delay } - return c.Exit() + c.loadSiteAppsConfig(ci) +} + +func (c *Client) loadSiteAppsConfig(ci *notifiarr.ClientInfo) { //nolint:cyclop + for _, app := range ci.Actions.Apps.Lidarr { + if app.Instance < 1 || app.Instance > len(c.Config.Apps.Lidarr) { + c.Errorf("Website provided configuration for missing Lidarr app: %d:%s", app.Instance, app.Name) + continue + } + + c.Config.Apps.Lidarr[app.Instance-1].StuckItem = app.Stuck + } + + for _, app := range ci.Actions.Apps.Radarr { + if app.Instance < 1 || app.Instance > len(c.Config.Apps.Radarr) { + c.Errorf("Website provided configuration for missing Radarr app: %d:%s", app.Instance, app.Name) + continue + } + + c.Config.Apps.Radarr[app.Instance-1].StuckItem = app.Stuck + } + + for _, app := range ci.Actions.Apps.Readarr { + if app.Instance < 1 || app.Instance > len(c.Config.Apps.Readarr) { + c.Errorf("Website provided configuration for missing Readarr app: %d:%s", app.Instance, app.Name) + continue + } + + c.Config.Apps.Readarr[app.Instance-1].StuckItem = app.Stuck + } + + for _, app := range ci.Actions.Apps.Sonarr { + if app.Instance < 1 || app.Instance > len(c.Config.Apps.Sonarr) { + c.Errorf("Website provided configuration for missing Sonarr app: %d:%s", app.Instance, app.Name) + continue + } + + c.Config.Apps.Sonarr[app.Instance-1].StuckItem = app.Stuck + } } // configureServices is called on startup and on reload, so be careful what goes in here. -func (c *Client) configureServices() error { +func (c *Client) configureServices(source notifiarr.EventType) { + c.loadSiteConfig(source) + if c.Config.Plex.Configured() { if info, err := c.Config.Plex.GetInfo(); err != nil { c.Config.Plex.Name = "" @@ -182,23 +219,11 @@ func (c *Client) configureServices() error { } } + c.website.Sighup = c.sighup c.Config.Snapshot.Validate() c.PrintStartupInfo() - c.notifiarr.Start(c.Config.Mode) - - // Make sure the port is not in use before starting the web server. - addr, err := CheckPort(c.Config.BindAddr) - if err != nil { - return err - } - // Reset this (CheckPort cleans it up too). - c.Config.BindAddr = addr - - if err := c.Config.Services.Start(c.Config.Service); err != nil { - return fmt.Errorf("service checks: %w", err) - } - - return nil + c.website.Start() + c.Config.Services.Start() } // Exit stops the web server and logs our exit messages. Start() calls this. @@ -220,6 +245,7 @@ func (c *Client) Exit() error { } } +// This is called from at least two different exit points. func (c *Client) exit() error { if c.server == nil { return nil @@ -242,7 +268,7 @@ func (c *Client) exit() error { // Also closes and re-opens all log files. Any errors cause the application to exit. func (c *Client) reloadConfiguration(source string) error { c.Print("==> Reloading Configuration: " + source) - c.notifiarr.Stop() + c.website.Stop(notifiarr.EventReload) c.Config.Services.Stop() err := c.StopWebServer() @@ -252,7 +278,10 @@ func (c *Client) reloadConfiguration(source string) error { defer c.StartWebServer() } - c.notifiarr, err = c.Config.Get(c.Flags.ConfigFile, c.Flags.EnvPrefix, c.Logger) + // start over. + c.Config = configfile.NewConfig(c.Logger) + + c.website, err = c.Config.Get(c.Flags.ConfigFile, c.Flags.EnvPrefix) if err != nil { return fmt.Errorf("getting configuration: %w", err) } @@ -262,15 +291,11 @@ func (c *Client) reloadConfiguration(source string) error { } c.Logger.SetupLogging(c.Config.LogConfig) - - if err := c.configureServices(); err != nil { - return err - } - + c.configureServices(notifiarr.EventReload) + c.setupMenus() c.Print("==> Configuration Reloaded! Config File:", c.Flags.ConfigFile) - err = ui.Notify("Configuration Reloaded!") - if err != nil { + if err = ui.Notify("Configuration Reloaded! Config File: %s", c.Flags.ConfigFile); err != nil { c.Error("Creating Toast Notification:", err) } diff --git a/pkg/client/tray.go b/pkg/client/tray.go index b49a73e42..0722e3a60 100644 --- a/pkg/client/tray.go +++ b/pkg/client/tray.go @@ -4,12 +4,14 @@ package client import ( + "fmt" "os" + "reflect" + "strings" "github.com/Notifiarr/notifiarr/pkg/bindata" "github.com/Notifiarr/notifiarr/pkg/mnd" "github.com/Notifiarr/notifiarr/pkg/notifiarr" - "github.com/Notifiarr/notifiarr/pkg/services" "github.com/Notifiarr/notifiarr/pkg/ui" "github.com/getlantern/systray" "golift.io/version" @@ -28,8 +30,10 @@ func (c *Client) startTray() { systray.SetTooltip(c.Flags.Name() + " v" + version.Version) c.makeChannels() // make these before starting the web server. c.makeMoreChannels() - c.setupChannels(c.watchKillerChannels, c.watchNotifiarrMenu, - c.watchLogsChannels, c.watchConfigChannels, c.watchGuiChannels) + c.setupChannels(c.watchKillerChannels, c.watchNotifiarrMenu, c.watchLogsChannels, + c.watchConfigChannels, c.watchGuiChannels, c.watchTimerChannels, c.watchTopChannels) + + c.setupMenus() // This starts the web server, and waits for reload/exit signals. if err := c.Exit(); err != nil { @@ -47,6 +51,55 @@ func (c *Client) startTray() { }) } +func (c *Client) setupMenus() { + if !ui.HasGUI() { + return + } + + if c.Config.LogConfig.DebugLog == "" { + c.menu["debug_logs"].Hide() + c.menu["debug_logs2"].Hide() + } else { + c.menu["debug_logs"].Show() + c.menu["debug_logs2"].Show() + } + + if !c.Config.Debug { + c.menu["debug"].Hide() + c.menu["debug_logs"].Hide() + c.menu["debug_logs2"].Hide() + } else { + c.menu["debug"].Show() + c.menu["debug_logs"].Show() + c.menu["debug_logs2"].Show() + } + + if c.Config.Services.LogFile == "" { + c.menu["logs_svcs"].Hide() + } else { + c.menu["logs_svcs"].Show() + } + + if !c.Config.Services.Disabled { + c.menu["svcs"].Check() + } else { + c.menu["svcs"].Uncheck() + } + + if ci, err := c.website.GetClientInfo(notifiarr.EventStart); err == nil { + if ci.IsSub() { + c.menu["sub"].SetTitle("Subscriber \u2764\ufe0f") + c.menu["sub"].Check() + c.menu["sub"].Disable() + c.menu["sub"].SetTooltip("THANK YOU for supporting the project!") + } else if ci.IsPatron() { + c.menu["sub"].SetTitle("Patron \U0001f9e1") + c.menu["sub"].SetTooltip("THANK YOU for supporting the project!") + c.menu["sub"].Check() + } + } +} + // setupChannels runs the channel watcher loops in go routines with a panic catcher. func (c *Client) setupChannels(funcs ...func()) { for _, f := range funcs { @@ -65,7 +118,7 @@ func (c *Client) makeChannels() { c.menu["view"] = ui.WrapMenu(conf.AddSubMenuItem("View", "show configuration")) c.menu["edit"] = ui.WrapMenu(conf.AddSubMenuItem("Edit", "edit configuration")) c.menu["write"] = ui.WrapMenu(conf.AddSubMenuItem("Write", "write config file")) - c.menu["key"] = ui.WrapMenu(conf.AddSubMenuItem("API Key", "set API Key")) + c.menu["svcs"] = ui.WrapMenu(conf.AddSubMenuItem("Services", "toggle service checks routine")) c.menu["load"] = ui.WrapMenu(conf.AddSubMenuItem("Reload", "reload configuration")) link := systray.AddMenuItem("Links", "external resources") @@ -73,7 +126,8 @@ func (c *Client) makeChannels() { c.menu["info"] = ui.WrapMenu(link.AddSubMenuItem(c.Flags.Name(), version.Print(c.Flags.Name()))) c.menu["info"].Disable() c.menu["hp"] = ui.WrapMenu(link.AddSubMenuItem("Notifiarr.com", "open Notifiarr.com")) - c.menu["wiki"] = ui.WrapMenu(link.AddSubMenuItem("Notifiarr Wiki", "open Notifiarr wiki")) + c.menu["wiki"] = ui.WrapMenu(link.AddSubMenuItem("Notifiarr.Wiki", "open Notifiarr wiki")) + c.menu["trash"] = ui.WrapMenu(link.AddSubMenuItem("TRaSH Guide", "open TRaSH wiki for Notifiarr")) c.menu["disc1"] = ui.WrapMenu(link.AddSubMenuItem("Notifiarr Discord", "open Notifiarr discord server")) c.menu["disc2"] = ui.WrapMenu(link.AddSubMenuItem("Go Lift Discord", "open Go Lift discord server")) c.menu["gh"] = ui.WrapMenu(link.AddSubMenuItem("GitHub Project", c.Flags.Name()+" on GitHub")) @@ -82,12 +136,9 @@ func (c *Client) makeChannels() { c.menu["logs"] = ui.WrapMenu(logs) c.menu["logs_view"] = ui.WrapMenu(logs.AddSubMenuItem("View", "view the application log")) c.menu["logs_http"] = ui.WrapMenu(logs.AddSubMenuItem("HTTP", "view the HTTP log")) + c.menu["debug_logs2"] = ui.WrapMenu(logs.AddSubMenuItem("Debug", "view the Debug log")) c.menu["logs_svcs"] = ui.WrapMenu(logs.AddSubMenuItem("Services", "view the Services log")) c.menu["logs_rotate"] = ui.WrapMenu(logs.AddSubMenuItem("Rotate", "rotate both log files")) - - if c.Config.Services.LogFile == "" { - c.menu["logs_svcs"].Hide() - } } // makeMoreChannels makes the Notifiarr menu and Debug menu items. @@ -95,44 +146,80 @@ func (c *Client) makeChannels() { func (c *Client) makeMoreChannels() { data := systray.AddMenuItem("Notifiarr", "plex sessions, system snapshots, service checks") c.menu["data"] = ui.WrapMenu(data) + c.menu["gaps"] = ui.WrapMenu(data.AddSubMenuItem("Send Radarr Gaps", "[premium feature] trigger radarr collections gaps")) c.menu["sync_cf"] = ui.WrapMenu(data.AddSubMenuItem("Sync Custom Formats", "[premium feature] trigger custom format sync")) - c.menu["snap_log"] = ui.WrapMenu(data.AddSubMenuItem("Log Full Snapshot", "write snapshot data to log file")) - c.menu["svcs_log"] = ui.WrapMenu(data.AddSubMenuItem("Log Service Checks", "check all services and log results")) - c.menu["svcs_prod"] = ui.WrapMenu(data.AddSubMenuItem("Check Services", "check all services and send results to notifiarr")) - c.menu["plex_prod"] = ui.WrapMenu(data.AddSubMenuItem("Plex Sessions", "send plex sessions to notifiarr")) - c.menu["snap_prod"] = ui.WrapMenu(data.AddSubMenuItem("System Snapshot", "send system snapshot to notifiarr")) - c.menu["svcs_test"] = ui.WrapMenu(data.AddSubMenuItem("Test Service Checks", "send all service check results to test endpoint")) - c.menu["plex_test"] = ui.WrapMenu(data.AddSubMenuItem("Test Plex Sessions", "send plex sessions to notifiarr test endpoint")) - c.menu["snap_test"] = ui.WrapMenu(data.AddSubMenuItem("Test System Snapshot", "send system snapshot to notifiarr test endpoint")) - c.menu["plex_dev"] = ui.WrapMenu(data.AddSubMenuItem("Dev Plex Sessions", "send plex sessions to notifiarr dev endpoint")) - c.menu["snap_dev"] = ui.WrapMenu(data.AddSubMenuItem("Dev System Snapshot", "send system snapshot to notifiarr dev endpoint")) - c.menu["app_ques"] = ui.WrapMenu(data.AddSubMenuItem("Stuck Items Check", "check app queues for stuck items and send to notifiarr")) - c.menu["app_ques_dev"] = ui.WrapMenu(data.AddSubMenuItem("Stuck Items Check (Dev)", "check app queues for stuck items and send to notifiarr dev")) + c.menu["svcs_prod"] = ui.WrapMenu(data.AddSubMenuItem("Check and Send Services", "check all services and send results to notifiarr")) + c.menu["plex_prod"] = ui.WrapMenu(data.AddSubMenuItem("Send Plex Sessions", "send plex sessions to notifiarr")) + c.menu["snap_prod"] = ui.WrapMenu(data.AddSubMenuItem("Send System Snapshot", "send system snapshot to notifiarr")) + c.menu["app_ques"] = ui.WrapMenu(data.AddSubMenuItem("Stuck Queue Items Check", "check app queues for stuck items and send to notifiarr")) c.menu["send_dash"] = ui.WrapMenu(data.AddSubMenuItem("Send Dashboard States", "collect and send all application states for a dashboard update")) + if ci, err := c.website.GetClientInfo(notifiarr.EventStart); err == nil { + ui.WrapMenu(data.AddSubMenuItem("- Custom Timers -", "")).Disable() + + for _, t := range ci.Actions.Custom { + desc := "this is a dynamic custom timer" + if t.Desc != "" { + desc = t.Desc + } + + c.menu["timer"+t.Name] = ui.WrapMenu(data.AddSubMenuItem(t.Name, + fmt.Sprintf("%s; config: interval: %s, path: %s", desc, t.Interval, t.URI))) + } + } + debug := systray.AddMenuItem("Debug", "Debug Menu") c.menu["debug"] = ui.WrapMenu(debug) - c.menu["debug_logs"] = ui.WrapMenu(debug.AddSubMenuItem("View Log", "view the Debug log")) - // debug.AddSeparator() // not exist: https://github.com/getlantern/systray/issues/170 - ui.WrapMenu(debug.AddSubMenuItem("__________", "")).Disable() // fake separator. - c.menu["debug_panic"] = ui.WrapMenu(debug.AddSubMenuItem("Panic", "cause an application panic (crash)")) + c.menu["mode"] = ui.WrapMenu(debug.AddSubMenuItem("Mode: "+strings.Title(c.Config.Mode), "toggle application mode")) + c.menu["debug_logs"] = ui.WrapMenu(debug.AddSubMenuItem("View Debug Log", "view the Debug log")) + c.menu["svcs_log"] = ui.WrapMenu(debug.AddSubMenuItem("Log Service Checks", "check all services and log results")) - if c.Config.LogConfig.DebugLog == "" { - c.menu["debug_logs"].Hide() + ui.WrapMenu(debug.AddSubMenuItem("- Danger Zone -", "")).Disable() + c.menu["debug_panic"] = ui.WrapMenu(debug.AddSubMenuItem("Application Panic", "cause an application panic (crash)")) + c.menu["update"] = ui.WrapMenu(systray.AddMenuItem("Update", "check GitHub for updated version")) + c.menu["sub"] = ui.WrapMenu(systray.AddMenuItem("Subscribe", "subscribe for premium features")) + c.menu["exit"] = ui.WrapMenu(systray.AddMenuItem("Quit", "exit "+c.Flags.Name())) +} + +// Listen to the top-menu-item channels so they don't back up with junk. +func (c *Client) watchTopChannels() { + for { + select { + case <-c.menu["conf"].Clicked(): // unused, top menu. + case <-c.menu["link"].Clicked(): // unused, top menu. + case <-c.menu["logs"].Clicked(): // unused, top menu. + case <-c.menu["data"].Clicked(): // unused, top menu. + case <-c.menu["debug"].Clicked(): // unused, top menu. + } } +} - if !c.Config.Debug { - c.menu["svcs_test"].Hide() - c.menu["plex_test"].Hide() - c.menu["snap_test"].Hide() - c.menu["plex_dev"].Hide() - c.menu["snap_dev"].Hide() - c.menu["app_ques_dev"].Hide() - c.menu["debug"].Hide() +// dynamic menu items with reflection, anyone? +func (c *Client) watchTimerChannels() { + ci, err := c.website.GetClientInfo(notifiarr.EventStart) + if err != nil || len(ci.Actions.Custom) == 0 { + return } - c.menu["update"] = ui.WrapMenu(systray.AddMenuItem("Update", "Check GitHub for Update")) - c.menu["exit"] = ui.WrapMenu(systray.AddMenuItem("Quit", "Exit "+c.Flags.Name())) + cases := make([]reflect.SelectCase, len(ci.Actions.Custom)) + for i, t := range ci.Actions.Custom { + cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(c.menu["timer"+t.Name].Clicked())} + } + + for { + index, _, ok := reflect.Select(cases) + if !ok { + // Channel cases[index] has been closed, remove it. + cases = append(cases[:index], cases[index+1:]...) + if len(cases) < 1 { + return + } + + continue + } + + ci.Actions.Custom[index].Run(notifiarr.EventUser) + } } func (c *Client) watchKillerChannels() { @@ -141,16 +228,10 @@ func (c *Client) watchKillerChannels() { for { select { case <-c.menu["exit"].Clicked(): - c.Errorf("Need help? %s\n=====> Exiting! User Requested", mnd.HelpLink) + c.Printf("Need help? %s\n=====> Exiting! User Requested", mnd.HelpLink) return - case <-c.menu["debug"].Clicked(): - // turn on and off debug? - // u.menu["debug"].Check() case <-c.menu["debug_panic"].Clicked(): c.menuPanic() - case <-c.menu["debug_logs"].Clicked(): - go ui.OpenLog(c.Config.LogConfig.DebugLog) // nolint:errcheck - c.Print("User Viewing Debug File:", c.Config.LogConfig.DebugLog) case <-c.menu["load"].Clicked(): if err := c.reloadConfiguration("User Requested"); err != nil { c.Errorf("Need help? %s\n=====> Exiting! Reloading Configuration: %v", mnd.HelpLink, err) @@ -171,11 +252,15 @@ func (c *Client) watchGuiChannels() { case <-c.menu["hp"].Clicked(): go ui.OpenURL("https://notifiarr.com/") case <-c.menu["wiki"].Clicked(): + go ui.OpenURL("https://notifiarr.wiki/") + case <-c.menu["trash"].Clicked(): go ui.OpenURL("https://trash-guides.info/Notifiarr/Quick-Start/") case <-c.menu["disc1"].Clicked(): go ui.OpenURL("https://notifiarr.com/discord") case <-c.menu["disc2"].Clicked(): go ui.OpenURL("https://golift.io/discord") + case <-c.menu["sub"].Clicked(): + go ui.OpenURL("https://www.buymeacoffee.com/nitsua") } } } @@ -188,11 +273,29 @@ func (c *Client) watchConfigChannels() { go ui.Info(mnd.Title+": Configuration", c.displayConfig()) case <-c.menu["edit"].Clicked(): go ui.OpenFile(c.Flags.ConfigFile) - c.Print("User Editing Config File:", c.Flags.ConfigFile) + c.Print("user requested] Editing Config File:", c.Flags.ConfigFile) case <-c.menu["write"].Clicked(): go c.writeConfigFile() - case <-c.menu["key"].Clicked(): - c.changeKey() + case <-c.menu["mode"].Clicked(): + if c.Config.Mode == notifiarr.ModeDev { + c.Config.Mode = c.website.Setup(notifiarr.ModeProd) + } else { + c.Config.Mode = c.website.Setup(notifiarr.ModeDev) + } + + c.menu["mode"].SetTitle("Mode: " + strings.Title(c.Config.Mode)) + c.Printf("[user requested] Application mode changed to %s!", strings.Title(c.Config.Mode)) + ui.Notify("Application mode changed to %s!", strings.Title(c.Config.Mode)) + case <-c.menu["svcs"].Clicked(): + if c.menu["svcs"].Checked() { + c.menu["svcs"].Uncheck() + c.Config.Services.Stop() + ui.Notify("Stopped checking services!") + } else { + c.menu["svcs"].Check() + c.Config.Services.Start() + ui.Notify("Service checks started!") + } } } } @@ -203,72 +306,51 @@ func (c *Client) watchLogsChannels() { select { case <-c.menu["logs_view"].Clicked(): go ui.OpenLog(c.Config.LogFile) - c.Print("User Viewing Log File:", c.Config.LogFile) + c.Print("[user requested] Viewing App Log File:", c.Config.LogFile) case <-c.menu["logs_http"].Clicked(): go ui.OpenLog(c.Config.HTTPLog) - c.Print("User Viewing Log File:", c.Config.HTTPLog) + c.Print("[user requested] Viewing HTTP Log File:", c.Config.HTTPLog) case <-c.menu["logs_svcs"].Clicked(): go ui.OpenLog(c.Config.Services.LogFile) - c.Print("User Viewing Log File:", c.Config.Services.LogFile) + c.Print("[user requested] Viewing Services Log File:", c.Config.Services.LogFile) + case <-c.menu["debug_logs"].Clicked(): + go ui.OpenLog(c.Config.LogConfig.DebugLog) + c.Print("[user requested] Viewing Debug File:", c.Config.LogConfig.DebugLog) + case <-c.menu["debug_logs2"].Clicked(): + go ui.OpenLog(c.Config.LogConfig.DebugLog) + c.Print("[user requested] Viewing Debug File:", c.Config.LogConfig.DebugLog) case <-c.menu["logs_rotate"].Clicked(): c.rotateLogs() case <-c.menu["update"].Clicked(): - c.checkForUpdate() + go c.checkForUpdate() } } } -//nolint:errcheck,cyclop +//nolint:errcheck func (c *Client) watchNotifiarrMenu() { for { select { + case <-c.menu["gaps"].Clicked(): + c.website.Trigger.SendGaps(notifiarr.EventUser) case <-c.menu["sync_cf"].Clicked(): - ui.Notify("Starting custom format and quality profiles sync") - c.Printf("[user requested] Triggering Custom Formats and Quality Profiles Sync for Radarr and Sonarr.") - c.notifiarr.Trigger.SyncCF(false) - case <-c.menu["snap_log"].Clicked(): - ui.Notify("Logging local system snapshot") - c.logSnaps() + c.website.Trigger.SyncCF(notifiarr.EventUser) case <-c.menu["svcs_log"].Clicked(): - c.Printf("[user requested] Checking services and logging results.") - ui.Notify("Running and logging %d Service Checks", len(c.Config.Service)) - c.Config.Services.RunChecks(&services.Source{Name: "log", URL: ""}) + c.Print("[user requested] Checking services and logging results.") + ui.Notify("Running and logging %d Service Checks.", len(c.Config.Service)) + c.Config.Services.RunChecks("log") case <-c.menu["svcs_prod"].Clicked(): - c.Printf("[user requested] Checking services and sending results to Notifiarr.") - ui.Notify("Running and sending %d Service Checks", len(c.Config.Service)) - c.Config.Services.RunChecks(&services.Source{Name: "user", URL: notifiarr.ProdURL}) - case <-c.menu["svcs_test"].Clicked(): - c.Printf("[user requested] Checking services and sending results to Notifiarr Test.") - ui.Notify("Running and sending %d Service Checks (test)", len(c.Config.Service)) - c.Config.Services.RunChecks(&services.Source{Name: "user", URL: notifiarr.TestURL}) - case <-c.menu["plex_test"].Clicked(): - ui.Notify("Gathering and sending Plex sessions (test)") - c.sendPlexSessions(notifiarr.TestURL) - case <-c.menu["snap_test"].Clicked(): - ui.Notify("Gathering and sending system snapshot (test)") - c.sendSystemSnapshot(notifiarr.TestURL) - case <-c.menu["plex_dev"].Clicked(): - ui.Notify("Gathering and sending Plex sessions (dev)") - c.sendPlexSessions(notifiarr.DevURL) - case <-c.menu["snap_dev"].Clicked(): - ui.Notify("Gathering and sending system snapshot (dev)") - c.sendSystemSnapshot(notifiarr.DevURL) + c.Print("[user requested] Checking services and sending results to Notifiarr.") + ui.Notify("Running and sending %d Service Checks.", len(c.Config.Service)) + c.Config.Services.RunChecks(notifiarr.EventUser) case <-c.menu["app_ques"].Clicked(): - ui.Notify("Sending finished, possibly stuck, queue items") - c.notifiarr.Trigger.SendFinishedQueueItems(notifiarr.BaseURL) - case <-c.menu["app_ques_dev"].Clicked(): - ui.Notify("Sending finished, possibly stuck, queue items (dev)") - c.notifiarr.Trigger.SendFinishedQueueItems(notifiarr.DevBaseURL) + c.website.Trigger.SendStuckQueueItems(notifiarr.EventUser) case <-c.menu["plex_prod"].Clicked(): - ui.Notify("Gathering and sending Plex sessions") - c.sendPlexSessions(notifiarr.ProdURL) + c.website.Trigger.SendPlexSessions(notifiarr.EventUser) case <-c.menu["snap_prod"].Clicked(): - ui.Notify("Gathering and sending system snapshot") - c.sendSystemSnapshot(notifiarr.ProdURL) + c.website.Trigger.SendSnapshot(notifiarr.EventUser) case <-c.menu["send_dash"].Clicked(): - ui.Notify("Gathering and sending app states for dashboard") - c.Print("User Requested State Collection for Dashboard") - c.notifiarr.Trigger.GetState() + c.website.Trigger.SendDashboardState(notifiarr.EventUser) } } } diff --git a/pkg/client/tray_commands.go b/pkg/client/tray_commands.go index 3d52aef9d..9efb43e94 100644 --- a/pkg/client/tray_commands.go +++ b/pkg/client/tray_commands.go @@ -4,16 +4,11 @@ package client import ( - "encoding/json" - "errors" "fmt" "runtime" - "strings" "time" "github.com/Notifiarr/notifiarr/pkg/mnd" - "github.com/Notifiarr/notifiarr/pkg/notifiarr" - "github.com/Notifiarr/notifiarr/pkg/plex" "github.com/Notifiarr/notifiarr/pkg/ui" "github.com/Notifiarr/notifiarr/pkg/update" "github.com/hako/durafmt" @@ -25,7 +20,8 @@ import ( func (c *Client) toggleServer() { if c.server == nil { ui.Notify("Started web server") //nolint:errcheck - c.Print("[user requested] Starting Web Server") + c.Printf("[user requested] Starting Web Server, baseurl: %s, bind address: %s", + c.Config.URLBase, c.Config.BindAddr) c.StartWebServer() return @@ -51,28 +47,6 @@ func (c *Client) rotateLogs() { } } -// changeKey shuts down the web server and changes the API key. -// The server has to shut down to avoid race conditions. -func (c *Client) changeKey() { - key, ok, err := ui.Entry(mnd.Title+": Configuration", "API Key", c.Config.APIKey) - if err != nil { - c.Errorf("Updating API Key: %v", err) - } else if !ok || key == c.Config.APIKey { - return - } - - c.Print("[user requested] Updating API Key!") - - if err := c.StopWebServer(); err != nil && !errors.Is(err, ErrNoServer) { - c.Errorf("Unable to update API Key: %v", err) - return - } else if !errors.Is(err, ErrNoServer) { - defer c.StartWebServer() - } - - c.Config.APIKey = key -} - func (c *Client) checkForUpdate() { c.Print("[user requested] GitHub Update Check") @@ -167,19 +141,6 @@ func (c *Client) displayConfig() (s string) { //nolint: funlen,cyclop return s + "\n" } -// sendPlexSessions is triggered from a menu-bar item. -func (c *Client) sendPlexSessions(url string) { - c.Printf("[user requested] Sending Plex Sessions to %s", url) - - if body, err := c.notifiarr.SendMeta(notifiarr.PlexCron, url, nil, false); err != nil { - c.Errorf("[user requested] Sending Plex Sessions to %s: %v", url, err) - } else if fields := strings.Split(string(body), `"`); len(fields) > 3 { //nolint:gomnd - c.Printf("[user requested] Sent Plex Sessions to %s, reply: %s", url, fields[3]) - } else { - c.Printf("[user requested] Sent Plex Sessions to %s", url) - } -} - func (c *Client) writeConfigFile() { val, _, _ := ui.Entry(mnd.Title, "Enter path to write config file:", c.Flags.ConfigFile) @@ -201,6 +162,8 @@ func (c *Client) writeConfigFile() { } func (c *Client) menuPanic() { + defer c.CapturePanic() + yes, err := ui.Question(mnd.Title, "You really want to panic?", true) if !yes || err != nil { return @@ -209,66 +172,3 @@ func (c *Client) menuPanic() { defer c.Printf("User Requested Application Panic, good bye.") panic("user requested panic") } - -// sendSystemSnapshot is triggered from a menu-bar item, and from --send cli arg. -func (c *Client) sendSystemSnapshot(url string) { - c.Printf("[user requested] Sending System Snapshot to %s", url) - - snaps, errs, debug := c.Config.Snapshot.GetSnapshot() - for _, err := range errs { - if err != nil { - c.Errorf("[user requested] %v", err) - } - } - - for _, err := range debug { - if err != nil { - c.Errorf("[user requested] %v", err) - } - } - - payload := ¬ifiarr.Payload{Type: notifiarr.SnapCron, Snap: snaps} - if _, body, err := c.notifiarr.SendData(url, payload, true); err != nil { - c.Errorf("[user requested] Sending System Snapshot to %s: %v", url, err) - } else if fields := strings.Split(string(body), `"`); len(fields) > 3 { //nolint:gomnd - c.Printf("[user requested] Sent System Snapshot to %s, reply: %s", url, fields[3]) - } else { - c.Printf("[user requested] Sent System Snapshot to %s", url) - } -} - -// logSnaps writes a full snapshot payload to the log file. -func (c *Client) logSnaps() { - c.Printf("[user requested] Collecting Snapshot from Plex and the System (for log file).") - - snaps, errs, debug := c.Config.Snapshot.GetSnapshot() - for _, err := range errs { - if err != nil { - c.Errorf("[user requested] %v", err) - } - } - - for _, err := range debug { - if err != nil { - c.Errorf("[user requested] %v", err) - } - } - - var ( - plex *plex.Sessions - err error - ) - - if c.Config.Plex.Configured() { - if plex, err = c.Config.Plex.GetXMLSessions(); err != nil { - c.Errorf("[user requested] %v", err) - } - } - - b, _ := json.MarshalIndent(¬ifiarr.Payload{ - Type: notifiarr.LogLocal, - Snap: snaps, - Plex: plex, - }, "", " ") - c.Printf("[user requested] Snapshot Data:\n%s", string(b)) -} diff --git a/pkg/client/webserver.go b/pkg/client/webserver.go index 4521aa7cc..d4d4b351d 100644 --- a/pkg/client/webserver.go +++ b/pkg/client/webserver.go @@ -4,10 +4,8 @@ import ( "context" "errors" "fmt" - "net" "net/http" "os" - "strings" "time" "github.com/gorilla/mux" @@ -87,23 +85,3 @@ func (c *Client) StopWebServer() error { return nil } - -// CheckPort attempts to bind to a port to check if it's in use or not. -// We use this to check the port before starting the webserver. -func CheckPort(addr string) (string, error) { - // Cleanup user input. - addr = strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(addr, "/"), "http://"), "https://") - - a, err := net.ResolveTCPAddr("tcp", addr) - if err != nil { - return addr, fmt.Errorf("provided ip:port combo is invalid: %w", err) - } - - l, err := net.ListenTCP("tcp", a) - if err != nil { - return addr, fmt.Errorf("unable to listen on provided ip:port: %w", err) - } - defer l.Close() - - return addr, nil -} diff --git a/pkg/configfile/config.go b/pkg/configfile/config.go index c8ae91822..00c95fda5 100644 --- a/pkg/configfile/config.go +++ b/pkg/configfile/config.go @@ -11,7 +11,6 @@ import ( "os" "path" "path/filepath" - "strings" "github.com/Notifiarr/notifiarr/pkg/apps" "github.com/Notifiarr/notifiarr/pkg/logs" @@ -40,7 +39,7 @@ type Config struct { SSLCrtFile string `json:"sslCertFile" toml:"ssl_cert_file" xml:"ssl_cert_file" yaml:"sslCertFile"` SSLKeyFile string `json:"sslKeyFile" toml:"ssl_key_file" xml:"ssl_key_file" yaml:"sslKeyFile"` AutoUpdate string `json:"autoUpdate" toml:"auto_update" xml:"auto_update" yaml:"autoUpdate"` - SendDash cnfg.Duration `json:"sendDash" toml:"send_dash" xml:"send_dash" yaml:"sendDash"` + MaxBody int `json:"maxBody" toml:"max_body" xml:"max_body" yaml:"maxBody"` Mode string `json:"mode" toml:"mode" xml:"mode" yaml:"mode"` Upstreams []string `json:"upstreams" toml:"upstreams" xml:"upstreams" yaml:"upstreams"` Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"` @@ -53,9 +52,35 @@ type Config struct { Allow AllowedIPs `json:"-" toml:"-" xml:"-" yaml:"-"` } +// NewConfig returns a fresh config with only defaults and a logger ready to go. +func NewConfig(logger *logs.Logger) *Config { + return &Config{ + Mode: notifiarr.ModeProd, + Apps: &apps.Apps{ + URLBase: "/", + DebugLog: logger.DebugLog, + ErrorLog: logger.ErrorLog, + }, + Services: &services.Config{ + Interval: cnfg.Duration{Duration: services.DefaultSendInterval}, + Logger: logger, + }, + BindAddr: mnd.DefaultBindAddr, + Snapshot: &snapshot.Config{ + Timeout: cnfg.Duration{Duration: snapshot.DefaultTimeout}, + }, + LogConfig: &logs.LogConfig{ + LogFiles: mnd.DefaultLogFiles, + LogFileMb: mnd.DefaultLogFileMb, + }, + Timeout: cnfg.Duration{Duration: mnd.DefaultTimeout}, + } +} + // Get parses a config file and environment variables. // Sometimes the app runs without a config file entirely. -func (c *Config) Get(configFile, envPrefix string, logger *logs.Logger) (*notifiarr.Config, error) { +// You should only run this after getting a config with NewConfig(). +func (c *Config) Get(configFile, envPrefix string) (*notifiarr.Config, error) { if configFile != "" { if err := cnfgfile.Unmarshal(c, configFile); err != nil { return nil, fmt.Errorf("config file: %w", err) @@ -66,46 +91,47 @@ func (c *Config) Get(configFile, envPrefix string, logger *logs.Logger) (*notifi return nil, fmt.Errorf("environment variables: %w", err) } - // This function returns the notifiarr package Config struct too. - // This config contains [some of] the same data as the normal Config. - notifiarr := ¬ifiarr.Config{ - Apps: c.Apps, - Plex: c.Plex, - Snap: c.Snapshot, - Logger: logger, - URL: notifiarr.ProdURL, - Timeout: c.Timeout.Duration, - DashDur: c.SendDash.Duration, - } - c.setup(notifiarr) - // Make sure each app has a sane timeout. - if err := c.Apps.Setup(c.Timeout.Duration); err != nil { + err := c.Apps.Setup(c.Timeout.Duration) + if err != nil { return nil, fmt.Errorf("setting up app: %w", err) } - return notifiarr, nil + c.Services.Apps = c.Apps + + svcs, err := c.Services.Setup(c.Service) + if err != nil { + return nil, fmt.Errorf("service checks: %w", err) + } + + // Make sure the port is not in use before starting the web server. + c.BindAddr, err = CheckPort(c.BindAddr) + // This function returns the notifiarr package Config struct too. + // This config contains [some of] the same data as the normal Config. + c.Services.Notifiarr = ¬ifiarr.Config{ + Apps: c.Apps, + Plex: c.Plex, + Snap: c.Snapshot, + Logger: c.Services.Logger, + BaseURL: notifiarr.BaseURL, + Timeout: c.Timeout.Duration, + MaxBody: c.MaxBody, + Services: svcs, + } + c.setup() + + return c.Services.Notifiarr, err } -func (c *Config) setup(notifiarr *notifiarr.Config) { +func (c *Config) setup() { + c.Mode = c.Services.Notifiarr.Setup(c.Mode) c.URLBase = path.Join("/", c.URLBase) c.Allow = MakeIPs(c.Upstreams) - if c.Services != nil { - c.Services.Notifiarr = notifiarr - c.Services.Apps = c.Apps - } - if c.Timeout.Duration == 0 { c.Timeout.Duration = mnd.DefaultTimeout } - if c.BindAddr == "" { - c.BindAddr = mnd.DefaultBindAddr - } else if !strings.Contains(c.BindAddr, ":") { - c.BindAddr = "0.0.0.0:" + c.BindAddr - } - if ui.HasGUI() && c.LogConfig != nil { // Setting AppName forces log files (even if not configured). // Used for GUI apps that have no console output. diff --git a/pkg/configfile/allowedips.go b/pkg/configfile/helper.go similarity index 57% rename from pkg/configfile/allowedips.go rename to pkg/configfile/helper.go index dc3a64c1f..0451ff7fd 100644 --- a/pkg/configfile/allowedips.go +++ b/pkg/configfile/helper.go @@ -4,6 +4,8 @@ import ( "fmt" "net" "strings" + + "github.com/Notifiarr/notifiarr/pkg/mnd" ) /* This is a helper method to check if an IP is in a list/cidr. */ @@ -45,7 +47,7 @@ func (n AllowedIPs) Contains(ip string) bool { // This "allowed" list is later used to check incoming IPs from web requests. func MakeIPs(upstreams []string) (a AllowedIPs) { for _, ip := range upstreams { - if strings.Contains(ip, "/") { + if !strings.Contains(ip, "/") { if strings.Contains(ip, ":") { ip += "/128" } else { @@ -60,3 +62,28 @@ func MakeIPs(upstreams []string) (a AllowedIPs) { return a } + +// CheckPort attempts to bind to a port to check if it's in use or not. +// We use this to check the port before starting the webserver. +func CheckPort(addr string) (string, error) { + // Cleanup user input. + addr = strings.TrimPrefix(strings.TrimPrefix(strings.TrimRight(addr, "/"), "http://"), "https://") + if addr == "" { + addr = mnd.DefaultBindAddr + } else if !strings.Contains(addr, ":") { + addr = "0.0.0.0:" + addr + } + + a, err := net.ResolveTCPAddr("tcp", addr) + if err != nil { + return addr, fmt.Errorf("provided ip:port combo is invalid: %w", err) + } + + l, err := net.ListenTCP("tcp", a) + if err != nil { + return addr, fmt.Errorf("unable to listen on provided ip:port: %w", err) + } + defer l.Close() + + return addr, nil +} diff --git a/pkg/configfile/template.go b/pkg/configfile/template.go index 907fc9dda..47780d1cb 100644 --- a/pkg/configfile/template.go +++ b/pkg/configfile/template.go @@ -56,7 +56,8 @@ bind_addr = "{{.BindAddr}}" quiet = {{.Quiet}}{{if .Debug}} ## Debug prints more data and json payloads. Recommend setting debug_log if enabled. -debug = true{{end}}{{if .Mode}} +debug = true +max_body = {{ .MaxBody }} # maximum body size for debug logs. 0 = no limit.{{end}}{{if and .Mode (ne .Mode "production")}} ## Mode may be "prod" or "dev" or "test". Default, invalid, or unknown uses "prod". mode = "{{.Mode}}"{{end}} @@ -95,12 +96,8 @@ log_file_mb = {{.LogFileMb}} log_files = {{.LogFiles}} ## ## Unix file mode for new log files. Umask also affects this. -## Missing or 0 uses default of 0600. Permissive is 0644. Ignored by Windows. -file_mode = {{.FileMode.String}} - -## How often to send current application states for the dashboard. -## -send_dash = "{{.SendDash}}" +## Missing, blank or 0 uses default of 0600. Permissive is 0644. Ignored by Windows. +file_mode = "{{.FileMode.String}}" ## Web server and application timeouts. ## @@ -119,67 +116,55 @@ timeout = "{{.Timeout}}" ## Examples follow. UNCOMMENT (REMOVE #), AT MINIMUM: [[header]], url, api_key {{if .Lidarr}}{{range .Lidarr}} [[lidarr]] - name = "{{.Name}}" - url = "{{.URL}}" - api_key = "{{.APIKey}}" - interval = "{{.Interval}}" # Service check duration (if name is not empty). - timeout = "{{.Timeout}}"{{if .CheckQ}} - check_q = {{.CheckQ}} # 0 = no repeat, 1 = every hour, 2 = every 2 hours, etc.{{else}} - #check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}}{{end -}} + name = "{{.Name}}" + url = "{{.URL}}" + api_key = "{{.APIKey}}" + interval = "{{.Interval}}" # Service check duration (if name is not empty). + timeout = "{{.Timeout}}"{{if .MaxBody}} + max_body = {{ .MaxBody }} # maximum body size for debug logs. 0 = no limit.{{end}}{{end -}} {{else}}#[[lidarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://lidarr:8989/" -#api_key = "" -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}} +#name = "" # Set a name to enable checks of your service. +#url = "http://lidarr:8989/" +#api_key = "".{{end}} {{if .Radarr}}{{range .Radarr}} [[radarr]] - name = "{{.Name}}" - url = "{{.URL}}" - api_key = "{{.APIKey}}" - disable_cf = {{.DisableCF}} # Disable custom format sync. - interval = "{{.Interval}}" # Service check duration (if name is not empty). - timeout = "{{.Timeout}}"{{if .CheckQ}} - check_q = {{.CheckQ}} # 0 = no repeat, 1 = every hour, 2 = every 2 hours, etc.{{else}} - #check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}}{{end -}} + name = "{{.Name}}" + url = "{{.URL}}" + api_key = "{{.APIKey}}" + interval = "{{.Interval}}" # Service check duration (if name is not empty). + timeout = "{{.Timeout}}"{{ if .MaxBody }} + max_body = {{ .MaxBody }} # maximum body size for debug logs. 0 = no limit.{{end}}{{end -}} {{else}}#[[radarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://127.0.0.1:7878/radarr" -#api_key = "" -#disable_cf = true # Disable custom format sync. -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}} +#name = "" # Set a name to enable checks of your service. +#url = "http://127.0.0.1:7878/radarr" +#api_key = ""{{end}} {{if .Readarr}}{{range .Readarr}} [[readarr]] - name = "{{.Name}}" - url = "{{.URL}}" - api_key = "{{.APIKey}}" - interval = "{{.Interval}}" # Service check duration (if name is not empty). - timeout = "{{.Timeout}}"{{if .CheckQ}} - check_q = {{.CheckQ}} # 0 = no repeat, 1 = every hour, 2 = every 2 hours, etc.{{else}} - #check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}}{{end -}} + name = "{{.Name}}" + url = "{{.URL}}" + api_key = "{{.APIKey}}" + interval = "{{.Interval}}" # Service check duration (if name is not empty). + timeout = "{{.Timeout}}"{{if .MaxBody}} + max_body = {{ .MaxBody }} # maximum body size for debug logs. 0 = no limit.{{end}}{{end -}} {{else}}#[[readarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://127.0.0.1:8787/readarr" -#api_key = "" -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}} +#name = "" # Set a name to enable checks of your service. +#url = "http://127.0.0.1:8787/readarr" +#api_key = ""{{end}} {{if .Sonarr}}{{range .Sonarr}} [[sonarr]] - name = "{{.Name}}" - url = "{{.URL}}" - api_key = "{{.APIKey}}" - disable_cf = {{.DisableCF}} # Disable release profile sync. - interval = "{{.Interval}}" # Service check duration (if name is not empty). - timeout = "{{.Timeout}}"{{if .CheckQ}} - check_q = {{.CheckQ}} # 0 = no repeat, 1 = every hour, 2 = every 2 hours, etc.{{else}} - #check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}}{{end -}} + name = "{{.Name}}" + url = "{{.URL}}" + api_key = "{{.APIKey}}" + interval = "{{.Interval}}" # Service check duration (if name is not empty). + timeout = "{{.Timeout}}"{{if .MaxBody}} + max_body = {{ .MaxBody }} # maximum body size for debug logs. 0 = no limit.{{end}}{{end -}} {{else}}#[[sonarr]] -#name = "" # Set a name to enable checks of your service. -#url = "http://sonarr:8989/" -#api_key = "" -#disable_cf = true # Disable release profile sync. -#check_q = 0 # Check for items stuck in queue. 0 = no repeat, 1 to repeat every hour, 2 for every 2 hours, etc.{{end}} +#name = "" # Set a name to enable checks of your service. +#url = "http://sonarr:8989/" +#api_key = ""{{end}} # Download Client Configs (below) are used for dashboard state and service checks. @@ -208,7 +193,20 @@ timeout = "{{.Timeout}}" #name = "" # Set a name to enable checks of your service. #url = "http://qbit:8080/" #user = "" -#pass = "" +#pass = ""{{end}} + +{{if .SabNZB}}{{range .SabNZB}} +[[sabnzbd]] + name = "{{.Name}}" + url = "{{.URL}}" + api_key = "{{.APIKey}}" + interval = "{{.Interval}}" # Service check duration (if name is not empty). + timeout = "{{.Timeout}}"{{end}} +{{else}} +#[[sabnzbd]] +#name = "" # Set a name to enable checks of this application. +#url = "http://sabnzbd:8080/" +#api_key = "" {{end}} ################# @@ -218,57 +216,33 @@ timeout = "{{.Timeout}}" ## Find your token: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ ## [plex]{{if and .Plex (not force)}} - url = "{{.Plex.URL}}" # Your plex URL - token = "{{.Plex.Token}}" # your plex token; get this from a web inspector - interval = "{{.Plex.Interval}}" # how often to send session data, 0 = off - timeout = "{{.Plex.Timeout}}" # how long to wait for HTTP responses - cooldown = "{{.Plex.Cooldown}}" # how often plex webhooks may trigger session hooks - account_map = "{{.Plex.AccountMap}}" # map an email to a name, ex: "som@ema.il,Name|some@ther.mail,name" - movies_percent_complete = {{.Plex.MoviesPC}} # 0, 70-99, send notifications when a movie session is this % complete. - series_percent_complete = {{.Plex.SeriesPC}} # 0, 70-99, send notifications when an episode session is this % complete. - no_activity = {{.Plex.NoActivity}} # Disable getting sessions from Plex after a webhook, hides "Activity" + url = "{{.Plex.URL}}" # Your plex URL + token = "{{.Plex.Token}}" # your plex token; get this from a web inspector + timeout = "{{.Plex.Timeout}}" # how long to wait for HTTP responses {{- else}} - url = "http://localhost:32400" # Your plex URL - token = "" # your plex token; get this from a web inspector - interval = "30m0s" # how often to send session data, 0 = off - cooldown = "15s" # how often plex webhooks may trigger session hooks - account_map = "" # shared plex servers: map an email to a name, ex: "som@ema.il,Name|some@ther.mail,name" - movies_percent_complete = 0 # 0, 70-99, send notifications when a movie session is this % complete. - series_percent_complete = 0 # 0, 70-99, send notifications when an episode session is this % complete. + url = "http://localhost:32400" # Your plex URL + token = "" # your plex token; get this from a web inspector {{- end }} - ##################### -# Snapshot Settings # +# Tautulli Settings # ##################### -## Install package(s) -## - Windows: smartmontools - https://sourceforge.net/projects/smartmontools/ -## - Linux: apt install smartmontools || yum install smartmontools -## - Docker: Already Included. Run in --privileged mode. -## - Synology: opkg install smartmontools -## - Entware: https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS -## - Entware Package List: https://github.com/Entware/Entware-ng/wiki/Install-on-Synology-NAS -## -[snapshot] - interval = "{{.Snapshot.Interval}}" # how often to send a snapshot, 0 = off, 30m - 2h recommended - timeout = "{{.Snapshot.Timeout}}" # how long a snapshot may take - monitor_raid = {{.Snapshot.Raid}} # mdadm / megacli - monitor_drives = {{.Snapshot.DriveData}} # smartctl: age, temp, health - monitor_space = {{.Snapshot.DiskUsage}} # disk usage for all partitions - monitor_uptime = {{.Snapshot.Uptime}} # system data, users, hostname, uptime, os, build - monitor_cpuMemory = {{.Snapshot.CPUMem}} # literally cpu usage, load averages, and memory - monitor_cpuTemp = {{.Snapshot.CPUTemp}} # cpu temperatures, not available on all platforms -{{- if .Snapshot.ZFSPools}} - zfs_pools = [ - {{- range $s := .Snapshot.ZFSPools}}"{{$s}}",{{end -}} - ] # list of zfs pools, ex: zfs_pools=["data", "data2"]{{else}} - zfs_pools = [] # list of zfs pools, ex: zfs_pools=["data", "data2"]{{end}} - use_sudo = {{.Snapshot.UseSudo}} # sudo is needed on unix when monitor_drives=true or for megacli. -## Example sudoers entries follow; these go in /etc/sudoers.d. Fix the paths to smartctl and MegaCli. -## notifiarr ALL=(root) NOPASSWD:/usr/sbin/smartctl * -## notifiarr ALL=(root) NOPASSWD:/usr/sbin/MegaCli64 -LDInfo -Lall -aALL - +# Enables email=>username map. Set a name to enable service checks. +# Must uncomment [tautulli], 'api_key' and 'url' at a minimum. +{{if and .Tautulli (not force)}} +[tautulli] + name = "{{.Tautulli.Name}}" # only set a name if you want to enable service checks. + url = "{{.Tautulli.URL}}" # Your Tautulli URL + api_key = "{{.Tautulli.APIKey}}" # your plex token; get this from a web inspector + timeout = "{{.Tautulli.Timeout}}" # how long to wait for HTTP responses + interval = "{{.Tautulli.Interval}}" # how often to send service checks +{{- else}} +#[tautulli] +# name = "" # only set a name if you want to enable service checks. +# url = "http://localhost:8181" # Your Tautulli URL +# api_key = "" # your tautulli api key; get this from settings +{{- end }} ################## # Service Checks # diff --git a/pkg/logs/logfiles.go b/pkg/logs/logfiles.go index 12cb93628..6af5057a0 100644 --- a/pkg/logs/logfiles.go +++ b/pkg/logs/logfiles.go @@ -102,6 +102,11 @@ func (l *Logger) postLogRotate(_, newFile string) { } func (l *Logger) openDebugLog() { + if !l.logs.Debug { + // in case we're reloading without debug and had it before. + l.DebugLog.SetOutput(io.Discard) + } + if !l.logs.Debug || l.logs.DebugLog == "" { return } diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go index 4f108ef3a..e1667d8e9 100644 --- a/pkg/logs/logs.go +++ b/pkg/logs/logs.go @@ -81,9 +81,6 @@ func New() *Logger { // SetupLogging splits log writers into a file and/or stdout. func (l *Logger) SetupLogging(config *LogConfig) { - logFiles = config.LogFiles - logFileMb = config.LogFileMb - fileMode = config.FileMode.Mode() l.logs = config l.setDefaultLogPaths() l.setLogPaths() @@ -160,6 +157,7 @@ func (l *Logger) CapturePanic() { ui.ShowConsoleWindow() l.ErrorLog.Output(callDepth, //nolint:errcheck fmt.Sprintf("Go Panic! %s\n%v\n%s", mnd.BugIssue, r, string(debug.Stack()))) + panic(r) } } diff --git a/pkg/logs/logs_linux.go b/pkg/logs/logs_linux.go index 824223475..c3486da5a 100644 --- a/pkg/logs/logs_linux.go +++ b/pkg/logs/logs_linux.go @@ -7,9 +7,9 @@ import ( "syscall" ) -var stderr = os.Stderr.Fd() // nolint:gochecknoglobals +// nolint:gochecknoglobals +var stderr = os.Stderr.Fd() func redirectStderr(file *os.File) { - os.Stderr = file _ = syscall.Dup3(int(file.Fd()), int(stderr), 0) } diff --git a/pkg/logs/logs_others.go b/pkg/logs/logs_others.go index 6a1df9c51..af217f5f9 100644 --- a/pkg/logs/logs_others.go +++ b/pkg/logs/logs_others.go @@ -1,4 +1,4 @@ -//+build !linux,!windows +//go:build !linux && !windows package logs @@ -15,5 +15,4 @@ var stderr = os.Stderr.Fd() func redirectStderr(file *os.File) { // This works on darwin and freebsd, maybe others. _ = syscall.Dup2(int(file.Fd()), int(stderr)) - os.Stderr = file } diff --git a/pkg/mnd/constants.go b/pkg/mnd/constants.go index 6dba553cd..4c22c0cd0 100644 --- a/pkg/mnd/constants.go +++ b/pkg/mnd/constants.go @@ -8,8 +8,9 @@ const ( Mode0755 = 0o755 Mode0750 = 0o750 Mode0600 = 0o600 - Megabyte = 1024 * 1024 - KB100 = 1024 * 100 + Kilobyte = 1024 + Megabyte = Kilobyte * Kilobyte + KB100 = Kilobyte * 100 OneDay = 24 * time.Hour HalfHour = 30 * time.Minute Base10 = 10 diff --git a/pkg/notifiarr/cfsync.go b/pkg/notifiarr/cfsync.go index a70c26d09..36aa69e40 100644 --- a/pkg/notifiarr/cfsync.go +++ b/pkg/notifiarr/cfsync.go @@ -3,13 +3,22 @@ package notifiarr import ( "encoding/json" "fmt" - "net/http" "github.com/Notifiarr/notifiarr/pkg/apps" + "golift.io/cnfg" "golift.io/starr/radarr" "golift.io/starr/sonarr" ) +// syncConfig is the configuration returned from the notifiarr website. +type syncConfig struct { + Interval cnfg.Duration `json:"interval"` // how often to fire in minutes. + Radarr int64 `json:"radarr"` // items in sync + RadarrInstances intList `json:"radarrInstances"` // which instance IDs we sync + Sonarr int64 `json:"sonarr"` // items in sync + SonarrInstances intList `json:"sonarrInstances"` // which instance IDs we sync +} + // cfMapIDpayload is used to post-back ID changes for profiles and formats. type cfMapIDpayload struct { Instance int `json:"instance"` @@ -36,120 +45,100 @@ type RadarrCustomFormatPayload struct { NewMaps *cfMapIDpayload `json:"newMaps,omitempty"` } -func (t *Triggers) SyncCF(wait bool) { +func (t *Triggers) SyncCF(event EventType) { if t.stop == nil { return } - if !wait { - t.syncCF <- nil - return - } - - reply := make(chan struct{}) - t.syncCF <- reply - <-reply + t.sync.C <- event } -func (c *Config) syncCF(reply chan struct{}) { - c.syncRadarr() - c.syncSonarr() - - if reply != nil { - reply <- struct{}{} - } +func (c *Config) syncCF(event EventType) { + c.Debugf("Running CF Sync via event: %s", event) + c.syncRadarr(event) + c.syncSonarr(event) } // syncRadarr triggers a custom format sync for Radarr. -func (c *Config) syncRadarr() { - if ci, err := c.GetClientInfo(); err != nil { - c.Errorf("Cannot sync Radarr Custom Formats. Error: %v", err) +func (c *Config) syncRadarr(event EventType) { + if c.ClientInfo == nil || len(c.Actions.Sync.RadarrInstances) < 1 { + c.Debugf("Cannot sync Radarr Custom Formats. Website provided 0 instances.") return - } else if ci.Message.CFSync < 1 { + } else if len(c.Apps.Radarr) < 1 { + c.Debugf("Cannot sync Radarr Custom Formats. No Radarr instances configured.") return } - for i, r := range c.Apps.Radarr { - if r.DisableCF || r.URL == "" || r.APIKey == "" { + for i, app := range c.Apps.Radarr { + instance := i + 1 + if app.URL == "" || app.APIKey == "" || !c.Actions.Sync.RadarrInstances.Has(instance) { + c.Debugf("CF Sync Skipping Radarr instance %d. Not in sync list: %v", instance, c.Actions.Sync.RadarrInstances) continue } - switch synced, err := c.syncRadarrCF(i+1, r); { - case err != nil: - c.Errorf("Radarr Custom Formats sync request for '%d:%s' failed: %v", i+1, r.URL, err) - case synced: - c.Printf("Synced Custom Formats from Notifiarr for Radarr: %d:%s", i+1, r.URL) - default: - c.Printf("Sent Custom Formats sync request to Notifiarr for Radarr: %d:%s", i+1, r.URL) + if err := c.syncRadarrCF(instance, app); err != nil { + c.Errorf("[%s requested] Radarr Custom Formats sync request for '%d:%s' failed: %v", event, instance, app.URL, err) + return } + + c.Printf("[%s requested] Synced Custom Formats from Notifiarr for Radarr: %d:%s", event, instance, app.URL) } } -func (c *Config) syncRadarrCF(instance int, r *apps.RadarrConfig) (bool, error) { +func (c *Config) syncRadarrCF(instance int, app *apps.RadarrConfig) error { //nolint:dupl var ( err error - payload = RadarrCustomFormatPayload{Instance: instance, Name: r.Name, NewMaps: c.radarrCF[instance]} + payload = RadarrCustomFormatPayload{Instance: instance, Name: app.Name, NewMaps: c.radarrCF[instance]} ) - payload.QualityProfiles, err = r.Radarr.GetQualityProfiles() + payload.QualityProfiles, err = app.GetQualityProfiles() if err != nil { - return false, fmt.Errorf("getting quality profiles: %w", err) + return fmt.Errorf("getting quality profiles: %w", err) } - payload.CustomFormats, err = r.Radarr.GetCustomFormats() + payload.CustomFormats, err = app.GetCustomFormats() if err != nil { - return false, fmt.Errorf("getting custom formats: %w", err) + return fmt.Errorf("getting custom formats: %w", err) } - //nolint:bodyclose // already closed - resp, body, err := c.SendData(c.BaseURL+CFSyncRoute+"?app=radarr", payload, false) + body, err := c.SendData(CFSyncRoute.Path("", "app=radarr"), payload, false) if err != nil { - return false, fmt.Errorf("sending current formats: %w", err) - } else if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("%w: %s", ErrNon200, resp.Status) + return fmt.Errorf("sending current formats: %w", err) } delete(c.radarrCF, instance) - if len(body) < 1 { - return false, nil - } else if err := c.updateRadarrCF(instance, r, body); err != nil { - return false, fmt.Errorf("updating application: %w", err) + if body.Result != success { + return fmt.Errorf("%w: %s", ErrInvalidResponse, body.Result) } - return true, nil + if err := c.updateRadarrCF(instance, app, body.Details.Response); err != nil { + return fmt.Errorf("updating application: %w", err) + } + + return nil } -func (c *Config) updateRadarrCF(instance int, r *apps.RadarrConfig, data []byte) error { - payload := struct { - Response string `json:"response"` - Message struct { - RadarrCustomFormatPayload - } `json:"message"` - }{} - if err := json.Unmarshal(data, &payload); err != nil { +func (c *Config) updateRadarrCF(instance int, app *apps.RadarrConfig, data []byte) error { + reply := &RadarrCustomFormatPayload{} + if err := json.Unmarshal(data, &reply); err != nil { return fmt.Errorf("bad json response: %w", err) } - reply := payload.Message c.Debugf("Received %d quality profiles and %d custom formats for Radarr: %d:%s", - len(reply.QualityProfiles), len(reply.CustomFormats), instance, r.URL) - - if payload.Response != success { - return fmt.Errorf("%w: %s", ErrInvalidResponse, payload.Response) - } + len(reply.QualityProfiles), len(reply.CustomFormats), instance, app.URL) maps := &cfMapIDpayload{QP: []idMap{}, CF: []idMap{}, Instance: instance} for i, profile := range reply.CustomFormats { id := profile.ID - if _, err := r.UpdateCustomFormat(profile, id); err != nil { + if _, err := app.UpdateCustomFormat(profile, id); err != nil { profile.ID = 0 c.Debugf("Error Updating custom format [%d/%d] (attempting to ADD %d): %v", i, len(reply.CustomFormats), id, err) - newID, err2 := r.AddCustomFormat(profile) + newID, err2 := app.AddCustomFormat(profile) if err2 != nil { return fmt.Errorf("[%d/%d] updating custom format: %d: (update) %v, (add) %w", i, len(reply.CustomFormats), id, err, err2) @@ -160,14 +149,14 @@ func (c *Config) updateRadarrCF(instance int, r *apps.RadarrConfig, data []byte) } for i, profile := range reply.QualityProfiles { - if err := r.UpdateQualityProfile(profile); err != nil { + if err := app.UpdateQualityProfile(profile); err != nil { id := profile.ID profile.ID = 0 c.Debugf("Error Updating quality profile [%d/%d] (attempting to ADD %d): %v", i, len(reply.QualityProfiles), id, err) - newID, err2 := r.AddQualityProfile(profile) + newID, err2 := app.AddQualityProfile(profile) if err2 != nil { return fmt.Errorf("[%d/%d] updating quality profile: %d: (update) %v, (add) %w", i, len(reply.QualityProfiles), id, err, err2) @@ -186,17 +175,13 @@ func (c *Config) postbackRadarrCF(instance int, maps *cfMapIDpayload) error { return nil } - //nolint:bodyclose // already closed. - resp, _, err := c.SendData(c.BaseURL+CFSyncRoute+"?app=radarr&updateIDs=true", &RadarrCustomFormatPayload{ + _, err := c.SendData(CFSyncRoute.Path("", "app=radarr", "updateIDs=true"), &RadarrCustomFormatPayload{ Instance: instance, NewMaps: maps, }, false) if err != nil { c.radarrCF[instance] = maps return fmt.Errorf("updating custom format ID map: %w", err) - } else if resp.StatusCode != http.StatusOK { - c.radarrCF[instance] = maps - return fmt.Errorf("updating custom format ID map: %w: %s", ErrNon200, resp.Status) } delete(c.radarrCF, instance) @@ -217,95 +202,85 @@ type SonarrCustomFormatPayload struct { } // syncSonarr triggers a custom format sync for Sonarr. -func (c *Config) syncSonarr() { - if ci, err := c.GetClientInfo(); err != nil { - c.Debugf("Cannot sync Sonarr Release Profiles. Error: %v", err) +func (c *Config) syncSonarr(event EventType) { + if c.ClientInfo == nil || len(c.Actions.Sync.SonarrInstances) < 1 { + c.Debugf("Cannot sync Sonarr Release Profiles. Website provided 0 instances.") return - } else if ci.Message.RPSync < 1 { + } else if len(c.Apps.Sonarr) < 1 { + c.Debugf("Cannot sync Sonarr Release Profiles. No Sonarr instances configured.") return } - for i, s := range c.Apps.Sonarr { - if s.DisableCF || s.URL == "" || s.APIKey == "" { + for i, app := range c.Apps.Sonarr { + instance := i + 1 + if app.URL == "" || app.APIKey == "" || !c.Actions.Sync.SonarrInstances.Has(instance) { + c.Debugf("CF Sync Skipping Sonarr instance %d. Not in sync list: %v", instance, c.Actions.Sync.SonarrInstances) continue } - switch synced, err := c.syncSonarrRP(i+1, s); { - case err != nil: - c.Errorf("Sonarr Release Profiles sync for '%d:%s' failed: %v", i+1, s.URL, err) - case synced: - c.Printf("Synced Release Profiles from Notifiarr for Sonarr: %d:%s", i+1, s.URL) - default: - c.Printf("Sent Release Profiles sync request to Notifiarr for Sonarr: %d:%s", i+1, s.URL) + if err := c.syncSonarrRP(instance, app); err != nil { + c.Errorf("[%s requested] Sonarr Release Profiles sync for '%d:%s' failed: %v", event, instance, app.URL, err) + return } + + c.Printf("[%s requested] Synced Sonarr Release Profiles from Notifiarr: %d:%s", event, instance, app.URL) } } -func (c *Config) syncSonarrRP(instance int, s *apps.SonarrConfig) (bool, error) { +func (c *Config) syncSonarrRP(instance int, app *apps.SonarrConfig) error { //nolint:dupl var ( err error - payload = SonarrCustomFormatPayload{Instance: instance, Name: s.Name, NewMaps: c.sonarrRP[instance]} + payload = SonarrCustomFormatPayload{Instance: instance, Name: app.Name, NewMaps: c.sonarrRP[instance]} ) - payload.QualityProfiles, err = s.Sonarr.GetQualityProfiles() + payload.QualityProfiles, err = app.GetQualityProfiles() if err != nil { - return false, fmt.Errorf("getting quality profiles: %w", err) + return fmt.Errorf("getting quality profiles: %w", err) } - payload.ReleaseProfiles, err = s.Sonarr.GetReleaseProfiles() + payload.ReleaseProfiles, err = app.GetReleaseProfiles() if err != nil { - return false, fmt.Errorf("getting release profiles: %w", err) + return fmt.Errorf("getting release profiles: %w", err) } - //nolint:bodyclose // already closed - resp, body, err := c.SendData(c.BaseURL+CFSyncRoute+"?app=sonarr", payload, false) + body, err := c.SendData(CFSyncRoute.Path("", "app=sonarr"), payload, false) if err != nil { - return false, fmt.Errorf("sending current profiles: %w", err) - } else if resp.StatusCode != http.StatusOK { - return false, fmt.Errorf("%w: %s", ErrNon200, resp.Status) + return fmt.Errorf("sending current profiles: %w", err) } delete(c.sonarrRP, instance) - if len(body) < 1 { - return false, nil - } else if err := c.updateSonarrRP(instance, s, body); err != nil { - return false, fmt.Errorf("updating application: %w", err) + if body.Result != success { + return fmt.Errorf("%w: %s", ErrInvalidResponse, body.Result) + } + + if err := c.updateSonarrRP(instance, app, body.Details.Response); err != nil { + return fmt.Errorf("updating application: %w", err) } - return true, nil + return nil } -func (c *Config) updateSonarrRP(instance int, s *apps.SonarrConfig, data []byte) error { - payload := struct { - Response string `json:"response"` - Message struct { - SonarrCustomFormatPayload - } `json:"message"` - }{} - if err := json.Unmarshal(data, &payload); err != nil { +func (c *Config) updateSonarrRP(instance int, app *apps.SonarrConfig, data []byte) error { + reply := &SonarrCustomFormatPayload{} + if err := json.Unmarshal(data, &reply); err != nil { return fmt.Errorf("bad json response: %w", err) } - reply := payload.Message c.Debugf("Received %d quality profiles and %d release profiles for Sonarr: %d:%s", - len(reply.QualityProfiles), len(reply.ReleaseProfiles), instance, s.URL) - - if payload.Response != success { - return fmt.Errorf("%w: %s", ErrInvalidResponse, payload.Response) - } + len(reply.QualityProfiles), len(reply.ReleaseProfiles), instance, app.URL) maps := &cfMapIDpayload{RP: []idMap{}, QP: []idMap{}, Instance: instance} for i, profile := range reply.ReleaseProfiles { - if err := s.UpdateReleaseProfile(profile); err != nil { + if err := app.UpdateReleaseProfile(profile); err != nil { id := profile.ID profile.ID = 0 c.Debugf("Error Updating release profile [%d/%d] (attempting to ADD %d): %v", i, len(reply.ReleaseProfiles), id, err) - newID, err2 := s.AddReleaseProfile(profile) + newID, err2 := app.AddReleaseProfile(profile) if err2 != nil { return fmt.Errorf("[%d/%d] updating release profiles: %d: (update) %v, (add) %w", i, len(reply.ReleaseProfiles), id, err, err2) @@ -316,14 +291,14 @@ func (c *Config) updateSonarrRP(instance int, s *apps.SonarrConfig, data []byte) } for i, profile := range reply.QualityProfiles { - if err := s.UpdateQualityProfile(profile); err != nil { + if err := app.UpdateQualityProfile(profile); err != nil { id := profile.ID profile.ID = 0 c.Debugf("Error Updating quality format [%d/%d] (attempting to ADD %d): %v", i, len(reply.QualityProfiles), id, err) - newID, err2 := s.AddQualityProfile(profile) + newID, err2 := app.AddQualityProfile(profile) if err2 != nil { return fmt.Errorf("[%d/%d] updating quality profile: %d: (update) %v, (add) %w", i, len(reply.QualityProfiles), id, err, err2) @@ -342,17 +317,13 @@ func (c *Config) postbackSonarrRP(instance int, maps *cfMapIDpayload) error { return nil } - //nolint:bodyclose // already closed - resp, _, err := c.SendData(c.BaseURL+CFSyncRoute+"?app=sonarr&updateIDs=true", &SonarrCustomFormatPayload{ + _, err := c.SendData(CFSyncRoute.Path("", "app=sonarr", "updateIDs=true"), &SonarrCustomFormatPayload{ Instance: instance, NewMaps: maps, }, false) if err != nil { c.sonarrRP[instance] = maps return fmt.Errorf("updating quality release ID map: %w", err) - } else if resp.StatusCode != http.StatusOK { - c.sonarrRP[instance] = maps - return fmt.Errorf("updating quality release ID map: %w: %s", ErrNon200, resp.Status) } delete(c.sonarrRP, instance) diff --git a/pkg/notifiarr/clientinfo.go b/pkg/notifiarr/clientinfo.go index fd67e9be6..7d3456177 100644 --- a/pkg/notifiarr/clientinfo.go +++ b/pkg/notifiarr/clientinfo.go @@ -4,124 +4,313 @@ import ( "context" "encoding/json" "fmt" - "net/http" "os" "runtime" "time" + "github.com/Notifiarr/notifiarr/pkg/plex" + "github.com/Notifiarr/notifiarr/pkg/snapshot" "github.com/Notifiarr/notifiarr/pkg/ui" + "github.com/Notifiarr/notifiarr/pkg/update" "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/process" + "golift.io/cnfg" "golift.io/version" ) -// ClientInfo is the reply from the ClientRoute endpoint. type ClientInfo struct { - Status string `json:"status"` - Timers []timer `json:"timers"` - Message struct { - Text string `json:"text"` + User struct { + WelcomeMSG string `json:"welcome"` Subscriber bool `json:"subscriber"` Patron bool `json:"patron"` - Gaps gaps `json:"gaps"` - CFSync int64 `json:"cfSync"` - RPSync int64 `json:"rpSync"` - } `json:"message"` + } `json:"user"` + Actions struct { + Poll bool `json:"poll"` + Plex *plex.Server `json:"plex"` // optional + Apps appConfigs `json:"apps"` // unused yet! + Dashboard dashConfig `json:"dashboard"` // now in use. + Sync syncConfig `json:"sync"` // in use (cfsync) + Gaps gapsConfig `json:"gaps"` // radarr collection gaps + Custom []*timerConfig `json:"custom"` // custom GET timers + Snapshot *snapshot.Config `json:"snapshot"` // optional + } `json:"actions"` } -type timer struct { - URI string - Interval int - last time.Time +// ServiceConfig comes from the services package. It's only used for display on the website. +type ServiceConfig struct { + Interval cnfg.Duration `json:"interval"` + Parallel uint `json:"parallel"` + Disabled bool `json:"disabled"` + Checks []*ServiceCheck `json:"checks"` } -func (t *timer) Ready() bool { - return t.last.After(time.Now().Add(time.Duration(t.Interval) * time.Minute)) +// ServiceCheck comes from the services package. It's only used for display on the website. +type ServiceCheck struct { + Name string `json:"name"` + Type string `json:"type"` + Expect string `json:"expect"` + Timeout cnfg.Duration `json:"timeout"` + Interval cnfg.Duration `json:"interval"` +} + +type intList []int + +func (l intList) Has(instance int) bool { + for _, i := range l { + if instance == i { + return true + } + } + + return false } // String returns the message text for a client info response. func (c *ClientInfo) String() string { if c == nil { - return "" + return "" } - return c.Message.Text + return c.User.WelcomeMSG } // IsSub returns true if the client is a subscriber. False otherwise. func (c *ClientInfo) IsSub() bool { - return c != nil && c.Message.Subscriber + return c != nil && c.User.Subscriber } // IsPatron returns true if the client is a patron. False otherwise. func (c *ClientInfo) IsPatron() bool { - return c != nil && c.Message.Patron + return c != nil && c.User.Patron } // GetClientInfo returns an error if the API key is wrong. Returns client info otherwise. -func (c *Config) GetClientInfo() (*ClientInfo, error) { - if c.extras.clientInfo != nil { - return c.extras.clientInfo, nil +func (c *Config) GetClientInfo(event EventType) (*ClientInfo, error) { + c.extras.ciMutex.Lock() + defer c.extras.ciMutex.Unlock() + + if c.extras.ClientInfo != nil { + return c.extras.ClientInfo, nil } - resp, body, err := c.SendData(c.BaseURL+ClientRoute, c.Info(), true) //nolint:bodyclose // already closed. + body, err := c.SendData(ClientRoute.Path(event), c.Info(), true) if err != nil { - return nil, fmt.Errorf("POSTing client info: %w", err) + return nil, fmt.Errorf("sending client info: %w", err) } v := ClientInfo{} - if err = json.Unmarshal(body, &v); err != nil { - return &v, fmt.Errorf("parsing response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return &v, ErrNon200 + if err = json.Unmarshal(body.Details.Response, &v); err != nil { + return &v, fmt.Errorf("parsing response: %w, %s", err, string(body.Details.Response)) } // Only set this if there was no error. - c.extras.clientInfo = &v + c.extras.ClientInfo = &v - return c.extras.clientInfo, nil + return c.extras.ClientInfo, nil } // Info is used for JSON input for our outgoing client info. func (c *Config) Info() map[string]interface{} { - numPlex := 0 // maybe one day we'll support more than 1 plex. + var ( + plexConfig interface{} + numPlex = 0 // maybe one day we'll support more than 1 plex. + ) + if c.Plex.Configured() { numPlex = 1 + plexConfig = map[string]interface{}{ + "seriesPc": c.Plex.SeriesPC, + "moviesPc": c.Plex.MoviesPC, + "cooldown": c.Plex.Cooldown, + "accountMap": c.Plex.AccountMap, + "interval": c.Plex.Interval, + "noActivity": c.Plex.NoActivity, + } } - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + return map[string]interface{}{ + "client": map[string]interface{}{ + "arch": runtime.GOARCH, + "buildDate": version.BuildDate, + "goVersion": version.GoVersion, + "os": runtime.GOOS, + "revision": version.Revision, + "version": version.Version, + "uptimeSec": time.Since(version.Started).Round(time.Second).Seconds(), + "started": version.Started, + "docker": os.Getenv("NOTIFIARR_IN_DOCKER") == "true", + "gui": ui.HasGUI(), + }, + "num": map[string]interface{}{ + "deluge": len(c.Apps.Deluge), + "lidarr": len(c.Apps.Lidarr), + "plex": numPlex, + "qbit": len(c.Apps.Qbit), + "radarr": len(c.Apps.Radarr), + "readarr": len(c.Apps.Readarr), + "sonarr": len(c.Apps.Sonarr), + }, + "config": map[string]interface{}{ + "globalTimeout": c.Timeout.String(), + "retries": c.Retries, + "plex": plexConfig, + "snapshot": c.Snap, + "apps": c.getAppConfigs(), + }, + "internal": map[string]interface{}{ + "stuckDur": stuckDur.String(), + "pollDur": pollDur.String(), + }, + "services": c.Services, + } +} + +func (c *Config) getTautulliData(add map[string]interface{}) { + u, err := c.Apps.Tautulli.GetUsers() + if err != nil { + c.Error("Getting Tautulli Users:", err) + return + } + + add["tautulli"] = map[string]interface{}{"users": u.MapEmailName()} +} + +// HostInfoNoError will return nil if there is an error, otherwise a copy of the host info. +func (c *Config) HostInfoNoError() *host.InfoStat { + if c.extras.hostInfo == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - hostInfo, _ := host.InfoWithContext(ctx) - if hostInfo != nil { - hostInfo.Hostname = "" // we do not need this. + procs, _ := process.ProcessesWithContext(ctx) + + return &host.InfoStat{ + Hostname: c.extras.hostInfo.Hostname, + Uptime: uint64(time.Now().Unix()) - c.extras.hostInfo.BootTime, + BootTime: c.extras.hostInfo.BootTime, + Procs: uint64(len(procs)), + OS: c.extras.hostInfo.OS, + Platform: c.extras.hostInfo.Platform, + PlatformFamily: c.extras.hostInfo.PlatformFamily, + PlatformVersion: c.extras.hostInfo.PlatformVersion, + KernelVersion: c.extras.hostInfo.KernelVersion, + KernelArch: c.extras.hostInfo.KernelArch, + VirtualizationSystem: c.extras.hostInfo.VirtualizationSystem, + VirtualizationRole: c.extras.hostInfo.VirtualizationRole, + HostID: c.extras.hostInfo.HostID, + } +} + +// GetHostInfoUID attempts to make a unique machine identifier... +func (c *Config) GetHostInfoUID() (*host.InfoStat, error) { + if c.extras.hostInfo != nil { + return c.HostInfoNoError(), nil } - return map[string]interface{}{ - "arch": runtime.GOARCH, - "build_date": version.BuildDate, - "cfsync_dur": cfSyncTimer.Seconds(), - "dash_dur": c.DashDur.Seconds(), - "docker": os.Getenv("NOTIFIARR_IN_DOCKER") == "true", - "go_version": version.GoVersion, - "gui": ui.HasGUI(), - "host": hostInfo, - "num_deluge": len(c.Apps.Deluge), - "num_lidarr": len(c.Apps.Lidarr), - "num_plex": numPlex, - "num_qbit": len(c.Apps.Qbit), - "num_radarr": len(c.Apps.Radarr), - "num_readarr": len(c.Apps.Readarr), - "num_sonarr": len(c.Apps.Sonarr), - "os": runtime.GOOS, - "retries": c.Retries, - "revision": version.Revision, - "snap_dur": c.Snap.Interval.Seconds(), - "snap_tout": c.Snap.Timeout.Seconds(), - "stuck_dur": stuckTimer.Seconds(), - "timeout": c.Timeout.Seconds(), - "uptime_dur": time.Since(version.Started).Round(time.Second).Seconds(), - "version": version.Version, + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) //nolint:gomnd + defer cancel() + + hostInfo, err := host.InfoWithContext(ctx) + if err != nil { + return nil, fmt.Errorf("getting host info: %w", err) + } + + syn, err := snapshot.GetSynology(true) + if syn != nil && err == nil { + syn.SetInfo(hostInfo) + } + + if hostInfo.Platform == "" && + (hostInfo.VirtualizationSystem == "docker" || os.Getenv("NOTIFIARR_IN_DOCKER") == "true") { + hostInfo.Platform = "Docker " + hostInfo.KernelVersion + hostInfo.PlatformFamily = "Docker" + } + + c.extras.hostInfo = hostInfo + + return c.HostInfoNoError(), nil // return a copy. +} + +func (c *Config) pollForReload(event EventType) { + body, err := c.SendData(ClientRoute.Path(EventPoll), c.Info(), true) + if err != nil { + c.Errorf("[%s requested] Polling Notifiarr: %v", event, err) + return + } + + var v struct { + Reload bool `json:"reload"` + LastSync time.Time `json:"lastSync"` + LastChange time.Time `json:"lastChange"` + } + + if err = json.Unmarshal(body.Details.Response, &v); err != nil { + c.Errorf("[%s requested] Polling Notifiarr: %v", event, err) + return + } + + if v.Reload { + c.Printf("[%s requested] Website indicated new configurations; reloading to pick them up!"+ + " Last Sync: %v, Last Change: %v, Diff: %v", event, v.LastSync, v.LastChange, v.LastSync.Sub(v.LastChange)) + c.Sighup <- &update.Signal{Text: "poll triggered reload"} + } else if c.ClientInfo == nil { + c.Printf("[%s requested] API Key checked out, reloading to pick up configuration from website!", event) + c.Sighup <- &update.Signal{Text: "client info reload"} } } + +func (c *Config) getAppConfigs() map[string]interface{} { + apps := make(map[string][]map[string]interface{}) + + for i, app := range c.Apps.Lidarr { + apps["lidarr"] = append(apps["lidarr"], map[string]interface{}{ + "name": app.Name, + "instance": i + 1, + "checkQ": app.CheckQ, + "stuckOn": app.StuckItem, + "interval": app.Interval, + }) + } + + for i, app := range c.Apps.Radarr { + apps["radarr"] = append(apps["radarr"], map[string]interface{}{ + "name": app.Name, + "instance": i + 1, + "checkQ": app.CheckQ, + "stuckOn": app.StuckItem, + "interval": app.Interval, + }) + } + + for i, app := range c.Apps.Readarr { + apps["readarr"] = append(apps["readarr"], map[string]interface{}{ + "name": app.Name, + "instance": i + 1, + "checkQ": app.CheckQ, + "stuckOn": app.StuckItem, + "interval": app.Interval, + }) + } + + for i, app := range c.Apps.Sonarr { + apps["sonarr"] = append(apps["sonarr"], map[string]interface{}{ + "name": app.Name, + "instance": i + 1, + "checkQ": app.CheckQ, + "stuckOn": app.StuckItem, + "interval": app.Interval, + }) + } + + // We do this so more apps can be added later. + r := make(map[string]interface{}) + for k, v := range apps { + r[k] = v + } + + c.getTautulliData(r) + + return r +} diff --git a/pkg/notifiarr/state.go b/pkg/notifiarr/dashboard.go similarity index 74% rename from pkg/notifiarr/state.go rename to pkg/notifiarr/dashboard.go index 658034f0c..aa679c465 100644 --- a/pkg/notifiarr/state.go +++ b/pkg/notifiarr/dashboard.go @@ -2,13 +2,13 @@ package notifiarr import ( "fmt" - "net/http" "sort" "strings" "sync" "time" "github.com/Notifiarr/notifiarr/pkg/apps" + "golift.io/cnfg" "golift.io/starr/radarr" ) @@ -21,6 +21,11 @@ const ( showLatest = 5 ) +// dashConfig is the configuration returned from the notifiarr website. +type dashConfig struct { + Interval cnfg.Duration `json:"interval"` // how often to fire in minutes. +} + // Sortable holds data about any Starr item. Kind of a generic data store. type Sortable struct { id int64 @@ -31,6 +36,7 @@ type Sortable struct { Episode int64 `json:"episode,omitempty"` } +// SortableList allows sorting a list. type SortableList []*Sortable // State is partially filled out once for each app instance. @@ -44,7 +50,8 @@ type State struct { Upcoming int64 `json:"upcoming,omitempty"` Next SortableList `json:"next,omitempty"` Latest SortableList `json:"latest,omitempty"` - Elapsed time.Duration `json:"elapsed"` // How long it took. + OnDisk int64 `json:"onDisk,omitempty"` + Elapsed cnfg.Duration `json:"elapsed"` // How long it took. Name string `json:"name"` // Radarr Movies int64 `json:"movies,omitempty"` @@ -69,8 +76,11 @@ type State struct { Seeding int64 `json:"seeding,omitempty"` Paused int64 `json:"paused,omitempty"` Errors int64 `json:"errors,omitempty"` + Month int64 `json:"month,omitempty"` + Week int64 `json:"week,omitempty"` } +// States is our compiled states for the dashboard. type States struct { Lidarr []*State `json:"lidarr"` Radarr []*State `json:"radarr"` @@ -78,33 +88,36 @@ type States struct { Sonarr []*State `json:"sonarr"` Qbit []*State `json:"qbit"` Deluge []*State `json:"deluge"` + SabNZB []*State `json:"sabnzbd"` } -func (t *Triggers) GetState() { +// SendDashboardState sends the current states for the dashboard. +func (t *Triggers) SendDashboardState(event EventType) { if t.stop == nil { return } - t.state <- struct{}{} + t.dash.C <- event } -func (c *Config) getState() { - start := time.Now() - states := c.getStates() - apps := time.Since(start).Round(time.Millisecond) - - //nolint:bodyclose // already closed - switch resp, body, err := c.SendData(c.BaseURL+DashRoute, states, true); { - case err != nil: - c.Errorf("Sending Dashboard State Data (apps:%s total:%s): %v", - apps, time.Since(start).Round(time.Millisecond), err) - case resp.StatusCode != http.StatusOK: - c.Errorf("Sending Dashboard State Data (apps:%s total:%s): %v: %s", - apps, time.Since(start).Round(time.Millisecond), ErrNon200, string(body)) - default: - c.Debugf("Sent Dashboard State Data! Elapsed: apps:%s total:%s", - apps, time.Since(start).Round(time.Millisecond)) +func (c *Config) sendDashboardState(event EventType) { + var ( + start = time.Now() + states = c.getStates() + apps = time.Since(start).Round(time.Millisecond) + ) + + resp, err := c.SendData(DashRoute.Path(event), states, true) + if err != nil { + c.Errorf("[%s requested] Sending Dashboard State Data to Notifiarr (apps:%s total:%s): %v", + event, apps, time.Since(start).Round(time.Millisecond), err) + return } + + c.Printf("[%s requested] Sent Dashboard State Data to Notifiarr! Elapsed: apps:%s total:%s."+ + " Website took %s and replied with: %s, %s", + event, apps, time.Since(start).Round(time.Millisecond), + resp.Details.Elapsed, resp.Result, resp.Details.Response) } // getStates fires a routine for each app type and tries to get a lot of data fast! @@ -113,7 +126,7 @@ func (c *Config) getStates() *States { var wg sync.WaitGroup - wg.Add(6) //nolint:gomnd // we are polling 6 apps. + wg.Add(7) //nolint:gomnd // we are polling 7 apps. go func() { defer c.CapturePanic() @@ -145,6 +158,11 @@ func (c *Config) getStates() *States { s.Sonarr = c.getSonarrStates() wg.Done() //nolint:wsl }() + go func() { + defer c.CapturePanic() + s.SabNZB = c.getSabNZBStates() + wg.Done() //nolint:wsl + }() wg.Wait() return s @@ -282,18 +300,37 @@ func (c *Config) getSonarrStates() []*State { return states } +//nolint:funlen func (c *Config) getDelugeState(instance int, d *apps.DelugeConfig) (*State, error) { - state := &State{Instance: instance, Name: d.Name} start := time.Now() - xfers, err := d.GetXfersCompat() - state.Elapsed = time.Since(start) + state := &State{ + Elapsed: cnfg.Duration{Duration: time.Since(start)}, + Instance: instance, + Name: d.Name, + Next: []*Sortable{}, + Latest: []*Sortable{}, + } if err != nil { return state, fmt.Errorf("getting transfers from instance %d: %w", instance, err) } for _, xfer := range xfers { + if eta, _ := xfer.Eta.Int64(); eta != 0 && xfer.FinishedTime == 0 { + // c.Error(xfer.FinishedTime, eta, xfer.Name) + state.Next = append(state.Next, &Sortable{ + Name: xfer.Name, + Date: time.Now().Add(time.Second * time.Duration(eta)), + }) + } else if xfer.FinishedTime > 0 { + seconds := time.Duration(xfer.FinishedTime) * time.Second + state.Latest = append(state.Latest, &Sortable{ + Name: xfer.Name, + Date: time.Now().Add(-seconds).Round(time.Second), + }) + } + state.Size += int64(xfer.TotalSize) state.Uploaded += int64(xfer.TotalUploaded) state.Downloaded += int64(xfer.AllTimeDownload) @@ -324,6 +361,11 @@ func (c *Config) getDelugeState(instance int, d *apps.DelugeConfig) (*State, err } } + sort.Sort(dateSorter(state.Next)) + sort.Sort(sort.Reverse(dateSorter(state.Latest))) + state.Next.Shrink(showNext) + state.Latest.Shrink(showLatest) + return state, nil } @@ -332,7 +374,7 @@ func (c *Config) getLidarrState(instance int, l *apps.LidarrConfig) (*State, err start := time.Now() albums, err := l.GetAlbum("") // all albums - state.Elapsed = time.Since(start) + state.Elapsed.Duration = time.Since(start) if err != nil { return state, fmt.Errorf("getting albums from instance %d: %w", instance, err) @@ -351,6 +393,7 @@ func (c *Config) getLidarrState(instance int, l *apps.LidarrConfig) (*State, err state.Tracks += int64(album.Statistics.TotalTrackCount) state.Missing += int64(album.Statistics.TrackCount - album.Statistics.TrackFileCount) have = album.Statistics.TrackCount-album.Statistics.TrackFileCount < 1 + state.OnDisk += int64(album.Statistics.TrackFileCount) } if album.ReleaseDate.After(time.Now()) && album.Monitored && !have { @@ -363,7 +406,12 @@ func (c *Config) getLidarrState(instance int, l *apps.LidarrConfig) (*State, err } } - state.Percent /= float64(state.Tracks) + if state.Tracks > 0 { + state.Percent /= float64(state.Tracks) + } else { + state.Percent = 100 + } + state.Artists = len(artistIDs) sort.Sort(dateSorter(state.Next)) state.Next.Shrink(showNext) @@ -412,17 +460,33 @@ FORLOOP: } func (c *Config) getQbitState(instance int, q *apps.QbitConfig) (*State, error) { - state := &State{Instance: instance, Name: q.Name} start := time.Now() - xfers, err := q.GetXfers() - state.Elapsed = time.Since(start) + state := &State{ + Elapsed: cnfg.Duration{Duration: time.Since(start)}, + Instance: instance, + Name: q.Name, + Next: []*Sortable{}, + Latest: []*Sortable{}, + } if err != nil { return state, fmt.Errorf("getting transfers from instance %d: %w", instance, err) } for _, xfer := range xfers { + if xfer.Eta != 8640000 && xfer.Eta != 0 && xfer.AmountLeft > 0 { + state.Next = append(state.Next, &Sortable{ + Name: xfer.Name, + Date: time.Now().Add(time.Second * time.Duration(xfer.Eta)), + }) + } else if xfer.AmountLeft == 0 { + state.Latest = append(state.Latest, &Sortable{ + Name: xfer.Name, + Date: time.Unix(int64(xfer.CompletionOn), 0).Round(time.Second), + }) + } + state.Size += xfer.Size state.Uploaded += xfer.Uploaded state.Downloaded += int64(xfer.Downloaded) @@ -446,6 +510,11 @@ func (c *Config) getQbitState(instance int, q *apps.QbitConfig) (*State, error) } } + sort.Sort(dateSorter(state.Next)) + sort.Sort(sort.Reverse(dateSorter(state.Latest))) + state.Next.Shrink(showNext) + state.Latest.Shrink(showLatest) + return state, nil } @@ -454,7 +523,7 @@ func (c *Config) getRadarrState(instance int, r *apps.RadarrConfig) (*State, err start := time.Now() movies, err := r.GetMovie(0) - state.Elapsed = time.Since(start) + state.Elapsed.Duration = time.Since(start) if err != nil { return state, fmt.Errorf("getting movies from instance %d: %w", instance, err) @@ -493,6 +562,7 @@ func processRadarrState(state *State, movies []*radarr.Movie) { if movie.MovieFile != nil { state.Latest = append(state.Latest, &Sortable{Name: movie.Title, Date: movie.MovieFile.DateAdded}) + state.OnDisk++ } } } @@ -502,7 +572,7 @@ func (c *Config) getReadarrState(instance int, r *apps.ReadarrConfig) (*State, e start := time.Now() books, err := r.GetBook("") // all books - state.Elapsed = time.Since(start) + state.Elapsed.Duration = time.Since(start) if err != nil { return state, fmt.Errorf("getting books from instance %d: %w", instance, err) @@ -521,6 +591,7 @@ func (c *Config) getReadarrState(instance int, r *apps.ReadarrConfig) (*State, e state.Editions += book.Statistics.TotalBookCount state.Missing += int64(book.Statistics.BookCount - book.Statistics.BookFileCount) have = book.Statistics.BookCount-book.Statistics.BookFileCount < 1 + state.OnDisk += int64(book.Statistics.BookFileCount) } if book.ReleaseDate.After(time.Now()) && book.Monitored && !have { @@ -533,7 +604,12 @@ func (c *Config) getReadarrState(instance int, r *apps.ReadarrConfig) (*State, e } } - state.Percent /= float64(state.Editions) + if state.Editions > 0 { + state.Percent /= float64(state.Editions) + } else { + state.Percent = 100 + } + state.Authors = len(authorIDs) sort.Sort(dateSorter(state.Next)) state.Next.Shrink(showNext) @@ -579,7 +655,7 @@ func (c *Config) getSonarrState(instance int, s *apps.SonarrConfig) (*State, err start := time.Now() allshows, err := s.GetAllSeries() - state.Elapsed = time.Since(start) + state.Elapsed.Duration = time.Since(start) if err != nil { return state, fmt.Errorf("getting series from instance %d: %w", instance, err) @@ -592,6 +668,7 @@ func (c *Config) getSonarrState(instance int, s *apps.SonarrConfig) (*State, err state.Size += show.Statistics.SizeOnDisk state.Episodes += int64(show.Statistics.TotalEpisodeCount) state.Missing += int64(show.Statistics.EpisodeCount - show.Statistics.EpisodeFileCount) + state.OnDisk += int64(show.Statistics.EpisodeFileCount) } if show.NextAiring.After(time.Now()) { @@ -603,7 +680,11 @@ func (c *Config) getSonarrState(instance int, s *apps.SonarrConfig) (*State, err } } - state.Percent /= float64(state.Shows) + if state.Shows > 0 { + state.Percent /= float64(state.Shows) + } else { + state.Percent = 100 + } if state.Next, err = c.getSonarrStateUpcoming(s, state.Next); err != nil { return state, fmt.Errorf("instance %d: %w", instance, err) @@ -628,7 +709,6 @@ func (c *Config) getSonarrHistory(s *apps.SonarrConfig) ([]*Sortable, error) { if len(table) >= showLatest { break } else if rec.EventType != "downloadFolderImported" { - c.Debug(rec.EventType, rec.SourceTitle) continue } @@ -690,6 +770,87 @@ func (c *Config) getSonarrStateUpcoming(s *apps.SonarrConfig, next []*Sortable) return redo, nil } +func (c *Config) getSabNZBStates() []*State { + states := []*State{} + + for instance, s := range c.Apps.SabNZB { + if s.URL == "" { + continue + } + + c.Debugf("Getting SabNZB State: %d:%s", instance+1, s.URL) + + state, err := c.getSabNZBState(instance+1, s) + if err != nil { + state.Error = err.Error() + c.Errorf("Getting SabNZB Data from %d:%s: %v", instance+1, s.URL, err) + } + + states = append(states, state) + } + + return states +} + +func (c *Config) getSabNZBState(instance int, s *apps.SabNZBConfig) (*State, error) { + state := &State{Instance: instance, Name: s.Name} + start := time.Now() + queue, err := s.GetQueue() + hist, err2 := s.GetHistory() + state.Elapsed.Duration = time.Since(start) + + if err != nil { + return state, fmt.Errorf("getting queue from instance %d: %w", instance, err) + } else if err2 != nil { + return state, fmt.Errorf("getting history from instance %d: %w", instance, err2) + } + + state.Size = hist.TotalSize.Bytes + state.Month = hist.MonthSize.Bytes + state.Week = hist.WeekSize.Bytes + + state.Downloads = len(queue.Slots) + hist.Noofslots + state.Next = []*Sortable{} + state.Latest = []*Sortable{} + + for _, xfer := range queue.Slots { + if strings.EqualFold(xfer.Status, "Downloading") { + state.Downloading++ + } else if strings.EqualFold(xfer.Status, "Paused") { + state.Paused++ + } + + if xfer.Mbleft > 0 { + state.Incomplete++ + } + + state.Next = append(state.Next, &Sortable{ + Date: xfer.Eta.Round(time.Second).UTC(), + Name: xfer.Filename, + }) + } + + for _, xfer := range hist.Slots { + state.Latest = append(state.Latest, &Sortable{ + Name: xfer.Name, + Date: time.Unix(xfer.Completed, 0).Round(time.Second).UTC(), + }) + + if xfer.FailMessage != "" { + state.Errors++ + } else { + state.Downloaded++ + } + } + + sort.Sort(dateSorter(state.Next)) + sort.Sort(sort.Reverse(dateSorter(state.Latest))) + state.Next.Shrink(showNext) + state.Latest.Shrink(showLatest) + + return state, nil +} + type dateSorter []*Sortable func (s dateSorter) Len() int { diff --git a/pkg/notifiarr/gaps.go b/pkg/notifiarr/gaps.go index f42d4d89d..af930fcb4 100644 --- a/pkg/notifiarr/gaps.go +++ b/pkg/notifiarr/gaps.go @@ -2,83 +2,71 @@ package notifiarr import ( "fmt" - "net/http" + "github.com/Notifiarr/notifiarr/pkg/apps" + "golift.io/cnfg" "golift.io/starr/radarr" ) /* Gaps allows filling gaps in Radarr collections. */ -type gaps struct { - Instances []int - Interval int +// gapsConfig is the configuration returned from the notifiarr website. +type gapsConfig struct { + Instances intList `json:"instances"` + Interval cnfg.Duration `json:"interval"` } -func (t *Triggers) SendGaps(source string) { +func (t *Triggers) SendGaps(event EventType) { if t.stop == nil { return } - t.gaps <- source + t.gaps.C <- event } -func (c *Config) sendGaps(source string) { - c.Printf("Sending Radarr Collections Gaps to Notifiarr: %s", source) - - ci, err := c.GetClientInfo() - if err != nil { - c.Errorf("Cannot send Radarr Collection Gaps: %v", err) - return - } else if len(ci.Message.Gaps.Instances) == 0 { +func (c *Config) sendGaps(event EventType) { + if c.ClientInfo == nil || len(c.Actions.Gaps.Instances) == 0 || len(c.Apps.Radarr) == 0 { + c.Errorf("[%s requested] Cannot send Radarr Collection Gaps: instances or configured Radarrs (%d) are zero.", + event, len(c.Apps.Radarr)) return } for i, r := range c.Apps.Radarr { - if r.DisableCF || r.URL == "" || r.APIKey == "" || !ci.Message.Gaps.Has(i+1) { + instance := i + 1 + if r.URL == "" || r.APIKey == "" || !c.Actions.Gaps.Instances.Has(instance) { continue } - if err := c.sendInstanceGaps(i + 1); err != nil { - c.Errorf("Radarr Collection Gaps request for '%d:%s' failed: %v", i+1, r.URL, err) + if resp, err := c.sendInstanceGaps(event, instance, r); err != nil { + c.Errorf("[%s requested] Radarr Collection Gaps request for '%d:%s' failed: %v", event, instance, r.URL, err) } else { - c.Printf("Sent Collection Gaps to Notifiarr for Radarr: %d:%s", i+1, r.URL) + c.Printf("[%s requested] Sent Collection Gaps to Notifiarr for Radarr: %d:%s. "+ + "Website took %s and replied with: %s, %s", + event, instance, r.URL, resp.Details.Elapsed, resp.Result, resp.Details.Response) } } } -func (c *Config) sendInstanceGaps(i int) error { +func (c *Config) sendInstanceGaps(event EventType, instance int, app *apps.RadarrConfig) (*Response, error) { type radarrGapsPayload struct { Instance int `json:"instance"` Name string `json:"name"` Movies []*radarr.Movie `json:"movies"` } - movies, err := c.Apps.Radarr[i].GetMovie(0) + movies, err := app.GetMovie(0) if err != nil { - return fmt.Errorf("getting movies: %w", err) + return nil, fmt.Errorf("getting movies: %w", err) } - //nolint:bodyclose // already closed - resp, _, err := c.SendData(c.BaseURL+GapsRoute+"?app=radarr", &radarrGapsPayload{ + resp, err := c.SendData(GapsRoute.Path(event, "app=radarr"), &radarrGapsPayload{ Movies: movies, - Name: c.Apps.Radarr[i].Name, - Instance: i, + Name: app.Name, + Instance: instance, }, false) if err != nil { - return fmt.Errorf("sending collection gaps: %w", err) - } else if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%w: %s", ErrNon200, resp.Status) - } - - return nil -} - -func (g gaps) Has(instance int) bool { - for _, i := range g.Instances { - if instance == i { - return true - } + return nil, fmt.Errorf("sending collection gaps: %w", err) } - return false + return resp, nil } diff --git a/pkg/notifiarr/handlers.go b/pkg/notifiarr/handlers.go index a1fce3a98..c74a54a20 100644 --- a/pkg/notifiarr/handlers.go +++ b/pkg/notifiarr/handlers.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/Notifiarr/notifiarr/pkg/apps" "github.com/Notifiarr/notifiarr/pkg/mnd" "github.com/Notifiarr/notifiarr/pkg/plex" ) @@ -119,7 +120,7 @@ func (c *Config) PlexHandler(w http.ResponseWriter, r *http.Request) { //nolint: case strings.EqualFold(v.Event, "media.play"): fallthrough case strings.EqualFold(v.Event, "media.resume"): - go c.collectSessions(&v) + go c.collectSessions(EventHook, &v) c.Printf("Plex Incoming Webhook: %s, %s '%s' => %s (collecting sessions)", v.Server.Title, v.Account.Title, v.Event, v.Metadata.Title) r.Header.Set("X-Request-Time", fmt.Sprintf("%dms", time.Since(start).Milliseconds())) @@ -131,54 +132,23 @@ func (c *Config) PlexHandler(w http.ResponseWriter, r *http.Request) { //nolint: } } -// collectSessions is called in a go routine after a plex media.play webhook. -// This reaches back into Plex, asks for sessions and then sends the whole -// payloads (incoming webhook and sessions) over to notifiarr.com. -// SendMeta also collects system snapshot info, so a lot happens here. -func (c *Config) collectSessions(v *plexIncomingWebhook) { - reply, err := c.SendMeta(PlexHook, c.URL, v, true) - if err != nil { - c.Errorf("Sending Plex Sessions to Notifiarr: %v", err) - return - } - - c.plexNotifiarrReplyParserLog(reply, v) -} - // sendPlexWebhook simply relays an incoming "admin" plex webhook to Notifiarr.com. func (c *Config) sendPlexWebhook(v *plexIncomingWebhook) { - _, reply, err := c.SendData(c.URL, &Payload{ //nolint:bodyclose // already closed - Type: PlexHook, - Load: v, - Plex: &plex.Sessions{ - Name: c.Plex.Name, - AccountMap: strings.Split(c.Plex.AccountMap, "|"), - }, - }, true) + resp, err := c.SendData(PlexRoute.Path(EventHook), &Payload{Load: v, Plex: &plex.Sessions{Name: c.Plex.Name}}, true) if err != nil { c.Errorf("Sending Plex Webhook to Notifiarr: %v", err) return } - c.plexNotifiarrReplyParserLog(reply, v) -} - -// This is probably going to break at some point. -func (c *Config) plexNotifiarrReplyParserLog(reply []byte, v *plexIncomingWebhook) { - const fieldPos = 3 - - if fields := strings.Split(string(reply), `"`); len(fields) > fieldPos { - c.Printf("Plex => Notifiarr: %s '%s' => %s (%s)", v.Account.Title, v.Event, v.Metadata.Title, fields[fieldPos]) - } else { - c.Printf("Plex => Notifiarr: %s '%s' => %s", v.Account.Title, v.Event, v.Metadata.Title) - } + c.Printf("Plex => Notifiarr: %s '%s' => %s. Website took %s and replied with: %s, %s", + v.Account.Title, v.Event, v.Metadata.Title, resp.Details.Elapsed, resp.Result, resp.Details.Response) } type appStatus struct { + Lidarr []*conTest `json:"lidarr"` Radarr []*conTest `json:"radarr"` Readarr []*conTest `json:"readarr"` Sonarr []*conTest `json:"sonarr"` - Lidarr []*conTest `json:"lidarr"` Plex []*conTest `json:"plex"` } @@ -192,33 +162,9 @@ type conTest struct { func (c *Config) VersionHandler(r *http.Request) (int, interface{}) { var ( output = c.Info() - rad = make([]*conTest, len(c.Apps.Radarr)) - read = make([]*conTest, len(c.Apps.Readarr)) - son = make([]*conTest, len(c.Apps.Sonarr)) - lid = make([]*conTest, len(c.Apps.Lidarr)) - status = &appStatus{Radarr: rad, Readarr: read, Sonarr: son, Lidarr: lid} + status = appStatsForVersion(c.Apps) ) - for i, app := range c.Apps.Radarr { - stat, err := app.GetSystemStatus() - rad[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} - } - - for i, app := range c.Apps.Readarr { - stat, err := app.GetSystemStatus() - read[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} - } - - for i, app := range c.Apps.Sonarr { - stat, err := app.GetSystemStatus() - son[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} - } - - for i, app := range c.Apps.Lidarr { - stat, err := app.GetSystemStatus() - lid[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} - } - if c.Plex.Configured() { stat, err := c.Plex.GetInfo() if stat == nil { @@ -244,7 +190,44 @@ func (c *Config) VersionHandler(r *http.Request) (int, interface{}) { }} } - output["app_status"] = status + output["appsStatus"] = status + + if host, err := c.GetHostInfoUID(); err != nil { + output["hostError"] = err.Error() + } else { + output["host"] = host + } return http.StatusOK, output } + +func appStatsForVersion(apps *apps.Apps) *appStatus { + var ( + lid = make([]*conTest, len(apps.Lidarr)) + rad = make([]*conTest, len(apps.Radarr)) + read = make([]*conTest, len(apps.Readarr)) + son = make([]*conTest, len(apps.Sonarr)) + ) + + for i, app := range apps.Lidarr { + stat, err := app.GetSystemStatus() + lid[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} + } + + for i, app := range apps.Radarr { + stat, err := app.GetSystemStatus() + rad[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} + } + + for i, app := range apps.Readarr { + stat, err := app.GetSystemStatus() + read[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} + } + + for i, app := range apps.Sonarr { + stat, err := app.GetSystemStatus() + son[i] = &conTest{Instance: i + 1, Up: err == nil, Status: stat} + } + + return &appStatus{Radarr: rad, Readarr: read, Sonarr: son, Lidarr: lid} +} diff --git a/pkg/notifiarr/notifiarr.go b/pkg/notifiarr/notifiarr.go index ac399fa90..e5c30974f 100644 --- a/pkg/notifiarr/notifiarr.go +++ b/pkg/notifiarr/notifiarr.go @@ -7,14 +7,9 @@ package notifiarr import ( - "bytes" - "context" - "encoding/json" - "errors" "fmt" - "io/ioutil" - "log" "net/http" + "os" "strings" "sync" "time" @@ -23,6 +18,7 @@ import ( "github.com/Notifiarr/notifiarr/pkg/logs" "github.com/Notifiarr/notifiarr/pkg/plex" "github.com/Notifiarr/notifiarr/pkg/snapshot" + "github.com/shirou/gopsutil/v3/host" ) // Errors returned by this library. @@ -31,28 +27,103 @@ var ( ErrInvalidResponse = fmt.Errorf("invalid response") ) -// Notifiarr URLs. +// Route is used to give us methods on our route paths. +type Route string + +// Notifiarr URLs. Data sent to these URLs: +/* +api/v1/notification/plex?event=... + api (was plexcron) + user (was plexcron) + cron (was plexcron) + webhook (was plexhook) + movie + episode + +api/v1/notification/services?event=... + api + user + cron + start (only fires on startup) + +api/v1/notification/snapshot?event=... + api + user + cron + +api/v1/notification/dashboard?event=... (requires interval from website/client endpoint) + api + user + cron + +api/v1/notification/stuck?event=... + api + user + cron + +api/v1/user/gaps?app=radarr&event=... + api + user + cron + +api/v2/user/client?event=start + see description https://github.com/Notifiarr/notifiarr/pull/115 + +api/v1/user/trash?app=... + radarr + sonarr +*/ const ( - BaseURL = "https://notifiarr.com" - ProdURL = BaseURL + "/notifier.php" - TestURL = BaseURL + "/notifierTest.php" - DevBaseURL = "http://dev.notifiarr.com" - DevURL = DevBaseURL + "/notifier.php" - ClientRoute = "/api/v1/user/client" - // CFSyncRoute is the webserver route to send sync requests to. - CFSyncRoute = "/api/v1/user/trash" - DashRoute = "/api/v1/user/dashboard" - GapsRoute = "/api/v1/user/gaps" + BaseURL = "https://notifiarr.com" + DevBaseURL = "https://dev.notifiarr.com" + userRoute1 Route = "/api/v1/user" + userRoute2 Route = "/api/v2/user" + ClientRoute Route = userRoute2 + "/client" + CFSyncRoute Route = userRoute1 + "/trash" + GapsRoute Route = userRoute1 + "/gaps" + notifiRoute Route = "/api/v1/notification" + DashRoute Route = notifiRoute + "/dashboard" + StuckRoute Route = notifiRoute + "/stuck" + PlexRoute Route = notifiRoute + "/plex" + SnapRoute Route = notifiRoute + "/snapshot" + SvcRoute Route = notifiRoute + "/services" ) -// These are used as 'source' values in json payloads sent to the webserver. const ( - PlexCron = "plexcron" - SnapCron = "snapcron" - PlexHook = "plexhook" - LogLocal = "loglocal" + ModeDev = "development" + ModeProd = "production" ) +// EventType identifies the type of event that sent a paylaod to notifiarr. +type EventType string + +// These are all our known event types. +const ( + EventCron EventType = "cron" + EventUser EventType = "user" + EventAPI EventType = "api" + EventHook EventType = "webhook" + EventStart EventType = "start" + EventMovie EventType = "movie" + EventEpisode EventType = "episode" + EventPoll EventType = "poll" + EventReload EventType = "reload" +) + +// Path adds parameter to a route path and turns it into a string. +func (r Route) Path(event EventType, params ...string) string { + switch { + case len(params) == 0 && event == "": + return string(r) + case len(params) == 0: + return string(r) + "?event=" + string(event) + case event == "": + return string(r) + "?" + strings.Join(params, "&") + default: + return string(r) + "?" + strings.Join(append(params, "event="+string(event)), "&") + } +} + const ( // DefaultRetries is the number of times to attempt a request to notifiarr.com. // 4 means 5 total tries: 1 try + 4 retries. @@ -66,7 +137,6 @@ const success = "success" // Payload is the outbound payload structure that is sent to Notifiarr for Plex and system snapshot data. type Payload struct { - Type string `json:"eventType"` Plex *plex.Sessions `json:"plex,omitempty"` Snap *snapshot.Snapshot `json:"snapshot,omitempty"` Load *plexIncomingWebhook `json:"payload,omitempty"` @@ -74,58 +144,55 @@ type Payload struct { // Config is the input data needed to send payloads to notifiarr. type Config struct { - Apps *apps.Apps // has API key - Plex *plex.Server // plex sessions - Snap *snapshot.Config // system snapshot data - DashDur time.Duration - Retries int - URL string - BaseURL string - Timeout time.Duration - Trigger Triggers + Apps *apps.Apps // has API key + Plex *plex.Server // plex sessions + Snap *snapshot.Config // system snapshot data + Services *ServiceConfig + Retries int + BaseURL string + Timeout time.Duration + Trigger Triggers + MaxBody int + Sighup chan os.Signal + *logs.Logger // log file writer extras } type extras struct { - clientInfo *ClientInfo - client *httpClient - radarrCF map[int]*cfMapIDpayload - sonarrRP map[int]*cfMapIDpayload - plexTimer *Timer + ciMutex sync.Mutex + *ClientInfo + client *httpClient + radarrCF map[int]*cfMapIDpayload + sonarrRP map[int]*cfMapIDpayload + plexTimer *Timer + hostInfo *host.InfoStat } // Triggers allow trigger actions in the timer routine. type Triggers struct { - stop chan struct{} // Triggered by calling Stop() - syncCF chan chan struct{} // Sync Radarr CF and Sonarr RP - gaps chan string // Send Radarr Collection Gaps - stuck chan string // Stuck Items - plex chan string // Send Plex Sessions - state chan struct{} // Dashboard State - snap chan string // Snapshot - sess chan time.Time // Return Plex Sessions - sessr chan *holder // Session Return Channel + stop *action // Triggered by calling Stop() + sync *action // Sync Radarr CF and Sonarr RP + gaps *action // Send Radarr Collection Gaps + stuck *action // Stuck Items + plex *action // Send Plex Sessions + dash *action // Dashboard State + snap *action // Snapshot + sess chan time.Time // Return Plex Sessions + sessr chan *holder // Session Return Channel } // Start (and log) snapshot and plex cron jobs if they're configured. -func (c *Config) Start(mode string) { - if c.Trigger.stop != nil { - panic("notifiarr timers cannot run twice") - } - +func (c *Config) Setup(mode string) string { switch strings.ToLower(mode) { default: - fallthrough - case "prod", "production": - c.URL = ProdURL c.BaseURL = BaseURL - case "test", "testing": - c.URL = TestURL + case "prod", ModeProd: c.BaseURL = BaseURL - case "dev", "devel", "development": - c.URL = DevURL + mode = ModeProd + case "dev", "devel", ModeDev, "test", "testing": c.BaseURL = DevBaseURL + mode = ModeDev } if c.Retries < 0 { @@ -134,285 +201,81 @@ func (c *Config) Start(mode string) { c.Retries = DefaultRetries } - c.extras.radarrCF = make(map[int]*cfMapIDpayload) - c.extras.sonarrRP = make(map[int]*cfMapIDpayload) - c.extras.plexTimer = &Timer{} - c.extras.client = &httpClient{ - Retries: c.Retries, - Logger: c.ErrorLog, - Client: &http.Client{}, - } - c.Trigger.syncCF = make(chan chan struct{}) - c.Trigger.stuck = make(chan string) - c.Trigger.plex = make(chan string) - c.Trigger.state = make(chan struct{}) - c.Trigger.snap = make(chan string) - c.Trigger.sess = make(chan time.Time) - c.Trigger.gaps = make(chan string) - - go c.runSessionHolder() - c.startTimers() -} - -// Stop snapshot and plex cron jobs. -func (c *Config) Stop() { - if c != nil && c.Trigger.stop != nil { - c.Trigger.stop <- struct{}{} - close(c.Trigger.syncCF) - close(c.Trigger.stuck) - close(c.Trigger.plex) - close(c.Trigger.state) - close(c.Trigger.snap) - close(c.Trigger.gaps) - - defer close(c.Trigger.sess) - c.Trigger.sess = nil - } -} - -// SendMeta is kicked off by the webserver in go routine. -// It's also called by the plex cron (with webhook set to nil). -// This runs after Plex drops off a webhook telling us someone did something. -// This gathers cpu/ram, and waits 10 seconds, then grabs plex sessions. -// It's all POSTed to notifiarr. May be used with a nil Webhook. -func (c *Config) SendMeta(eventType, url string, hook *plexIncomingWebhook, wait bool) ([]byte, error) { - extra := time.Second - if wait { - extra = plex.WaitTime - } - - ctx, cancel := context.WithTimeout(context.Background(), extra+c.Snap.Timeout.Duration) - defer cancel() - - var ( - payload = &Payload{ - Type: eventType, - Load: hook, - Plex: &plex.Sessions{ - Name: c.Plex.Name, - AccountMap: strings.Split(c.Plex.AccountMap, "|"), - }, - } - wg sync.WaitGroup - ) - - rep := make(chan error) - defer close(rep) - - go func() { - for err := range rep { - if err != nil { - c.Errorf("Building Metadata: %v", err) - } - } - }() - - wg.Add(1) - - go func() { - payload.Snap = c.GetMetaSnap(ctx) - wg.Done() // nolint:wsl - }() - - if !wait || !c.Plex.NoActivity { - var err error - if payload.Plex, err = c.GetSessions(wait); err != nil { - rep <- fmt.Errorf("getting sessions: %w", err) + if c.extras.client == nil { + c.extras.client = &httpClient{ + Retries: c.Retries, + Logger: c.ErrorLog, + Client: &http.Client{}, } } - wg.Wait() - - _, e, err := c.SendData(url, payload, true) //nolint:bodyclose // already closed - - return e, err -} - -// GetMetaSnap grabs some basic system info: cpu, memory, username. -func (c *Config) GetMetaSnap(ctx context.Context) *snapshot.Snapshot { - var ( - snap = &snapshot.Snapshot{} - wg sync.WaitGroup - ) - - rep := make(chan error) - defer close(rep) - - go func() { - for err := range rep { - if err != nil { // maybe move this out of this method? - c.Errorf("Building Metadata: %v", err) - } - } - }() - - wg.Add(3) //nolint: gomnd,wsl - go func() { - rep <- snap.GetCPUSample(ctx, true) - wg.Done() //nolint:wsl - }() - go func() { - rep <- snap.GetMemoryUsage(ctx, true) - wg.Done() //nolint:wsl - }() - go func() { - for _, err := range snap.GetLocalData(ctx, false) { - rep <- err - } - wg.Done() //nolint:wsl - }() - - wg.Wait() - - return snap + return mode } -// SendJSON posts a JSON payload to a URL. Returns the response body or an error. -func (c *Config) SendJSON(url string, data []byte) (*http.Response, []byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(data)) - if err != nil { - return nil, nil, fmt.Errorf("creating http request: %w", err) +// Start runs the timers. +func (c *Config) Start() { + if c.Trigger.stop != nil { + panic("notifiarr timers cannot run twice") } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-API-Key", c.Apps.APIKey) - - start := time.Now() - - resp, err := c.client.Do(req) - if err != nil { - c.Debugf("Sent JSON Payload to %s in %s:\n%s\nResponse (0): %s", - url, time.Since(start).Round(time.Microsecond), string(data), err) - return nil, nil, fmt.Errorf("making http request: %w", err) + c.Trigger.stuck = &action{ + Fn: c.sendStuckQueueItems, + Msg: "Checking app queues and sending stuck items.", + C: make(chan EventType, 1), + T: time.NewTicker(stuckDur), } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - - defer func() { - headers := "" - - for k, vs := range resp.Header { - for _, v := range vs { - headers += k + ": " + v + "\n" - } - } - - c.Debugf("Sent JSON Payload to %s in %s:\n%s\nResponse (%s):\n%s\n%s", - url, time.Since(start).Round(time.Microsecond), string(data), resp.Status, headers, string(body)) - }() - - if err != nil { - return resp, body, fmt.Errorf("reading http response body: %w", err) + c.Trigger.plex = &action{ + Fn: c.sendPlexSessions, + Msg: "Gathering and sending Plex Sessions.", + C: make(chan EventType, 1), } - - return resp, body, nil -} - -func (c *Config) GetData(url string) (*http.Response, []byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, nil, fmt.Errorf("creating http request: %w", err) + c.Trigger.gaps = &action{ + Fn: c.sendGaps, + Msg: "Sending Radarr Collection Gaps.", + C: make(chan EventType, 1), } - - req.Header.Set("X-API-Key", c.Apps.APIKey) - - start := time.Now() - - resp, err := c.client.Do(req) - if err != nil { - return nil, nil, fmt.Errorf("making http request: %w", err) + c.Trigger.sync = &action{ + Fn: c.syncCF, + Msg: "Starting Custom Formats and Quality Profiles Sync for Radarr and Sonarr.", + C: make(chan EventType, 1), } - defer resp.Body.Close() - - body, err := ioutil.ReadAll(resp.Body) - - defer func() { - headers := "" - - for k, vs := range resp.Header { - for _, v := range vs { - headers += k + ": " + v + "\n" - } - } - - c.Debugf("Sent GET Request to %s in %s, Response (%s):\n%s\n%s", - url, time.Since(start).Round(time.Microsecond), resp.Status, headers, string(body)) - }() - - if err != nil { - return resp, body, fmt.Errorf("reading http response body: %w", err) + c.Trigger.dash = &action{ + Fn: c.sendDashboardState, + Msg: "Initiating State Collection for Dashboard.", + C: make(chan EventType, 1), } - - return resp, body, nil -} - -// SendData sends raw data to a notifiarr URL as JSON. -func (c *Config) SendData(url string, payload interface{}, pretty bool) (*http.Response, []byte, error) { - var ( - post []byte - err error - ) - - if pretty { - post, err = json.MarshalIndent(payload, "", " ") - } else { - post, err = json.Marshal(payload) + c.Trigger.snap = &action{ + Fn: c.sendSnapshot, + Msg: "Gathering and sending System Snapshot.", + C: make(chan EventType, 1), } - - if err != nil { - return nil, nil, fmt.Errorf("encoding data to JSON (report this bug please): %w", err) + c.Trigger.stop = &action{ + Msg: "Stop Channel is used for reloads and must not have a function.", + C: make(chan EventType), } + c.Trigger.sess = make(chan time.Time, 1) + c.extras.radarrCF = make(map[int]*cfMapIDpayload) + c.extras.sonarrRP = make(map[int]*cfMapIDpayload) + c.extras.plexTimer = &Timer{} - return c.SendJSON(url, post) -} - -// httpClient is our custom http client to wrap Do and provide retries. -type httpClient struct { - Retries int - *log.Logger - *http.Client + go c.runSessionHolder() + c.startTimers() } -// Do performs an http Request with retries and logging! -func (h *httpClient) Do(req *http.Request) (*http.Response, error) { - deadline, ok := req.Context().Deadline() - if !ok { - deadline = time.Now().Add(h.Timeout) +// Stop all internal cron timers and Triggers. +func (c *Config) Stop(event EventType) { + if c == nil { + return } - timeout := time.Until(deadline).Round(time.Millisecond) - - for i := 0; ; i++ { - resp, err := h.Client.Do(req) - if err == nil && resp.StatusCode < http.StatusInternalServerError { - return resp, nil - } else if err == nil { // resp.StatusCode is 500 or higher, make that en error. - body, _ := ioutil.ReadAll(resp.Body) // must read the entire body when err == nil - resp.Body.Close() // do not defer, because we're in a loop. - // shoehorn a non-200 error into the empty http error. - err = fmt.Errorf("%w: %s: %s", ErrNon200, resp.Status, string(body)) - } + c.Print("==> Stopping Notifiarr Timers.") - switch { - case errors.Is(err, context.DeadlineExceeded): - if i == 0 { - return nil, fmt.Errorf("notifiarr.com req timed out after %s: %w", timeout, err) - } - - return nil, fmt.Errorf("[%d/%d] notifiarr.com reqs timed out after %s, giving up: %w", - i+1, h.Retries+1, timeout, err) - case i == h.Retries: - return nil, fmt.Errorf("[%d/%d] notifiarr.com req failed: %w", i+1, h.Retries+1, err) - default: - h.Printf("[%d/%d] Request to Notifiarr.com failed, retrying in %s, error: %v", i+1, h.Retries+1, RetryDelay, err) - time.Sleep(RetryDelay) - } + if c.Trigger.stop == nil { + c.Error("==> Notifiarr Timers cannot be stopped: not running!") + return } + + c.Trigger.stop.C <- event + defer close(c.Trigger.sess) + c.Trigger.sess = nil } diff --git a/pkg/notifiarr/plexcron.go b/pkg/notifiarr/plexcron.go index 08df3af1e..342c3f9a4 100644 --- a/pkg/notifiarr/plexcron.go +++ b/pkg/notifiarr/plexcron.go @@ -12,17 +12,13 @@ import ( // Statuses for an item being played on Plex. const ( statusIgnoring = "ignoring" + statusPaused = "ignoring, paused" statusWatching = "watching" statusSending = "sending" statusError = "error" statusSent = "sent" ) -const ( - movie = "movie" - episode = "episode" -) - type holder struct { sessions *plex.Sessions error error @@ -31,22 +27,37 @@ type holder struct { var ErrNoChannel = fmt.Errorf("no channel to send session request") // SendPlexSessions sends plex sessions in a go routine through a channel. -func (t *Triggers) SendPlexSessions(source string) { +func (t *Triggers) SendPlexSessions(event EventType) { if t.stop == nil { return } - t.plex <- source + t.plex.C <- event } // sendPlexSessions is fired by a timer if plex monitoring is enabled. -func (c *Config) sendPlexSessions(source string) { - if body, err := c.SendMeta(source, c.URL, nil, false); err != nil { - c.Errorf("Sending Plex Sessions to %s: %v", c.URL, err) - } else if fields := strings.Split(string(body), `"`); len(fields) > 3 { //nolint:gomnd - c.Printf("Plex Sessions sent to %s, reply: %s", c.URL, fields[3]) +func (c *Config) sendPlexSessions(event EventType) { + c.collectSessions(event, nil) +} + +// collectSessions is called in a go routine after a plex media.play webhook. +// This reaches back into Plex, asks for sessions and then sends the whole +// payloads (incoming webhook and sessions) over to notifiarr.com. +// SendMeta also collects system snapshot info, so a lot happens here. +func (c *Config) collectSessions(event EventType, v *plexIncomingWebhook) { + wait := false + msg := "" + + if v != nil { + wait = true + msg = " (and webhook)" + } + + if resp, err := c.sendPlexMeta(event, v, wait); err != nil { + c.Errorf("[%s requested] Sending Plex Sessions%s to Notifiarr: %v", event, msg, err) } else { - c.Printf("Plex Sessions sent to %s.", c.URL) + c.Printf("[%s requested] Plex Sessions%s sent to Notifiar. Website took %s and replied with: %s, %s", + event, msg, resp.Details.Elapsed, resp.Result, resp.Details.Response) } } @@ -59,9 +70,9 @@ func (c *Config) GetSessions(wait bool) (*plex.Sessions, error) { } if wait { - c.Trigger.sess <- time.Now().Add(plex.WaitTime) + c.Trigger.sess <- time.Now().Add(c.Plex.Delay.Duration) } else { - c.Trigger.sess <- time.Now().Add(-plex.WaitTime) + c.Trigger.sess <- time.Now().Add(-c.Plex.Delay.Duration) } s := <-c.Trigger.sessr @@ -72,21 +83,19 @@ func (c *Config) GetSessions(wait bool) (*plex.Sessions, error) { func (c *Config) runSessionHolder() { defer c.CapturePanic() - var ( - sessions *plex.Sessions - updated time.Time - err error - ) - - if sessions, err = c.Plex.GetXMLSessions(); err == nil { - updated = time.Now() + sessions, err := c.Plex.GetSessions() // err not used until for loop. + if sessions != nil { + sessions.Updated.Time = time.Now() + if len(sessions.Sessions) > 0 { + c.plexSessionTracker(sessions.Sessions, nil) + } } c.Trigger.sessr = make(chan *holder) defer close(c.Trigger.sessr) for waitUntil := range c.Trigger.sess { - if sessions != nil && err == nil && updated.After(waitUntil) { + if sessions != nil && err == nil && sessions.Updated.After(waitUntil) { c.Trigger.sessr <- &holder{sessions: sessions} continue } @@ -95,20 +104,49 @@ func (c *Config) runSessionHolder() { time.Sleep(t) } - sessions, err = c.Plex.GetXMLSessions() - if err == nil { - updated = time.Now() + var currSessions *plex.Sessions // so we can update the error. + if currSessions, err = c.Plex.GetSessions(); currSessions != nil { + if sessions == nil || len(sessions.Sessions) < 1 { + c.plexSessionTracker(currSessions.Sessions, nil) + } else { + c.plexSessionTracker(currSessions.Sessions, sessions.Sessions) + } + + currSessions.Updated.Time = time.Now() + sessions = currSessions } c.Trigger.sessr <- &holder{sessions: sessions, error: err} } } +// plexSessionTracker checks for state changes between the previous session pull +// and the current session pull. if changes are present, a timestmp is added. +func (c *Config) plexSessionTracker(curr, prev []*plex.Session) { +CURRENT: + for _, currSess := range curr { + // make sure every current session has a start time. + currSess.Player.StateTime.Time = time.Now() + // now check if a current session matches a previous session + for _, prevSess := range prev { + if currSess.Session.ID == prevSess.Session.ID { + // we have a match, check for state change. + if currSess.Player.State == prevSess.Player.State { + // since the state is the same, copy the previous start time. + currSess.Player.StateTime.Time = prevSess.Player.StateTime.Time + } + // we found this current session in previous session list, so go to the next one. + continue CURRENT + } + } + } +} + // This cron tab runs every minute to send a report when a user gets to the end of a movie or tv show. // This is basically a hack to "watch" Plex for when an active item gets to around 90% complete. // This usually means the user has finished watching the item and we can send a "done" notice. // Plex does not send a webhook or identify in any other way when an item is "finished". -func (c *Config) checkForFinishedItems(sent map[string]struct{}) { +func (c *Config) checkPlexFinishedItems(sent map[string]struct{}) { sessions, err := c.GetSessions(false) if err != nil { c.Errorf("[PLEX] Getting Sessions from %s: %v", c.Plex.URL, err) @@ -149,13 +187,15 @@ func (c *Config) checkForFinishedItems(sent map[string]struct{}) { func (c *Config) checkSessionDone(s *plex.Session, pct float64) string { switch { - case c.Plex.MoviesPC > 0 && strings.EqualFold(s.Type, movie): + case s.Player.State != "playing": + return statusPaused + case c.Plex.MoviesPC > 0 && EventType(s.Type) == EventMovie: if pct < float64(c.Plex.MoviesPC) { return statusWatching } return c.sendSessionDone(s) - case c.Plex.SeriesPC > 0 && strings.EqualFold(s.Type, episode): + case c.Plex.SeriesPC > 0 && EventType(s.Type) == EventEpisode: if pct < float64(c.Plex.SeriesPC) { return statusWatching } @@ -172,23 +212,20 @@ func (c *Config) sendSessionDone(s *plex.Session) string { } ctx, cancel := context.WithTimeout(context.Background(), c.Snap.Timeout.Duration) - snap := c.GetMetaSnap(ctx) + snap := c.getMetaSnap(ctx) cancel() //nolint:wsl - _, body, err := c.SendData(c.URL, &Payload{ - Type: "plex_session_complete_" + s.Type, + route := PlexRoute.Path(EventType(s.Type)) + + _, err := c.SendData(route, &Payload{ Snap: snap, - Plex: &plex.Sessions{ - Name: c.Plex.Name, - Sessions: []*plex.Session{s}, - AccountMap: strings.Split(c.Plex.AccountMap, "|"), - }, + Plex: &plex.Sessions{Name: c.Plex.Name, Sessions: []*plex.Session{s}}, }, true) if err != nil { - return statusError + ": sending to " + c.URL + ": " + err.Error() + ": " + string(body) + return statusError + ": sending to " + route + ": " + err.Error() } - return statusSending + " to " + c.URL + return statusSending } func (c *Config) checkPlexAgent(s *plex.Session) error { diff --git a/pkg/notifiarr/snapcron.go b/pkg/notifiarr/snapcron.go index 691bf8442..9e5692f38 100644 --- a/pkg/notifiarr/snapcron.go +++ b/pkg/notifiarr/snapcron.go @@ -1,9 +1,5 @@ package notifiarr -import ( - "strings" -) - func (c *Config) logSnapshotStartup() { var ex string @@ -14,7 +10,7 @@ func (c *Config) logSnapshotStartup() { "uptime": c.Snap.Uptime, "cpumem": c.Snap.CPUMem, "cputemp": c.Snap.CPUTemp, - "zfs": c.Snap.ZFSPools != nil, + "zfs": len(c.Snap.ZFSPools) > 0, "sudo": c.Snap.UseSudo && c.Snap.DriveData, } { if !v { @@ -32,19 +28,19 @@ func (c *Config) logSnapshotStartup() { c.Snap.Interval, c.Snap.Timeout, ex) } -func (t *Triggers) SendSnapshot(source string) { +func (t *Triggers) SendSnapshot(event EventType) { if t.stop == nil { return } - t.snap <- source + t.snap.C <- event } -func (c *Config) sendSnapshot(source string) { +func (c *Config) sendSnapshot(event EventType) { snapshot, errs, debug := c.Snap.GetSnapshot() for _, err := range errs { if err != nil { - c.Errorf("Snapshot: %v", err) + c.Errorf("[%s requested] Snapshot: %v", event, err) } } @@ -55,11 +51,12 @@ func (c *Config) sendSnapshot(source string) { } } - if _, body, err := c.SendData(c.URL, &Payload{Type: source, Snap: snapshot}, true); err != nil { - c.Errorf("Sending snapshot to %s: %v", c.URL, err) - } else if fields := strings.Split(string(body), `"`); len(fields) > 3 { //nolint:gomnd - c.Printf("Systems Snapshot sent to %s, sending again in %s, reply: %s", c.URL, c.Snap.Interval, fields[3]) + resp, err := c.SendData(SnapRoute.Path(event), &Payload{Snap: snapshot}, true) + if err != nil { + c.Errorf("[%s requested] Sending snapshot to Notifiarr: %v", event, err) } else { - c.Printf("Systems Snapshot sent to %s, sending again in %s", c.URL, c.Snap.Interval) + c.Printf("[%s requested] System Snapshot sent to Notifiarr, cron interval: %s. "+ + "Website took %s and replied with: %s, %s", + event, c.Snap.Interval, resp.Details.Elapsed, resp.Result, resp.Details.Response) } } diff --git a/pkg/notifiarr/stuckitems.go b/pkg/notifiarr/stuckitems.go index 51e88cca2..f43275df4 100644 --- a/pkg/notifiarr/stuckitems.go +++ b/pkg/notifiarr/stuckitems.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "golift.io/cnfg" "golift.io/starr/sonarr" ) @@ -17,17 +18,30 @@ const ( completed = "completed" ) -type custom struct { +type appConfig struct { + Instance int `json:"instance"` + Name string `json:"name"` + Stuck bool `json:"stuck"` + Interval cnfg.Duration `json:"interval"` +} + +// appConfigs is the configuration returned from the notifiarr website. +type appConfigs struct { + Lidarr []*appConfig `json:"lidarr"` + Radarr []*appConfig `json:"radarr"` + Readarr []*appConfig `json:"readarr"` + Sonarr []*appConfig `json:"sonarr"` +} + +type ListItem struct { Elapsed time.Duration `json:"elapsed"` - Repeat uint `json:"repeat"` Name string `json:"name"` Queue []interface{} `json:"queue"` } -type ItemList map[int]custom +type ItemList map[int]ListItem type QueuePayload struct { - Type string `json:"type"` Lidarr ItemList `json:"lidarr,omitempty"` Radarr ItemList `json:"radarr,omitempty"` Readarr ItemList `json:"readarr,omitempty"` @@ -48,38 +62,43 @@ func (i ItemList) Empty() bool { return i.Len() < 1 } -func (t *Triggers) SendFinishedQueueItems(url string) { +func (t *Triggers) SendStuckQueueItems(event EventType) { if t.stop == nil { return } - t.stuck <- url + t.stuck.C <- event } -func (c *Config) sendFinishedQueueItems(url string) { +func (c *Config) sendStuckQueueItems(event EventType) { start := time.Now() q := c.getQueues() apps := time.Since(start).Round(time.Millisecond) - if q.Lidarr.Empty() && q.Radarr.Empty() && q.Readarr.Empty() && q.Sonarr.Empty() { + if q == nil || (q.Lidarr.Empty() && q.Radarr.Empty() && q.Readarr.Empty() && q.Sonarr.Empty()) { + c.Printf("[%s requested] No stuck items found to send to Notifiarr.", event) return } - _, _, err := c.SendData(url+ClientRoute, q, true) + resp, err := c.SendData(StuckRoute.Path(event), q, true) elapsed := time.Since(start).Round(time.Millisecond) if err != nil { - c.Errorf("Sending Stuck Queue Items (apps:%s total:%s) (Lidarr: %d, Radarr: %d, Readarr: %d, Sonarr: %d): %v", - apps, elapsed, q.Lidarr.Len(), q.Radarr.Len(), q.Readarr.Len(), q.Sonarr.Len(), err) + c.Errorf("[%s requested] Sending Stuck Queue Items "+ + "(apps:%s total:%s) (Lidarr: %d, Radarr: %d, Readarr: %d, Sonarr: %d): %v", + event, apps, elapsed, q.Lidarr.Len(), q.Radarr.Len(), q.Readarr.Len(), q.Sonarr.Len(), err) } else { - c.Printf("Sent Stuck Items (apps:%s total:%s): Lidarr: %d, Radarr: %d, Readarr: %d, Sonarr: %d", - apps, elapsed, q.Lidarr.Len(), q.Radarr.Len(), q.Readarr.Len(), q.Sonarr.Len()) + c.Printf("[%s requested] Sent Stuck Items to Notifiarr "+ + "(apps:%s total:%s): Lidarr: %d, Radarr: %d, Readarr: %d, Sonarr: %d. "+ + "Website took %s and replied with: %s, %s", + event, apps, elapsed, q.Lidarr.Len(), q.Radarr.Len(), q.Readarr.Len(), q.Sonarr.Len(), + resp.Details.Elapsed, resp.Result, resp.Details.Response) } } // getQueues fires a routine for each app type and tries to get a lot of data fast! func (c *Config) getQueues() *QueuePayload { - q := &QueuePayload{Type: "queue"} + q := &QueuePayload{} var wg sync.WaitGroup @@ -110,7 +129,9 @@ func (c *Config) getFinishedItemsLidarr() ItemList { stuck := make(ItemList) for i, l := range c.Apps.Lidarr { - if l.CheckQ == nil { + instance := i + 1 + + if !l.StuckItem { continue } @@ -118,11 +139,11 @@ func (c *Config) getFinishedItemsLidarr() ItemList { queue, err := l.GetQueue(getItemsMax) if err != nil { - c.Errorf("Getting Lidarr Queue (%d): %v", i, err) + c.Errorf("Getting Lidarr Queue (%d): %v", instance, err) continue } - instance := stuck[i+1] + app := stuck[instance] for _, item := range queue.Records { if s := strings.ToLower(item.Status); s != completed && s != warning && @@ -131,16 +152,15 @@ func (c *Config) getFinishedItemsLidarr() ItemList { } item.Quality = nil - instance.Queue = append(instance.Queue, item) + app.Queue = append(app.Queue, item) } - instance.Name = l.Name - instance.Repeat = *l.CheckQ - instance.Elapsed = time.Since(start) - stuck[i+1] = instance + app.Name = l.Name + app.Elapsed = time.Since(start) + stuck[instance] = app c.Debugf("Checking Lidarr (%d) Queue for Stuck Items, queue size: %d, stuck: %d", - i+1, len(queue.Records), len(stuck[i+1].Queue)) + instance, len(queue.Records), len(stuck[instance].Queue)) } return stuck @@ -150,7 +170,9 @@ func (c *Config) getFinishedItemsRadarr() ItemList { stuck := make(ItemList) for i, l := range c.Apps.Radarr { - if l.CheckQ == nil { + instance := i + 1 + + if !l.StuckItem { continue } @@ -158,11 +180,11 @@ func (c *Config) getFinishedItemsRadarr() ItemList { queue, err := l.GetQueue(getItemsMax, 1) if err != nil { - c.Errorf("Getting Radarr Queue (%d): %v", i, err) + c.Errorf("Getting Radarr Queue (%d): %v", instance, err) continue } - instance := stuck[i+1] + app := stuck[instance] for _, item := range queue.Records { if s := strings.ToLower(item.Status); s != completed && s != warning && @@ -173,16 +195,15 @@ func (c *Config) getFinishedItemsRadarr() ItemList { item.Quality = nil item.CustomFormats = nil item.Languages = nil - instance.Queue = append(instance.Queue, item) + app.Queue = append(app.Queue, item) } - instance.Name = l.Name - instance.Repeat = *l.CheckQ - instance.Elapsed = time.Since(start) - stuck[i+1] = instance + app.Name = l.Name + app.Elapsed = time.Since(start) + stuck[instance] = app c.Debugf("Checking Radarr (%d) Queue for Stuck Items, queue size: %d, stuck: %d", - i+1, len(queue.Records), len(stuck[i+1].Queue)) + instance, len(queue.Records), len(stuck[instance].Queue)) } return stuck @@ -192,7 +213,9 @@ func (c *Config) getFinishedItemsReadarr() ItemList { stuck := make(ItemList) for i, l := range c.Apps.Readarr { - if l.CheckQ == nil { + instance := i + 1 + + if !l.StuckItem { continue } @@ -200,11 +223,11 @@ func (c *Config) getFinishedItemsReadarr() ItemList { queue, err := l.GetQueue(getItemsMax) if err != nil { - c.Errorf("Getting Readarr Queue (%d): %v", i, err) + c.Errorf("Getting Readarr Queue (%d): %v", instance, err) continue } - instance := stuck[i+1] + app := stuck[instance] for _, item := range queue.Records { if s := strings.ToLower(item.Status); s != completed && s != warning && @@ -213,16 +236,15 @@ func (c *Config) getFinishedItemsReadarr() ItemList { } item.Quality = nil - instance.Queue = append(instance.Queue, item) + app.Queue = append(app.Queue, item) } - instance.Name = l.Name - instance.Repeat = *l.CheckQ - instance.Elapsed = time.Since(start) - stuck[i+1] = instance + app.Name = l.Name + app.Elapsed = time.Since(start) + stuck[instance] = app c.Debugf("Checking Readarr (%d) Queue for Stuck Items, queue size: %d, stuck: %d", - i+1, len(queue.Records), len(stuck[i+1].Queue)) + instance, len(queue.Records), len(stuck[instance].Queue)) } return stuck @@ -232,7 +254,9 @@ func (c *Config) getFinishedItemsSonarr() ItemList { stuck := make(ItemList) for i, l := range c.Apps.Sonarr { - if l.CheckQ == nil { + instance := i + 1 + + if !l.StuckItem { continue } @@ -240,13 +264,13 @@ func (c *Config) getFinishedItemsSonarr() ItemList { queue, err := l.GetQueue(getItemsMax, 1) if err != nil { - c.Errorf("Getting Sonarr Queue (%d): %v", i, err) + c.Errorf("Getting Sonarr Queue (%d): %v", instance, err) continue } // repeatStomper is used to collapse duplicate download IDs. repeatStomper := make(map[string]*sonarr.QueueRecord) - instance := stuck[i+1] + app := stuck[instance] for _, item := range queue.Records { if s := strings.ToLower(item.Status); s != completed && s != warning && @@ -259,16 +283,15 @@ func (c *Config) getFinishedItemsSonarr() ItemList { item.Quality = nil item.Language = nil repeatStomper[item.DownloadID] = item - instance.Queue = append(instance.Queue, item) + app.Queue = append(app.Queue, item) } - instance.Name = l.Name - instance.Repeat = *l.CheckQ - instance.Elapsed = time.Since(start) - stuck[i+1] = instance + app.Name = l.Name + app.Elapsed = time.Since(start) + stuck[instance] = app c.Debugf("Checking Sonarr (%d) Queue for Stuck Items, queue size: %d, stuck: %d", - i+1, len(queue.Records), len(stuck[i+1].Queue)) + instance, len(queue.Records), len(stuck[instance].Queue)) } return stuck diff --git a/pkg/notifiarr/timers.go b/pkg/notifiarr/timers.go index ebe32b715..25148975c 100644 --- a/pkg/notifiarr/timers.go +++ b/pkg/notifiarr/timers.go @@ -1,161 +1,231 @@ package notifiarr import ( + "fmt" + "reflect" "time" + + "github.com/Notifiarr/notifiarr/pkg/ui" + "golift.io/cnfg" ) const ( - cfSyncTimer = 30 * time.Minute - minDashTimer = 30 * time.Minute - stuckTimer = 5*time.Minute + 327*time.Millisecond + stuckDur = 5*time.Minute + 1327*time.Millisecond + pollDur = 4*time.Minute + 977*time.Millisecond ) -func (c *Config) startTimers() { - if c.Trigger.stop != nil { - return // Already running. +// timerConfig defines a custom GET timer from the website. +// Used to offload crons to clients. +type timerConfig struct { + Name string `json:"name"` // name of action. + Interval cnfg.Duration `json:"interval"` // how often to GET this URI. + URI string `json:"endpoint"` // endpoint for the URI. + Desc string `json:"description"` + ch chan EventType + getdata func(string) (*Response, error) + errorf func(string, ...interface{}) +} + +type action struct { + Fn func(EventType) // most actions use this + SFn func(map[string]struct{}) // this is just for plex sessions. + Msg string // msg is printed if provided, otherwise ignored. + C chan EventType // if provided, T is optional + T *time.Ticker // if provided, C is optional. +} + +// Run fires a custom cron timer (GET). +func (t *timerConfig) Run(event EventType) { + if t.ch == nil { + return } - c.Trigger.stop = make(chan struct{}) - snapTimer := c.getSnapTimer() - syncTimer, cronTimer, gapsTimer := c.getSyncGapsAndCronTimers() - plexTimer1, plexTimer2 := c.getPlexTimers() - stuckItemTimer := time.NewTicker(stuckTimer) - dashTimer := c.getDashTimer() + t.ch <- event +} - go c.runTimerLoop(snapTimer, syncTimer, plexTimer1, plexTimer2, stuckItemTimer, dashTimer, cronTimer, gapsTimer) +func (t *timerConfig) run(event EventType) { + if _, err := t.getdata(t.URI); err != nil { + t.errorf("[%s requested] Custom Timer Request for %s failed: %v", event, t.URI, err) + } } -func (c *Config) getDashTimer() *time.Ticker { - dashTimer := &time.Ticker{} +func (t *timerConfig) setup(c *Config) { + t.URI, t.errorf, t.getdata = c.BaseURL+"/"+t.URI, c.Errorf, c.GetData + t.ch = make(chan EventType, 1) +} - if c.DashDur > 0 && c.DashDur < minDashTimer { - c.DashDur = minDashTimer +func (c *Config) startTimers() { + var ( + _, err = c.GetClientInfo(EventStart) + actions = c.getClientInfoTimers(err == nil) // sync, gaps, dashboard, custom + plex = c.getPlexTimers() + cases = []reflect.SelectCase{} + combine = []*action{} + timer int + trigger int + ) + + if c.Snap.Interval.Duration > 0 { + c.Trigger.snap.T = time.NewTicker(c.Snap.Interval.Duration) + c.logSnapshotStartup() } - if c.DashDur > 0 { - c.Printf("==> Sending Current State Data for Dashboard every %v", c.DashDur) - dashTimer = time.NewTicker(c.DashDur) + if c.ClientInfo == nil || c.ClientInfo.Actions.Poll { + c.Printf("==> Started Notifiarr Poller, (have_clientinfo=%v) interval: %v, timeout: %v", + c.ClientInfo != nil, pollDur, c.Timeout) + actions = append(actions, //nolint:wsl + &action{Msg: "Polling Notifiarr for new settings.", Fn: c.pollForReload, T: time.NewTicker(pollDur)}) } - return dashTimer + for _, t := range append(actions, + plex, c.Trigger.snap, c.Trigger.gaps, c.Trigger.plex, + c.Trigger.dash, c.Trigger.stop, c.Trigger.sync, c.Trigger.stuck) { + if t == nil { + continue + } + + // Since we may add up to 2 actions per list item, duplicate the pointer in a new combined list. + if t.C != nil { + cases = append(cases, reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(t.C)}) + combine = append(combine, t) + trigger++ + } + + if t.T != nil { + cases = append(cases, reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(t.T.C)}) + combine = append(combine, t) + timer++ + } + } + + go c.runTimerLoop(combine, cases) + c.Printf("==> Started %d Notifiarr Timers with %d Triggers", timer, trigger) } -func (c *Config) getSyncGapsAndCronTimers() (*time.Ticker, *time.Ticker, *time.Ticker) { - cronTimer := &time.Ticker{C: make(<-chan time.Time)} - gapsTimer := &time.Ticker{C: make(<-chan time.Time)} +func (c *Config) getClientInfoTimers(haveInfo bool) []*action { + if !haveInfo { + return nil + } - ci, err := c.GetClientInfo() - if err != nil || (ci.Message.CFSync < 1 && ci.Message.RPSync < 1) { - return cronTimer, cronTimer, gapsTimer + if c.Actions.Gaps.Interval.Duration > 0 && len(c.Apps.Radarr) > 0 { + c.Trigger.gaps.T = time.NewTicker(c.Actions.Gaps.Interval.Duration) + c.Printf("==> Collection Gaps Timer Enabled, interval: %s", c.Actions.Gaps.Interval) } - if len(ci.Timers) > 0 { - c.Printf("==> Custom Timers Enabled: %d timers provided", len(ci.Timers)) - cronTimer = time.NewTicker(time.Minute) //nolint:wsl + if c.Actions.Sync.Interval.Duration > 0 && (len(c.Apps.Radarr) > 0 || len(c.Apps.Sonarr) > 0) { + c.Trigger.sync.T = time.NewTicker(c.Actions.Sync.Interval.Duration) + c.Printf("==> Keeping %d Radarr Custom Formats and %d Sonarr Release Profiles synced, interval: %s", + c.Actions.Sync.Radarr, c.Actions.Sync.Sonarr, c.Actions.Sync.Interval) } - if ci.Message.Gaps.Interval > 0 { - c.Printf("==> Collection Gaps Timer Enabled, interval: %dm", ci.Message.Gaps.Interval) - gapsTimer = time.NewTicker(time.Minute * time.Duration(ci.Message.Gaps.Interval)) + if c.Actions.Dashboard.Interval.Duration > 0 { + c.Trigger.dash.T = time.NewTicker(c.Actions.Dashboard.Interval.Duration) + c.Printf("==> Sending Current State Data for Dashboard every %s", c.Actions.Dashboard.Interval) } - c.Printf("==> Keeping %d Radarr Custom Formats and %d Sonarr Release Profiles synced", - ci.Message.CFSync, ci.Message.RPSync) + if len(c.Actions.Custom) > 0 { // This is not directly triggerable. + c.Printf("==> Custom Timers Enabled: %d timers provided", len(c.Actions.Custom)) + } - return time.NewTicker(cfSyncTimer), cronTimer, gapsTimer -} + customActions := []*action{} -func (c *Config) getPlexTimers() (*time.Ticker, *time.Ticker) { - empty := &time.Ticker{C: make(<-chan time.Time)} + for _, custom := range c.Actions.Custom { + custom.setup(c) - if !c.Plex.Configured() || c.Plex.Interval.Duration < 1 { - return empty, empty - } + var ticker *time.Ticker - // Add a little splay to the timers to not hit plex at the same time too often. - plexTimer1 := time.NewTicker(c.Plex.Interval.Duration + 139*time.Millisecond) - c.Printf("==> Plex Sessions Collection Started, URL: %s, interval: %v, timeout: %v, webhook cooldown: %v", - c.Plex.URL, c.Plex.Interval, c.Plex.Timeout, c.Plex.Cooldown) + if custom.Interval.Duration < time.Minute { + c.Errorf("Website provided custom cron interval under 1 minute. Ignored! Interval: %s Name: %s, URI: %s", + custom.Interval, custom.Name, custom.URI) + } else { + ticker = time.NewTicker(custom.Interval.Duration) + } - if c.Plex.MoviesPC != 0 || c.Plex.SeriesPC != 0 { - c.Printf("==> Plex Completed Items Started, URL: %s, interval: 1m, timeout: %v movies: %d%%, series: %d%%", - c.Plex.URL, c.Plex.Timeout, c.Plex.MoviesPC, c.Plex.SeriesPC) - return plexTimer1, time.NewTicker(time.Minute + 179*time.Millisecond) + customActions = append(customActions, &action{ + Fn: custom.run, + C: custom.ch, + Msg: fmt.Sprintf("Running Custom Cron Timer '%s' GET %s", custom.Name, custom.URI), + T: ticker, + }) } - return plexTimer1, empty + return customActions } -func (c *Config) getSnapTimer() *time.Ticker { - if c.Snap.Interval.Duration < 1 { - return &time.Ticker{C: make(<-chan time.Time)} +func (c *Config) getPlexTimers() *action { + if !c.Plex.Configured() { + return nil } - c.logSnapshotStartup() + if c.Plex.Interval.Duration > 0 { + // Add a little splay to the timers to not hit plex at the same time too often. + c.Printf("==> Plex Sessions Collection Started, URL: %s, interval: %v, timeout: %v, webhook cooldown: %v, delay: %v", + c.Plex.URL, c.Plex.Interval, c.Plex.Timeout, c.Plex.Cooldown, c.Plex.Delay) + c.Trigger.plex.T = time.NewTicker(c.Plex.Interval.Duration + 139*time.Millisecond) // nolint:wsl + } - return time.NewTicker(c.Snap.Interval.Duration) + if c.Plex.MoviesPC != 0 || c.Plex.SeriesPC != 0 { + c.Printf("==> Plex Completed Items Started, URL: %s, interval: 1m, timeout: %v movies: %d%%, series: %d%%", + c.Plex.URL, c.Plex.Timeout, c.Plex.MoviesPC, c.Plex.SeriesPC) + + return &action{SFn: c.checkPlexFinishedItems, T: time.NewTicker(time.Minute + 179*time.Millisecond)} + } + + return nil } // runTimerLoop does all of the timer/cron routines for starr apps and plex. // Many of the menu items and trigger handlers feed into this routine too. -// nolint:cyclop -func (c *Config) runTimerLoop(snapTimer, syncTimer, plexTimer1, plexTimer2, - stuckTimer, dashTimer, cronTimer, gapsTimer *time.Ticker) { - defer c.stopTimerLoop(snapTimer, syncTimer, plexTimer1, plexTimer2, stuckTimer, dashTimer, cronTimer, gapsTimer) +func (c *Config) runTimerLoop(actions []*action, cases []reflect.SelectCase) { + defer c.stopTimerLoop(actions) for sent := make(map[string]struct{}); ; { - select { - case <-cronTimer.C: - for _, timer := range c.extras.clientInfo.Timers { - if timer.Ready() { - if _, _, err := c.GetData(timer.URI); err != nil { - c.Errorf("Custom Timer Request for %s failed: %v", timer.URI, err) - } - break //nolint:wsl - } - } - case <-gapsTimer.C: - c.sendGaps("timer") - case source := <-c.Trigger.gaps: - c.sendGaps(source) - case reply := <-c.Trigger.syncCF: - c.syncCF(reply) - case <-syncTimer.C: - c.syncCF(nil) - case source := <-c.Trigger.snap: - c.sendSnapshot(source) - case <-snapTimer.C: - c.sendSnapshot(SnapCron) - case source := <-c.Trigger.plex: - c.sendPlexSessions(source) - case <-plexTimer1.C: - c.sendPlexSessions(PlexCron) - case <-stuckTimer.C: - c.sendFinishedQueueItems(c.BaseURL) - case url := <-c.Trigger.stuck: - c.sendFinishedQueueItems(url) - case <-plexTimer2.C: - c.checkForFinishedItems(sent) - case <-c.Trigger.state: - c.Print("API Trigger: Gathering current state for dashboard.") - c.getState() - case <-dashTimer.C: - c.Print("Gathering current state for dashboard.") - c.getState() - case <-c.Trigger.stop: + index, val, _ := reflect.Select(cases) + + action := actions[index] + if action.Fn == nil && action.SFn == nil { // stop channel has no Functions return } + + var event EventType + if _, ok := val.Interface().(time.Time); ok { + event = EventCron + } else if event, ok = val.Interface().(EventType); !ok { + event = "unknown" + } + + if event == EventUser { + if err := ui.Notify(action.Msg); err != nil { + c.Errorf("Displaying toast notification: %v", err) + } + } + + if action.Msg != "" { + c.Printf("[%s requested] %s", event, action.Msg) + } + + if action.Fn != nil { + action.Fn(event) + } else { + action.SFn(sent) + } } } // stopTimerLoop is defered by runTimerLoop. -func (c *Config) stopTimerLoop(timers ...*time.Ticker) { - defer close(c.Trigger.stop) +func (c *Config) stopTimerLoop(actions []*action) { + defer c.CapturePanic() c.Trigger.stop = nil - c.CapturePanic() + for _, action := range actions { + if action.C != nil { + close(action.C) + action.C = nil + } - for _, timer := range timers { - timer.Stop() + if action.T != nil { + action.T.Stop() + action.T = nil + } } } diff --git a/pkg/notifiarr/website.go b/pkg/notifiarr/website.go new file mode 100644 index 000000000..69262d7b3 --- /dev/null +++ b/pkg/notifiarr/website.go @@ -0,0 +1,366 @@ +package notifiarr + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "sync" + "time" + + "github.com/Notifiarr/notifiarr/pkg/plex" + "github.com/Notifiarr/notifiarr/pkg/snapshot" + "golift.io/cnfg" +) + +// Response is what notifiarr replies to our requests with. +/* try this +{ + "response": "success", + "message": { + "response": { + "instance": 1, + "debug": null + }, + "started": "23:57:03", + "finished": "23:57:03", + "elapsed": "0s" + } +} + +{ + "response": "success", + "message": { + "response": "Service status cron processed.", + "started": "00:04:15", + "finished": "00:04:15", + "elapsed": "0s" + } +} + +{ + "response": "success", + "message": { + "response": "Channel stats cron processed.", + "started": "00:04:31", + "finished": "00:04:36", + "elapsed": "5s" + } +} + +{ + "response": "success", + "message": { + "response": "Dashboard payload processed.", + "started": "00:02:04", + "finished": "00:02:11", + "elapsed": "7s" + } +} +*/ +// nitsua: all responses should be that way.. but response might not always be an object. +type Response struct { + Result string `json:"result"` + Details struct { + Response json.RawMessage `json:"response"` // can be anything. type it out later. + Started time.Time `json:"started"` + Finished time.Time `json:"finished"` + Elapsed cnfg.Duration `json:"elapsed"` + } `json:"details"` +} + +// sendPlexMeta is kicked off by the webserver in go routine. +// It's also called by the plex cron (with webhook set to nil). +// This runs after Plex drops off a webhook telling us someone did something. +// This gathers cpu/ram, and waits 10 seconds, then grabs plex sessions. +// It's all POSTed to notifiarr. May be used with a nil Webhook. +func (c *Config) sendPlexMeta(event EventType, hook *plexIncomingWebhook, wait bool) (*Response, error) { + extra := time.Second + if wait { + extra = c.Plex.Delay.Duration + } + + ctx, cancel := context.WithTimeout(context.Background(), extra+c.Snap.Timeout.Duration) + defer cancel() + + var ( + payload = &Payload{Load: hook, Plex: &plex.Sessions{Name: c.Plex.Name}} + wg sync.WaitGroup + ) + + rep := make(chan error) + defer close(rep) + + go func() { + for err := range rep { + if err != nil { + c.Errorf("Building Metadata: %v", err) + } + } + }() + + wg.Add(1) + + go func() { + payload.Snap = c.getMetaSnap(ctx) + wg.Done() // nolint:wsl + }() + + if !wait || !c.Plex.NoActivity { + var err error + if payload.Plex, err = c.GetSessions(wait); err != nil { + rep <- fmt.Errorf("getting sessions: %w", err) + } + } + + wg.Wait() + + return c.SendData(PlexRoute.Path(event), payload, true) +} + +// getMetaSnap grabs some basic system info: cpu, memory, username. +func (c *Config) getMetaSnap(ctx context.Context) *snapshot.Snapshot { + var ( + snap = &snapshot.Snapshot{} + wg sync.WaitGroup + ) + + rep := make(chan error) + defer close(rep) + + go func() { + for err := range rep { + if err != nil { // maybe move this out of this method? + c.Errorf("Building Metadata: %v", err) + } + } + }() + + wg.Add(3) //nolint: gomnd,wsl + go func() { + rep <- snap.GetCPUSample(ctx, true) + wg.Done() //nolint:wsl + }() + go func() { + rep <- snap.GetMemoryUsage(ctx, true) + wg.Done() //nolint:wsl + }() + go func() { + for _, err := range snap.GetLocalData(ctx, false) { + rep <- err + } + wg.Done() //nolint:wsl + }() + + wg.Wait() + + return snap +} + +func (c *Config) GetData(url string) (*Response, error) { + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating http request: %w", err) + } + + req.Header.Set("X-API-Key", c.Apps.APIKey) + + start := time.Now() + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("making http request: %w", err) + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + + defer c.debughttplog(resp, url, start, "", string(body)) + + if err != nil { + return nil, fmt.Errorf("reading http response body: %w", err) + } + + return unmarshalResponse(url, resp.StatusCode, body) +} + +// SendData sends raw data to a notifiarr URL as JSON. +func (c *Config) SendData(uri string, payload interface{}, log bool) (*Response, error) { + var ( + post []byte + err error + ) + + if data, err := json.Marshal(payload); err == nil { + var torn map[string]interface{} + if err := json.Unmarshal(data, &torn); err == nil { + if torn["host"], err = c.GetHostInfoUID(); err != nil { + c.Errorf("Host Info Unknown: %v", err) + } + + payload = torn + } + } + + if log { + post, err = json.MarshalIndent(payload, "", " ") + } else { + post, err = json.Marshal(payload) + } + + if err != nil { + return nil, fmt.Errorf("encoding data to JSON (report this bug please): %w", err) + } + + code, body, err := c.sendJSON(c.BaseURL+uri, post, log) + if err != nil { + return nil, err + } + + return unmarshalResponse(c.BaseURL+uri, code, body) +} + +// unmarshalResponse attempts to turn the reply from notifiarr.com into structured data. +func unmarshalResponse(url string, code int, body []byte) (*Response, error) { + var r Response + err := json.Unmarshal(body, &r) + + if code < http.StatusOK || code > http.StatusIMUsed { + if err != nil { + return nil, fmt.Errorf("%w: %s: %d %s (unmarshal error: %v)", + ErrNon200, url, code, http.StatusText(code), err) + } + + return nil, fmt.Errorf("%w: %s: %d %s, %s: %s", + ErrNon200, url, code, http.StatusText(code), r.Result, r.Details.Response) + } + + if err != nil { + return nil, fmt.Errorf("converting json response: %w", err) + } + + return &r, nil +} + +// sendJSON posts a JSON payload to a URL. Returns the response body or an error. +func (c *Config) sendJSON(url string, data []byte, log bool) (int, []byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(data)) + if err != nil { + return 0, nil, fmt.Errorf("creating http request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", c.Apps.APIKey) + + start := time.Now() + + resp, err := c.client.Do(req) + if err != nil { + c.debughttplog(nil, url, start, string(data), "") + return 0, nil, fmt.Errorf("making http request: %w", err) + } + defer resp.Body.Close() + + body, err := ioutil.ReadAll(resp.Body) + + if log { + defer c.debughttplog(resp, url, start, string(data), string(body)) + } else { + defer c.debughttplog(resp, url, start, "", string(body)) + } + + if err != nil { + return resp.StatusCode, body, fmt.Errorf("reading http response body: %w", err) + } + + return resp.StatusCode, body, nil +} + +// httpClient is our custom http client to wrap Do and provide retries. +type httpClient struct { + Retries int + *log.Logger + *http.Client +} + +// Do performs an http Request with retries and logging! +func (h *httpClient) Do(req *http.Request) (*http.Response, error) { + deadline, ok := req.Context().Deadline() + if !ok { + deadline = time.Now().Add(h.Timeout) + } + + timeout := time.Until(deadline).Round(time.Millisecond) + + for i := 0; ; i++ { + resp, err := h.Client.Do(req) + if err == nil { + if resp.StatusCode < http.StatusInternalServerError { + return resp, nil + } + + // resp.StatusCode is 500 or higher, make that en error. + size, _ := io.Copy(io.Discard, resp.Body) // must read the entire body when err == nil + resp.Body.Close() // do not defer, because we're in a loop. + // shoehorn a non-200 error into the empty http error. + err = fmt.Errorf("%w: %s: %d bytes, %s", ErrNon200, req.URL, size, resp.Status) + } + + switch { + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + if i == 0 { + return resp, fmt.Errorf("notifiarr req timed out after %s: %s: %w", timeout, req.URL, err) + } + + return resp, fmt.Errorf("[%d/%d] Notifiarr req timed out after %s, giving up: %w", + i+1, h.Retries+1, timeout, err) + case i == h.Retries: + return resp, fmt.Errorf("[%d/%d] Notifiarr req failed: %w", i+1, h.Retries+1, err) + default: + h.Printf("[%d/%d] Notifiarr req failed, retrying in %s, error: %v", i+1, h.Retries+1, RetryDelay, err) + time.Sleep(RetryDelay) + } + } +} + +func (c *Config) debughttplog(resp *http.Response, url string, start time.Time, data, body string) { + headers := "" + status := "0" + + if resp != nil { + status = resp.Status + + for k, vs := range resp.Header { + for _, v := range vs { + headers += k + ": " + v + "\n" + } + } + } + + if c.MaxBody > 0 && len(body) > c.MaxBody { + body = fmt.Sprintf("%s ", body[:c.MaxBody], c.MaxBody) + } + + if c.MaxBody > 0 && len(data) > c.MaxBody { + data = fmt.Sprintf("%s ", data[:c.MaxBody], c.MaxBody) + } + + if data == "" { + c.Debugf("Sent GET Request to %s in %s, Response (%s):\n%s\n%s", + url, time.Since(start).Round(time.Microsecond), status, headers, body) + } else { + c.Debugf("Sent JSON Payload to %s in %s:\n%s\nResponse (%s):\n%s\n%s", + url, time.Since(start).Round(time.Microsecond), data, status, headers, body) + } +} diff --git a/pkg/plex/handlers.go b/pkg/plex/handlers.go index ccf62f405..9ed9195c1 100644 --- a/pkg/plex/handlers.go +++ b/pkg/plex/handlers.go @@ -32,10 +32,10 @@ func (s *Server) HandleKillSession(r *http.Request) (int, interface{}) { reason = mux.Vars(r)["reason"] ) - err := s.KillSessionWithContext(ctx, sessionID, reason) + _, err := s.KillSessionWithContext(ctx, sessionID, reason) if err != nil { return http.StatusInternalServerError, fmt.Errorf("unable to kill session (%s@%d): %w", sessionID, plexID, err) } - return http.StatusOK, "kilt" + return http.StatusOK, fmt.Sprintf("kilt session '%s' with reason: %s", sessionID, reason) } diff --git a/pkg/plex/plex.go b/pkg/plex/plex.go index c2b25189f..15c6bcfcc 100644 --- a/pkg/plex/plex.go +++ b/pkg/plex/plex.go @@ -20,17 +20,17 @@ import ( // Server is the Plex configuration from a config file. // Without a URL or Token, nothing works and this package is unused. type Server struct { - Timeout cnfg.Duration `toml:"timeout" xml:"timeout"` - Interval cnfg.Duration `toml:"interval" xml:"interval"` - URL string `toml:"url" xml:"url"` - Token string `toml:"token" xml:"token"` - AccountMap string `toml:"account_map" xml:"account_map"` - Name string `toml:"-" xml:"server"` - ReturnJSON bool `toml:"return_json" xml:"return_json"` - NoActivity bool `toml:"no_activity" xml:"no_activity"` - Cooldown cnfg.Duration `toml:"cooldown" xml:"cooldown"` - SeriesPC uint `toml:"series_percent_complete" xml:"series_percent_complete"` - MoviesPC uint `toml:"movies_percent_complete" xml:"movies_percent_complete"` + Timeout cnfg.Duration `toml:"timeout" xml:"timeout" json:"timeout"` + Interval cnfg.Duration `toml:"interval" xml:"interval" json:"interval"` + URL string `toml:"url" xml:"url" json:"url"` + Token string `toml:"token" xml:"token" json:"token"` + AccountMap string `toml:"account_map" xml:"account_map" json:"accountMap"` + Name string `toml:"-" xml:"-" json:"-"` + NoActivity bool `toml:"no_activity" xml:"no_activity" json:"noActivity"` + Delay cnfg.Duration `toml:"activity_delay" xml:"activity_delay" json:"activityDelay"` + Cooldown cnfg.Duration `toml:"cooldown" xml:"cooldown" json:"cooldown"` + SeriesPC uint `toml:"series_percent_complete" xml:"series_percent_complete" json:"seriesPc"` + MoviesPC uint `toml:"movies_percent_complete" xml:"movies_percent_complete" json:"moviesPc"` client *http.Client } @@ -44,9 +44,9 @@ const ( maximumComplete = 99 ) -// WaitTime is the recommended wait time to pull plex sessions after a webhook. -// Only used when NoActivity = false. -const WaitTime = 10 * time.Second +// defaultWaitTime is the recommended wait time to pull plex sessions after a webhook. +// Only used when NoActivity = false, and used as default if Delay=0. +const defaultWaitTime = 10 * time.Second // ErrNoURLToken is returned when there is no token or URL. var ErrNoURLToken = fmt.Errorf("token or URL for Plex missing") @@ -89,13 +89,13 @@ func (s *Server) Validate() { //nolint:cyclop if s.Cooldown.Duration < s.Timeout.Duration { s.Cooldown.Duration = s.Timeout.Duration } -} -func (s *Server) getPlexURL(ctx context.Context, url string, headers map[string]string) ([]byte, error) { - if s == nil || s.URL == "" || s.Token == "" { - return nil, ErrNoURLToken + if s.Delay.Duration == 0 { + s.Delay.Duration = defaultWaitTime } +} +func (s *Server) getPlexURL(ctx context.Context, url string, headers map[string]string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("creating http request: %w", err) diff --git a/pkg/plex/sections.go b/pkg/plex/sections.go index ac98f266b..8a5786039 100644 --- a/pkg/plex/sections.go +++ b/pkg/plex/sections.go @@ -17,38 +17,38 @@ type LibrarySection struct { MediaTagPrefix string `json:"mediaTagPrefix"` MediaTagVersion int `json:"mediaTagVersion"` Metadata []struct { - RatingKey string `json:"ratingKey"` - Key string `json:"key"` - ParentRatingKey string `json:"parentRatingKey,omitempty"` - GrandparentRatingKey string `json:"grandparentRatingKey,omitempty"` - GUID string `json:"guid"` - ParentGUID string `json:"parentGuid,omitempty"` - GrandparentGUID string `json:"grandparentGuid,omitempty"` - Type string `json:"type"` - Title string `json:"title"` - GrandparentKey string `json:"grandparentKey,omitempty"` - ParentKey string `json:"parentKey,omitempty"` - LibrarySectionTitle string `json:"librarySectionTitle"` - LibrarySectionID int `json:"librarySectionID"` - LibrarySectionKey string `json:"librarySectionKey"` - GrandparentTitle string `json:"grandparentTitle,omitempty"` - ParentTitle string `json:"parentTitle,omitempty"` - ContentRating string `json:"contentRating"` - Summary string `json:"summary"` - Index int `json:"index,omitempty"` - ParentIndex int `json:"parentIndex,omitempty"` - Rating int `json:"rating,omitempty"` - Year int `json:"year,omitempty"` - Thumb string `json:"thumb"` - Art string `json:"art"` - ParentThumb string `json:"parentThumb,omitempty"` - GrandparentThumb string `json:"grandparentThumb,omitempty"` - GrandparentArt string `json:"grandparentArt,omitempty"` - GrandparentTheme string `json:"grandparentTheme,omitempty"` - Duration int `json:"duration"` - OriginallyAvailableAt string `json:"originallyAvailableAt"` - AddedAt int `json:"addedAt"` - UpdatedAt int `json:"updatedAt"` + RatingKey string `json:"ratingKey"` + Key string `json:"key"` + ParentRatingKey string `json:"parentRatingKey,omitempty"` + GrandparentRatingKey string `json:"grandparentRatingKey,omitempty"` + GUID string `json:"guid"` + ParentGUID string `json:"parentGuid,omitempty"` + GrandparentGUID string `json:"grandparentGuid,omitempty"` + Type string `json:"type"` + Title string `json:"title"` + GrandparentKey string `json:"grandparentKey,omitempty"` + ParentKey string `json:"parentKey,omitempty"` + LibrarySectionTitle string `json:"librarySectionTitle"` + LibrarySectionID int `json:"librarySectionID"` + LibrarySectionKey string `json:"librarySectionKey"` + GrandparentTitle string `json:"grandparentTitle,omitempty"` + ParentTitle string `json:"parentTitle,omitempty"` + ContentRating string `json:"contentRating"` + Summary string `json:"summary"` + Index int `json:"index,omitempty"` + ParentIndex int `json:"parentIndex,omitempty"` + Rating float64 `json:"rating,omitempty"` + Year int `json:"year,omitempty"` + Thumb string `json:"thumb"` + Art string `json:"art"` + ParentThumb string `json:"parentThumb,omitempty"` + GrandparentThumb string `json:"grandparentThumb,omitempty"` + GrandparentArt string `json:"grandparentArt,omitempty"` + GrandparentTheme string `json:"grandparentTheme,omitempty"` + Duration int `json:"duration"` + OriginallyAvailableAt string `json:"originallyAvailableAt"` + AddedAt int `json:"addedAt"` + UpdatedAt int `json:"updatedAt"` Media []struct { ID int `json:"id"` Duration int `json:"duration"` diff --git a/pkg/plex/sessions.go b/pkg/plex/sessions.go index 4af20fbf1..3d244ce96 100644 --- a/pkg/plex/sessions.go +++ b/pkg/plex/sessions.go @@ -6,64 +6,19 @@ import ( "fmt" "io/ioutil" "net/http" - "strings" - "time" ) // Sessions is the config input data. type Sessions struct { - Name string `json:"server"` - AccountMap []string `json:"account_map"` - Sessions []*Session `json:"sessions"` - XML string `json:"sessions_xml,omitempty"` - Updated time.Time `json:"update_time,omitempty"` - Age time.Duration `json:"update_age,omitempty"` + Name string `json:"server"` + HostID string `json:"hostId"` + Sessions []*Session `json:"sessions"` + Updated structDur `json:"updateTime,omitempty"` } // ErrBadStatus is returned when plex returns an invalid status code. var ErrBadStatus = fmt.Errorf("status code not 200") -// GetXMLSessions returns the Plex sessions in XML format. -func (s *Server) GetXMLSessions() (*Sessions, error) { - ctx, cancel := context.WithTimeout(context.Background(), s.Timeout.Duration) - defer cancel() - - var ( - v struct { - MediaContainer struct { - Sessions []*Session `json:"Metadata"` - } `json:"MediaContainer"` - } - - sessions = &Sessions{ - Name: s.Name, - AccountMap: strings.Split(s.AccountMap, "|"), - } - ) - - body, err := s.getPlexURL(ctx, s.URL+"/status/sessions", map[string]string{"Accept": "application/xml"}) - if err != nil { - return sessions, fmt.Errorf("%w: %s", err, string(body)) - } - - if s.ReturnJSON { - body, err := s.getPlexURL(ctx, s.URL+"/status/sessions", nil) - if err != nil { - return sessions, fmt.Errorf("%w: %s", err, string(body)) - } - - // log.Print("DEBUG PLEX PAYLOAD:\n", string(data)) - if err = json.Unmarshal(body, &v); err != nil { - return sessions, fmt.Errorf("parsing plex sessions: %w", err) - } - } - - sessions.Sessions = v.MediaContainer.Sessions - sessions.XML = string(body) - - return sessions, nil -} - // GetSessions returns the Plex sessions in JSON format, no timeout. func (s *Server) GetSessions() (*Sessions, error) { return s.GetSessionsWithContext(context.Background()) @@ -71,16 +26,17 @@ func (s *Server) GetSessions() (*Sessions, error) { // GetSessionsWithContext returns the Plex sessions in JSON format. func (s *Server) GetSessionsWithContext(ctx context.Context) (*Sessions, error) { + if !s.Configured() { + return nil, ErrNoURLToken + } + var ( v struct { MediaContainer struct { Sessions []*Session `json:"Metadata"` } `json:"MediaContainer"` } - sessions = &Sessions{ - Name: s.Name, - AccountMap: strings.Split(s.AccountMap, "|"), - } + sessions = &Sessions{Name: s.Name} ) ctx, cancel := context.WithTimeout(ctx, s.Timeout.Duration) @@ -91,9 +47,8 @@ func (s *Server) GetSessionsWithContext(ctx context.Context) (*Sessions, error) return sessions, fmt.Errorf("%w: %s", err, string(body)) } - // log.Print("DEBUG PLEX PAYLOAD:\n", string(data)) if err = json.Unmarshal(body, &v); err != nil { - return sessions, fmt.Errorf("parsing plex sessions: %w", err) + return sessions, fmt.Errorf("parsing plex sessions (TRY UPGRADING PLEX): %w: %s", err, string(body)) } sessions.Sessions = v.MediaContainer.Sessions @@ -102,9 +57,9 @@ func (s *Server) GetSessionsWithContext(ctx context.Context) (*Sessions, error) } // KillSessionWithContext kills a Plex session. -func (s *Server) KillSessionWithContext(ctx context.Context, sessionID, reason string) error { - if s == nil || s.URL == "" || s.Token == "" { - return ErrNoURLToken +func (s *Server) KillSessionWithContext(ctx context.Context, sessionID, reason string) ([]byte, error) { + if !s.Configured() { + return nil, ErrNoURLToken } ctx, cancel := context.WithTimeout(ctx, s.Timeout.Duration) @@ -112,7 +67,7 @@ func (s *Server) KillSessionWithContext(ctx context.Context, sessionID, reason s req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.URL+"/status/sessions/terminate", nil) if err != nil { - return fmt.Errorf("creating http request: %w", err) + return nil, fmt.Errorf("creating http request: %w", err) } req.Header.Set("X-Plex-Token", s.Token) @@ -124,24 +79,24 @@ func (s *Server) KillSessionWithContext(ctx context.Context, sessionID, reason s resp, err := s.getClient().Do(req) if err != nil { - return fmt.Errorf("making http request: %w", err) + return nil, fmt.Errorf("making http request: %w", err) } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading http response: %w", err) + return body, fmt.Errorf("reading http response: %w", err) } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("%w: %s", ErrBadStatus, string(body)) + return body, ErrBadStatus } - return nil + return body, nil } // KillSession kills a Plex session. -func (s *Server) KillSession(sessionID, reason string) error { +func (s *Server) KillSession(sessionID, reason string) ([]byte, error) { return s.KillSessionWithContext(context.Background(), sessionID, reason) } diff --git a/pkg/plex/types.go b/pkg/plex/types.go index 303e7fcd5..97c09387c 100644 --- a/pkg/plex/types.go +++ b/pkg/plex/types.go @@ -1,5 +1,10 @@ package plex +import ( + "fmt" + "time" +) + /* This file contains all the types for Plex Sessions API response. */ // Session is a Plex json struct. @@ -7,12 +12,12 @@ type Session struct { User User `json:"User"` Player Player `json:"Player"` TranscodeSession Transcode `json:"TranscodeSession"` - Added string `json:"addedAt"` + Added int64 `json:"addedAt"` Art string `json:"art"` - AudienceRating string `json:"audienceRating"` + AudienceRating float64 `json:"audienceRating"` AudienceRatingImg string `json:"audienceRatingImage"` ContentRating string `json:"contentRating"` - Duration float64 `json:"duration,string"` + Duration float64 `json:"duration"` GUID string `json:"guid"` GrandparentArt string `json:"grandparentArt"` GrandparentGUID string `json:"grandparentGuid"` @@ -21,21 +26,21 @@ type Session struct { GrandparentTheme string `json:"grandparentTheme"` GrandparentThumb string `json:"grandparentThumb"` GrandparentTitle string `json:"grandparentTitle"` - Index string `json:"index"` + Index int `json:"index"` Key string `json:"key"` - LastViewed int64 `json:"lastViewedAt,string"` + LastViewed int64 `json:"lastViewedAt"` LibrarySectionID string `json:"librarySectionID"` LibrarySectionKey string `json:"librarySectionKey"` LibrarySectionTitle string `json:"librarySectionTitle"` OriginallyAvailable string `json:"originallyAvailableAt"` ParentGUID string `json:"parentGuid"` - ParentIndex string `json:"parentIndex"` + ParentIndex int `json:"parentIndex"` ParentKey string `json:"parentKey"` ParentRatingKey string `json:"parentRatingKey"` ParentThumb string `json:"parentThumb"` ParentTitle string `json:"parentTitle"` PrimaryExtraKey string `json:"primaryExtraKey"` - Rating string `json:"rating"` + Rating float64 `json:"rating"` RatingImage string `json:"ratingImage"` RatingKey string `json:"ratingKey"` SessionKey string `json:"sessionKey"` @@ -45,21 +50,21 @@ type Session struct { Title string `json:"title"` TitleSort string `json:"titleSort"` Type string `json:"type"` - Updated int64 `json:"updatedAt,string"` - ViewCount string `json:"viewCount"` - ViewOffset float64 `json:"viewOffset,string"` - Year string `json:"year"` + Updated int64 `json:"updatedAt"` + ViewCount int64 `json:"viewCount"` + ViewOffset float64 `json:"viewOffset"` + Year int64 `json:"year"` Session struct { Bandwidth int64 `json:"bandwidth"` ID string `json:"id"` Location string `json:"location"` } `json:"Session"` - GuID []*GUID `json:"Guid,omitempty"` + GuID []*GUID `json:"Guid,omitempty"` + Media []*Media `json:"Media,omitempty"` /* Notifiarr does not need these. :shrug: Country []*Country `json:"Country"` Director []*Director `json:"Director"` Genre []*Genre `json:"Genre"` - Media []*Media `json:"Media"` Producer []*Producer `json:"Producer"` Role []*Role `json:"Role"` Similar []*Similar `json:"Similar"` @@ -76,31 +81,40 @@ type User struct { // Player is part of a Plex Session. type Player struct { - Address string `json:"address"` - Device string `json:"device"` - MachineID string `json:"machineIdentifier"` - Model string `json:"model"` - Platform string `json:"platform"` - PlatformVer string `json:"platformVersion"` - Product string `json:"product"` - Profile string `json:"profile"` - PublicAddr string `json:"remotePublicAddress"` - State string `json:"state"` - Title string `json:"title"` - UserID int64 `json:"userID"` - Vendor string `json:"vendor"` - Version string `json:"version"` - Relayed bool `json:"relayed"` - Local bool `json:"local"` - Secure bool `json:"secure"` + Address string `json:"address"` + Device string `json:"device"` + MachineID string `json:"machineIdentifier"` + Model string `json:"model"` + Platform string `json:"platform"` + PlatformVer string `json:"platformVersion"` + Product string `json:"product"` + Profile string `json:"profile"` + PublicAddr string `json:"remotePublicAddress"` + State string `json:"state"` + StateTime structDur `json:"stateTime"` // this is not a plex item. We calculate this. + Title string `json:"title"` + UserID int64 `json:"userID"` + Vendor string `json:"vendor"` + Version string `json:"version"` + Relayed bool `json:"relayed"` + Local bool `json:"local"` + Secure bool `json:"secure"` +} + +type structDur struct { + time.Time +} + +func (s *structDur) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`%.0f`, time.Since(s.Time).Seconds())), nil } // Country is part of a Plex Session. type Country struct { - Count string `json:"count"` - Filter string `json:"filter"` - ID interface{} `json:"id"` - Tag string `json:"tag"` + Count string `json:"count"` + Filter string `json:"filter"` + ID string `json:"id"` + Tag string `json:"tag"` } // Director is part of a Plex Session. @@ -121,84 +135,86 @@ type Genre struct { // MediaStream is part of a Plex Session. type MediaStream struct { AudioChannelLayout string `json:"audioChannelLayout,omitempty"` - BitDepth string `json:"bitDepth,omitempty"` - Bitrate float64 `json:"bitrate,string"` + BitDepth int `json:"bitDepth,omitempty"` + Bitrate float64 `json:"bitrate"` BitrateMode string `json:"bitrateMode,omitempty"` - Channels string `json:"channels,omitempty"` + Channels int `json:"channels,omitempty"` ChromaLocation string `json:"chromaLocation,omitempty"` ChromaSubsampling string `json:"chromaSubsampling,omitempty"` Codec string `json:"codec"` - CodedHeight string `json:"codedHeight,omitempty"` - CodedWidth string `json:"codedWidth,omitempty"` + CodedHeight int64 `json:"codedHeight,omitempty"` + CodedWidth int64 `json:"codedWidth,omitempty"` ColorPrimaries string `json:"colorPrimaries,omitempty"` ColorTrc string `json:"colorTrc,omitempty"` Decision string `json:"decision"` - Default string `json:"default,omitempty"` + Default bool `json:"default,omitempty"` DisplayTitle string `json:"displayTitle"` ExtDisplayTitle string `json:"extendedDisplayTitle"` - FrameRate float64 `json:"frameRate,omitempty,string"` - HasScalingMatrix string `json:"hasScalingMatrix,omitempty"` - Height int64 `json:"height,omitempty,string"` + FrameRate float64 `json:"frameRate,omitempty"` + HasScalingMatrix bool `json:"hasScalingMatrix,omitempty"` + Height int64 `json:"height,omitempty"` ID string `json:"id"` - Index string `json:"index"` + Index int `json:"index"` Language string `json:"language,omitempty"` LanguageCode string `json:"languageCode,omitempty"` - Level string `json:"level,omitempty"` + Level int `json:"level,omitempty"` Location string `json:"location"` Profile string `json:"profile"` - RefFrames string `json:"refFrames,omitempty"` - SamplingRate string `json:"samplingRate,omitempty"` + RefFrames int `json:"refFrames,omitempty"` + SamplingRate int `json:"samplingRate,omitempty"` ScanType string `json:"scanType,omitempty"` - Selected string `json:"selected,omitempty"` - StreamType string `json:"streamType"` - Width int64 `json:"width,omitempty,string"` + Selected bool `json:"selected,omitempty"` + StreamType int `json:"streamType"` + Width int64 `json:"width,omitempty"` + LanguageTag string `json:"languageTag,omitempty"` } // MediaPart is part of a Plex Session. type MediaPart struct { AudioProfile string `json:"audioProfile"` - Bitrate int64 `json:"bitrate,string"` + Bitrate float64 `json:"bitrate"` Container string `json:"container"` Decision string `json:"decision"` - Duration float64 `json:"duration,string"` + Duration float64 `json:"duration"` File string `json:"file"` - Height int64 `json:"height,string"` + Height int64 `json:"height"` ID string `json:"id"` Indexes string `json:"indexes"` Key string `json:"key"` Protocol string `json:"protocol"` Selected bool `json:"selected"` - Size string `json:"size"` - StreamingOptmzd string `json:"optimizedForStreaming"` + Size int64 `json:"size"` + StreamingOptmzd bool `json:"optimizedForStreaming"` VideoProfile string `json:"videoProfile"` - Width int64 `json:"width,string"` + Width int64 `json:"width"` Stream []*MediaStream `json:"Stream"` } // Media is part of a Plex Session. type Media struct { AspectRatio string `json:"aspectRatio"` - AudioChannels int `json:"audioChannels,string"` + AudioChannels int `json:"audioChannels"` AudioCodec string `json:"audioCodec"` AudioProfile string `json:"audioProfile"` - Bitrate int64 `json:"bitrate,string"` + Bitrate float64 `json:"bitrate"` Container string `json:"container"` - Duration float64 `json:"duration,string"` - Height int64 `json:"height,string"` + Duration float64 `json:"duration"` + Height int64 `json:"height"` ID string `json:"id"` Protocol string `json:"protocol"` - StreamingOptmzd string `json:"optimizedForStreaming"` + StreamingOptmzd bool `json:"optimizedForStreaming"` VideoCodec string `json:"videoCodec"` VideoFrameRate string `json:"videoFrameRate"` VideoProfile string `json:"videoProfile"` VideoResolution string `json:"videoResolution"` - Width int64 `json:"width,string"` + Width int64 `json:"width"` Selected bool `json:"selected"` Part []*MediaPart `json:"Part"` } // Producer is part of a Plex Session. type Producer struct { + Count string `json:"count"` Filter string `json:"filter"` ID string `json:"id"` Tag string `json:"tag"` @@ -216,29 +232,29 @@ type Role struct { // Transcode is part of a Plex Session. type Transcode struct { - AudioChannels int `json:"audioChannels"` - AudioCodec string `json:"audioCodec"` - AudioDecision string `json:"audioDecision"` - Container string `json:"container"` - Context string `json:"context"` - Duration int64 `json:"duration"` - Key string `json:"key"` - MaxOffsetAvailable string `json:"maxOffsetAvailable"` - MinOffsetAvailable string `json:"minOffsetAvailable"` - Progress string `json:"progress"` - Protocol string `json:"protocol"` - Remaining int64 `json:"remaining"` - Size int64 `json:"size"` - SourceAudioCodec string `json:"sourceAudioCodec"` - SourceVideoCodec string `json:"sourceVideoCodec"` - Speed string `json:"speed"` - TimeStamp string `json:"timeStamp"` - VideoCodec string `json:"videoCodec"` - VideoDecision string `json:"videoDecision"` - Throttled bool `json:"throttled"` - Complete bool `json:"complete"` - XcodeHwFullPipeline bool `json:"transcodeHwFullPipeline"` - XcodeHwRequested bool `json:"transcodeHwRequested"` + AudioChannels int `json:"audioChannels"` + AudioCodec string `json:"audioCodec"` + AudioDecision string `json:"audioDecision"` + Container string `json:"container"` + Context string `json:"context"` + Duration int64 `json:"duration"` + Key string `json:"key"` + MaxOffsetAvailable float64 `json:"maxOffsetAvailable"` + MinOffsetAvailable float64 `json:"minOffsetAvailable"` + Progress float64 `json:"progress"` + Protocol string `json:"protocol"` + Remaining int64 `json:"remaining"` + Size int64 `json:"size"` + SourceAudioCodec string `json:"sourceAudioCodec"` + SourceVideoCodec string `json:"sourceVideoCodec"` + Speed float64 `json:"speed"` + TimeStamp float64 `json:"timeStamp"` + VideoCodec string `json:"videoCodec"` + VideoDecision string `json:"videoDecision"` + Throttled bool `json:"throttled"` + Complete bool `json:"complete"` + XcodeHwFullPipeline bool `json:"transcodeHwFullPipeline"` + XcodeHwRequested bool `json:"transcodeHwRequested"` } // Writer is part of a Plex Session. diff --git a/pkg/services/apps.go b/pkg/services/apps.go index bf17bc869..3b5799503 100644 --- a/pkg/services/apps.go +++ b/pkg/services/apps.go @@ -5,9 +5,19 @@ import ( ) // collectApps turns app configs into service checks if they have a name. -func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop +func (c *Config) collectApps() []*Service { svcs := []*Service{} + svcs = c.collectLidarrApps(svcs) + svcs = c.collectRadarrApps(svcs) + svcs = c.collectReadarrApps(svcs) + svcs = c.collectSonarrApps(svcs) + svcs = c.collectDownloadApps(svcs) + svcs = c.collectTautulliApp(svcs) + return svcs +} + +func (c *Config) collectLidarrApps(svcs []*Service) []*Service { for _, a := range c.Apps.Lidarr { if a.Interval.Duration == 0 { a.Interval.Duration = DefaultCheckInterval @@ -25,6 +35,10 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + return svcs +} + +func (c *Config) collectRadarrApps(svcs []*Service) []*Service { for _, a := range c.Apps.Radarr { if a.Interval.Duration == 0 { a.Interval.Duration = DefaultCheckInterval @@ -42,6 +56,10 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + return svcs +} + +func (c *Config) collectReadarrApps(svcs []*Service) []*Service { for _, a := range c.Apps.Readarr { if a.Interval.Duration == 0 { a.Interval.Duration = DefaultCheckInterval @@ -59,6 +77,10 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + return svcs +} + +func (c *Config) collectSonarrApps(svcs []*Service) []*Service { for _, a := range c.Apps.Sonarr { if a.Interval.Duration == 0 { a.Interval.Duration = DefaultCheckInterval @@ -76,6 +98,11 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + return svcs +} + +func (c *Config) collectDownloadApps(svcs []*Service) []*Service { + // Deluge instances. for _, d := range c.Apps.Deluge { if d.Interval.Duration == 0 { d.Interval.Duration = DefaultCheckInterval @@ -93,6 +120,7 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + // Qbittorrent instances. for _, q := range c.Apps.Qbit { if q.Interval.Duration == 0 { q.Interval.Duration = DefaultCheckInterval @@ -110,5 +138,43 @@ func (c *Config) collectApps() []*Service { //nolint:funlen,cyclop } } + // SabNBZd instances. + for _, s := range c.Apps.SabNZB { + if s.Interval.Duration == 0 { + s.Interval.Duration = DefaultCheckInterval + } + + if s.Name != "" { + svcs = append(svcs, &Service{ + Name: s.Name, + Type: CheckHTTP, + Value: s.URL + "/api?mode=version&apikey=" + s.APIKey, + Expect: "200", + Timeout: cnfg.Duration{Duration: s.Timeout.Duration}, + Interval: s.Interval, + }) + } + } + + return svcs +} + +func (c *Config) collectTautulliApp(svcs []*Service) []*Service { + // Tautulli instance (1). + if t := c.Apps.Tautulli; t != nil && t.URL != "" && t.Name != "" { + if t.Interval.Duration == 0 { + t.Interval.Duration = DefaultCheckInterval + } + + svcs = append(svcs, &Service{ + Name: t.Name, + Type: CheckHTTP, + Value: t.URL + "/api/v2?cmd=status&apikey=" + t.APIKey, + Expect: "200", + Timeout: t.Timeout, + Interval: t.Interval, + }) + } + return svcs } diff --git a/pkg/services/checks.go b/pkg/services/checks.go index 8741e87a7..3d0aa95f2 100644 --- a/pkg/services/checks.go +++ b/pkg/services/checks.go @@ -132,12 +132,13 @@ func (s *Service) checkHTTP() *result { return r } - if len(body) > maxBody { - body = body[:maxBody] + b := string(body) + if len(b) > maxBody { + b = b[:maxBody] } r.state = StateCritical - r.output = resp.Status + ": " + strings.TrimSpace(string(body)) + r.output = resp.Status + ": " + strings.TrimSpace(b) return r } diff --git a/pkg/services/config.go b/pkg/services/config.go index eff0d1489..c2a5388be 100644 --- a/pkg/services/config.go +++ b/pkg/services/config.go @@ -10,7 +10,7 @@ import ( "golift.io/cnfg" ) -// Defaults. +// Services Defaults. const ( DefaultSendInterval = 10 * time.Minute MinimumSendInterval = DefaultSendInterval / 2 @@ -20,10 +20,9 @@ const ( DefaultTimeout = 10 * MinimumTimeout MaximumParallel = 10 DefaultBuffer = 1000 - NotifiarrEventType = "service_checks" ) -// Errors returned by this package. +// Errors returned by this Services package. var ( ErrNoName = fmt.Errorf("service check is missing a unique name") ErrNoCheck = fmt.Errorf("service check is missing a check value") @@ -31,7 +30,7 @@ var ( ErrBadTCP = fmt.Errorf("tcp checks must have an ip:port or host:port combo; the :port is required") ) -// Config for this plugin comes from a config file. +// Config for this Services plugin comes from a config file. type Config struct { Interval cnfg.Duration `toml:"interval" xml:"interval"` Parallel uint `toml:"parallel" xml:"parallel"` @@ -44,7 +43,7 @@ type Config struct { checks chan *Service done chan bool stopChan chan struct{} - triggerChan chan *Source + triggerChan chan notifiarr.EventType } // CheckType locks us into a few specific types of checks. @@ -71,10 +70,10 @@ const ( // Results is sent to Notifiarr. type Results struct { - Type string `json:"eventType"` - What string `json:"what"` - Interval float64 `json:"interval"` - Svcs []*CheckResult `json:"services"` + Type string `json:"eventType"` + What notifiarr.EventType `json:"what"` + Interval float64 `json:"interval"` + Svcs []*CheckResult `json:"services"` } // CheckResult represents the status of a service. @@ -103,9 +102,3 @@ type Service struct { lastCheck time.Time proc *procExpect // only used for process checks. } - -// Source is used to pass a source and destination for service checks (from a trigger). -type Source struct { - Name string - URL string -} diff --git a/pkg/services/interface.go b/pkg/services/interface.go index 9d44c840d..f1941f9c8 100644 --- a/pkg/services/interface.go +++ b/pkg/services/interface.go @@ -1,15 +1,19 @@ package services import ( - "encoding/json" "time" + + "github.com/Notifiarr/notifiarr/pkg/notifiarr" ) // runChecks runs checks from an external package. -func (c *Config) RunChecks(source *Source) { - if !c.Disabled { - c.triggerChan <- source +func (c *Config) RunChecks(source notifiarr.EventType) { + if c.triggerChan == nil || c.stopChan == nil { + c.Errorf("Cannot run service checks. Go routine is not running.") + return } + + c.triggerChan <- source } // runChecks runs checks that are due. Passing true, runs them even if they're not due. @@ -54,15 +58,15 @@ func (c *Config) getResults() []*CheckResult { } // SendResults sends a set of Results to Notifiarr. -func (c *Config) SendResults(url string, results *Results) { - results.Type = NotifiarrEventType +func (c *Config) SendResults(results *Results) { results.Interval = c.Interval.Seconds() - data, _ := json.MarshalIndent(results, "", " ") - if _, _, err := c.Notifiarr.SendJSON(url, data); err != nil { - c.Errorf("Sending service check update to %s: %v", url, err) + if _, err := c.Notifiarr.SendData(notifiarr.SvcRoute.Path(results.What), results, true); err != nil { + c.Errorf("Sending service check update to Notifiarr, event: %s, buffer: %d/%d, error: %v", + results.What, len(c.checks), cap(c.checks), err) } else { - c.Printf("Sent %d service check states to %s", len(results.Svcs), url) + c.Printf("Sent %d service check states to Notifiarr, event: %s, buffer: %d/%d", + len(results.Svcs), results.What, len(c.checks), cap(c.checks)) } } diff --git a/pkg/services/services.go b/pkg/services/services.go index 5e19d5b6c..8a412e444 100644 --- a/pkg/services/services.go +++ b/pkg/services/services.go @@ -11,32 +11,71 @@ import ( "github.com/Notifiarr/notifiarr/pkg/notifiarr" ) -// Start begins the service check routines. -func (c *Config) Start(services []*Service) error { +func (c *Config) Setup(services []*Service) (*notifiarr.ServiceConfig, error) { + if c.Parallel > MaximumParallel { + c.Parallel = MaximumParallel + } else if c.Parallel == 0 { + c.Parallel = 1 + } + + if c.Interval.Duration == 0 { + c.Interval.Duration = DefaultSendInterval + } else if c.Interval.Duration < MinimumSendInterval { + c.Interval.Duration = MinimumSendInterval + } + services = append(services, c.collectApps()...) - if c.Disabled || len(services) == 0 { + if len(services) == 0 { c.Disabled = true - return nil - } else if err := c.setup(services); err != nil { - return err } - c.start() + return c.setup(services) +} + +func (c *Config) setup(services []*Service) (*notifiarr.ServiceConfig, error) { + c.services = make(map[string]*Service) + scnfg := ¬ifiarr.ServiceConfig{ + Interval: c.Interval, + Parallel: c.Parallel, + Disabled: c.Disabled, + Checks: make([]*notifiarr.ServiceCheck, len(services)), + } + + for i, check := range services { + if err := services[i].validate(); err != nil { + return nil, err + } + + // Add this validated service to our service map. + c.services[services[i].Name] = services[i] + scnfg.Checks[i] = ¬ifiarr.ServiceCheck{ + Name: check.Name, + Type: string(check.Type), + Expect: check.Expect, + Timeout: check.Timeout, + Interval: check.Interval, + } + } - return nil + return scnfg, nil } -// start runs Parallel checkers and the check reporter. -func (c *Config) start() { +// Start begins the service check routines. +// Runs Parallel checkers and the check reporter. +func (c *Config) Start() { if c.LogFile != "" { c.Logger = logs.CustomLog(c.LogFile, "Services") - c.Printf("==> Service Checks Log File: %s", c.LogFile) + } - for i := range c.services { - c.services[i].log = c.Logger - } + for i := range c.services { + c.services[i].log = c.Logger } + c.checks = make(chan *Service, DefaultBuffer) + c.done = make(chan bool) + c.stopChan = make(chan struct{}) + c.triggerChan = make(chan notifiarr.EventType) + for i := uint(0); i < c.Parallel; i++ { go func() { defer c.CapturePanic() @@ -55,52 +94,35 @@ func (c *Config) start() { } go c.runServiceChecker() - c.Printf("==> Service Checker Started! %d services, interval: %s, parallel: %d", - len(c.services), c.Interval, c.Parallel) + + word := "Started" + if c.Disabled { + word = "Disabled" + } + + c.Printf("==> Service Checker %s! %d services, interval: %s, parallel: %d", + word, len(c.services), c.Interval, c.Parallel) } func (c *Config) runServiceChecker() { - ticker := time.NewTicker(c.Interval.Duration) - second := time.NewTicker(10 * time.Second) //nolint:gomnd + defer c.CapturePanic() + + ticker := &time.Ticker{C: make(<-chan time.Time)} + second := &time.Ticker{C: make(<-chan time.Time)} - defer func() { - c.CapturePanic() - second.Stop() - ticker.Stop() - }() + if !c.Disabled { + ticker = time.NewTicker(c.Interval.Duration) + defer ticker.Stop() - c.runChecks(true) - c.SendResults(notifiarr.ProdURL, &Results{ - What: "start", - Svcs: c.getResults(), - }) + second = time.NewTicker(10 * time.Second) //nolint:gomnd + defer second.Stop() + + c.runChecks(true) + c.SendResults(&Results{What: notifiarr.EventStart, Svcs: c.getResults()}) + } for { select { - case <-second.C: - c.runChecks(false) - case <-ticker.C: - c.SendResults(notifiarr.ProdURL, &Results{ - What: "timer", - Svcs: c.getResults(), - }) - case source := <-c.triggerChan: - c.runChecks(true) - - if source.URL == "" { - data, _ := json.MarshalIndent(&Results{ - What: "log", - Svcs: c.getResults(), - Type: NotifiarrEventType, - Interval: c.Interval.Seconds(), - }, "", " ") - c.Print("Payload (log only):", string(data)) - } else { - c.SendResults(source.URL, &Results{ - What: source.Name, - Svcs: c.getResults(), - }) - } case <-c.stopChan: for i := uint(0); i < c.Parallel; i++ { c.checks <- nil @@ -108,42 +130,25 @@ func (c *Config) runServiceChecker() { } c.stopChan <- struct{}{} + c.Printf("==> Service Checker Stopped!") return - } - } -} - -func (c *Config) setup(services []*Service) error { - c.services = make(map[string]*Service) - c.checks = make(chan *Service, DefaultBuffer) - c.done = make(chan bool) - c.stopChan = make(chan struct{}) - c.triggerChan = make(chan *Source) + case <-ticker.C: + c.SendResults(&Results{What: notifiarr.EventCron, Svcs: c.getResults()}) + case event := <-c.triggerChan: + c.Debugf("Running all service checks via event: %s, buffer: %d/%d", event, len(c.checks), cap(c.checks)) + c.runChecks(true) - for i := range services { - services[i].log = c.Logger - if err := services[i].validate(); err != nil { - return err + if event != "log" { + c.SendResults(&Results{What: event, Svcs: c.getResults()}) + } else { + data, _ := json.MarshalIndent(&Results{Svcs: c.getResults(), Interval: c.Interval.Seconds()}, "", " ") + c.Debug("Service Checks Payload (log only):", string(data)) + } + case <-second.C: + c.runChecks(false) } - - // Add this validated service to our service map. - c.services[services[i].Name] = services[i] } - - if c.Parallel > MaximumParallel { - c.Parallel = MaximumParallel - } else if c.Parallel == 0 { - c.Parallel = 1 - } - - if c.Interval.Duration == 0 { - c.Interval.Duration = DefaultSendInterval - } else if c.Interval.Duration < MinimumSendInterval { - c.Interval.Duration = MinimumSendInterval - } - - return nil } // Stop ends all service checker routines. diff --git a/pkg/snapshot/diskusage.go b/pkg/snapshot/diskusage.go index 8de20af01..2487e4e65 100644 --- a/pkg/snapshot/diskusage.go +++ b/pkg/snapshot/diskusage.go @@ -3,6 +3,7 @@ package snapshot import ( "context" "fmt" + "os" "runtime" "strconv" "strings" @@ -11,12 +12,14 @@ import ( "github.com/shirou/gopsutil/v3/disk" ) -func (s *Snapshot) getDisksUsage(ctx context.Context, run bool) []error { +func (s *Snapshot) getDisksUsage(ctx context.Context, run bool, allDrives bool) []error { if !run { return nil } - partitions, err := disk.PartitionsWithContext(ctx, false) + getAllDisks := allDrives || os.Getenv("NOTIFIARR_IN_DOCKER") == "true" + + partitions, err := disk.PartitionsWithContext(ctx, getAllDisks) if err != nil { return []error{fmt.Errorf("unable to get partitions: %w", err)} } @@ -28,7 +31,7 @@ func (s *Snapshot) getDisksUsage(ctx context.Context, run bool) []error { for i := range partitions { u, err := disk.UsageWithContext(ctx, partitions[i].Mountpoint) if err != nil { - errs = append(errs, fmt.Errorf("unable to get partition usage: %w", err)) + errs = append(errs, fmt.Errorf("unable to get partition usage: %s: %w", partitions[i].Mountpoint, err)) continue } diff --git a/pkg/snapshot/memory_linux.go b/pkg/snapshot/memory_linux.go index 78072a1aa..f0d06d595 100644 --- a/pkg/snapshot/memory_linux.go +++ b/pkg/snapshot/memory_linux.go @@ -20,7 +20,6 @@ func (s *Snapshot) GetMemoryUsage(ctx context.Context, run bool) error { if err != nil { return s.getMemoryUsageShared(ctx, run) } - defer file.Close() scanner := bufio.NewScanner(file) scanner.Split(bufio.ScanLines) @@ -36,6 +35,9 @@ func (s *Snapshot) GetMemoryUsage(ctx context.Context, run bool) error { } } + // Not defered because we want it closed before calling s.getMemoryUsageShared(). + _ = file.Close() + s.System.MemTotal *= 1024 s.System.MemFree *= 1024 diff --git a/pkg/snapshot/snapshot.go b/pkg/snapshot/snapshot.go index 4cc22f9e1..9ee3fce51 100644 --- a/pkg/snapshot/snapshot.go +++ b/pkg/snapshot/snapshot.go @@ -12,6 +12,7 @@ import ( "fmt" "os" "os/exec" + "runtime" "sync" "time" @@ -26,22 +27,26 @@ const DefaultTimeout = 30 * time.Second const ( minimumTimeout = 5 * time.Second + maximumTimeout = time.Minute minimumInterval = 10 * time.Minute ) // Config determines which checks to run, etc. +//nolint:lll type Config struct { - Timeout cnfg.Duration `toml:"timeout" xml:"timeout"` // total run time allowed. - Interval cnfg.Duration `toml:"interval" xml:"interval"` // how often to send snaps (cron). - ZFSPools []string `toml:"zfs_pools" xml:"zfs_pool"` // zfs pools to monitor. - UseSudo bool `toml:"use_sudo" xml:"use_sudo"` // use sudo for smartctl commands. - Raid bool `toml:"monitor_raid" xml:"monitor_raid"` // include mdstat and/or megaraid. - DriveData bool `toml:"monitor_drives" xml:"monitor_drives"` // smartctl commands. - DiskUsage bool `toml:"monitor_space" xml:"monitor_space"` // get disk usage. - Uptime bool `toml:"monitor_uptime" xml:"monitor_uptime"` // all system stats. - CPUMem bool `toml:"monitor_cpuMemory" xml:"monitor_cpuMemory"` // cpu perct and memory used/free. - CPUTemp bool `toml:"monitor_cpuTemp" xml:"monitor_cpuTemp"` // not everything supports temps. - synology bool + Timeout cnfg.Duration `toml:"timeout" xml:"timeout" json:"timeout"` // total run time allowed. + Interval cnfg.Duration `toml:"interval" xml:"interval" json:"interval"` // how often to send snaps (cron). + ZFSPools []string `toml:"zfs_pools" xml:"zfs_pool" json:"zfsPools"` // zfs pools to monitor. + UseSudo bool `toml:"use_sudo" xml:"use_sudo" json:"useSudo"` // use sudo for smartctl commands. + Raid bool `toml:"monitor_raid" xml:"monitor_raid" json:"monitorRaid"` // include mdstat and/or megaraid. + DriveData bool `toml:"monitor_drives" xml:"monitor_drives" json:"monitorDrives"` // smartctl commands. + DiskUsage bool `toml:"monitor_space" xml:"monitor_space" json:"monitorSpace"` // get disk usage. + AllDrives bool `toml:"all_drives" xml:"all_drives" json:"allDrives"` // usage for all drives? + Uptime bool `toml:"monitor_uptime" xml:"monitor_uptime" json:"monitorUptime"` // all system stats. + CPUMem bool `toml:"monitor_cpuMemory" xml:"monitor_cpuMemory" json:"monitorCpuMem"` // cpu perct and memory used/free. + CPUTemp bool `toml:"monitor_cpuTemp" xml:"monitor_cpuTemp" json:"monitorCpuTemp"` // not everything supports temps. + // Debug bool `toml:"debug" xml:"debug" json:"debug"` + synology bool } // Errors this package generates. @@ -90,10 +95,13 @@ type Partition struct { // Validate makes sure the snapshot configuration is valid. func (c *Config) Validate() { - if c.Timeout.Duration == 0 { + switch { + case c.Timeout.Duration == 0: c.Timeout.Duration = DefaultTimeout - } else if c.Timeout.Duration < minimumTimeout { + case c.Timeout.Duration < minimumTimeout: c.Timeout.Duration = minimumTimeout + case c.Timeout.Duration > maximumTimeout: + c.Timeout.Duration = maximumTimeout } if c.Interval.Duration == 0 { @@ -102,7 +110,7 @@ func (c *Config) Validate() { c.Interval.Duration = minimumInterval } - if os.Getenv("NOTIFIARR_IN_DOCKER") == "true" { + if os.Getenv("NOTIFIARR_IN_DOCKER") == "true" || runtime.GOOS == "windows" { c.UseSudo = false } @@ -113,12 +121,6 @@ func (c *Config) Validate() { // GetSnapshot returns a system snapshot based on requested data in the config. func (c *Config) GetSnapshot() (*Snapshot, []error, []error) { - if c.Timeout.Duration == 0 { - c.Timeout.Duration = DefaultTimeout - } else if c.Timeout.Duration < minimumTimeout { - c.Timeout.Duration = minimumTimeout - } - ctx, cancel := context.WithTimeout(context.Background(), c.Timeout.Duration) defer cancel() @@ -139,11 +141,13 @@ func (c *Config) getSnapshot(ctx context.Context, s *Snapshot) ([]error, []error errs = append(errs, err...) } - if err := s.GetSynology(c.Uptime); err != nil { + if syn, err := GetSynology(c.Uptime && s.synology); err != nil { errs = append(errs, err) + } else if syn != nil { + syn.SetInfo(s.System.InfoStat) } - if err := s.getDisksUsage(ctx, c.DiskUsage); len(err) != 0 { + if err := s.getDisksUsage(ctx, c.DiskUsage, c.AllDrives); len(err) != 0 { errs = append(errs, err...) } diff --git a/pkg/snapshot/synology.go b/pkg/snapshot/synology.go index 1b6de7e7c..1f9b58285 100644 --- a/pkg/snapshot/synology.go +++ b/pkg/snapshot/synology.go @@ -7,6 +7,8 @@ import ( "io" "os" "strings" + + "github.com/shirou/gopsutil/v3/host" ) // SynologyConf is the path to the syno config file. @@ -28,14 +30,14 @@ type Synology struct { */ // GetSynology checks if the app is running on a Synology, and gets system info. -func (s *Snapshot) GetSynology(run bool) error { //nolint:cyclop - if !run || !s.synology { - return nil +func GetSynology(run bool) (*Synology, error) { //nolint:cyclop + if !run { + return nil, nil } file, err := os.Open(SynologyConf) if err != nil { - return fmt.Errorf("opening synology conf: %w", err) + return nil, fmt.Errorf("opening synology conf: %w", err) } defer file.Close() @@ -50,7 +52,7 @@ func (s *Snapshot) GetSynology(run bool) error { //nolint:cyclop if errors.Is(err, io.EOF) { break } else if err != nil { - return fmt.Errorf("reading synology conf: %w", err) + return nil, fmt.Errorf("reading synology conf: %w", err) } lsplit := strings.Split(line, "=") @@ -72,21 +74,19 @@ func (s *Snapshot) GetSynology(run bool) error { //nolint:cyclop } } - s.setSynology(syn) - - return nil + return syn, nil } -func (s *Snapshot) setSynology(syn *Synology) { - if s.System.InfoStat.Platform == "" && syn.Vendor != "" { - s.System.InfoStat.Platform = syn.Vendor +func (s *Synology) SetInfo(hi *host.InfoStat) { + if hi.Platform == "" && s.Vendor != "" { + hi.Platform = s.Vendor } - if s.System.InfoStat.PlatformFamily == "" && syn.Manager != "" { - s.System.InfoStat.PlatformFamily = syn.Manager + " " + syn.Model + if hi.PlatformFamily == "" && s.Manager != "" { + hi.PlatformFamily = s.Manager + " " + s.Model } - if s.System.InfoStat.PlatformVersion == "" && syn.Version != "" { - s.System.InfoStat.PlatformVersion = syn.Version + "-" + syn.Build + if hi.PlatformVersion == "" && s.Version != "" { + hi.PlatformVersion = s.Version + "-" + s.Build } } diff --git a/pkg/snapshot/temperature_bsd.go b/pkg/snapshot/temperature_bsd.go deleted file mode 100644 index 031a739ed..000000000 --- a/pkg/snapshot/temperature_bsd.go +++ /dev/null @@ -1,53 +0,0 @@ -// +build freebsd openbsd netbsd - -package snapshot - -import ( - "context" - "fmt" - - "github.com/shirou/gopsutil/v3/host" - "golang.org/x/sys/unix" -) - -func (s *Snapshot) getSystemTemps(ctx context.Context, run bool) error { - if !run { - return nil - } - - s.System.Temps = make(map[string]float64) - - temps, err := host.SensorsTemperaturesWithContext(ctx) - if err != nil { - return s.getSystemTempsFreeBSD() - } - - for _, t := range temps { - if t.Temperature > 0 { - s.System.Temps[t.SensorKey] = t.Temperature - } - } - - return nil -} - -// The host library may not support BSD, so try it ourselves. -// nolint: gomnd -func (s *Snapshot) getSystemTempsFreeBSD() error { - temp, err := unix.SysctlUint32("dev.cpu.0.temperature") - if err != nil { - return fmt.Errorf("unable to get cpu temperature: %w", err) - } - - // Convert from Kelvin * 10 to Celsius. - s.System.Temps = map[string]float64{"cpu0": float64(int32(temp)-2732) / 10} - - for i := 1; i < 8; i++ { - temp, err := unix.SysctlUint32(fmt.Sprintf("dev.cpu.%d.temperature", i)) - if err == nil { - s.System.Temps[fmt.Sprintf("cpu%d", i)] = float64(int32(temp)-2732) / 10 - } - } - - return nil -} diff --git a/pkg/snapshot/temperature_other.go b/pkg/snapshot/temperatures.go similarity index 58% rename from pkg/snapshot/temperature_other.go rename to pkg/snapshot/temperatures.go index b21f41501..f42ff65c1 100644 --- a/pkg/snapshot/temperature_other.go +++ b/pkg/snapshot/temperatures.go @@ -1,10 +1,10 @@ -// +build !freebsd,!openbsd,!netbsd - package snapshot import ( "context" + "errors" "fmt" + "strings" "github.com/shirou/gopsutil/v3/host" ) @@ -17,9 +17,6 @@ func (s *Snapshot) getSystemTemps(ctx context.Context, run bool) error { s.System.Temps = make(map[string]float64) temps, err := host.SensorsTemperaturesWithContext(ctx) - if err != nil { - return fmt.Errorf("unable to get sensor temperatures: %w", err) - } for _, t := range temps { if t.Temperature > 0 { @@ -27,5 +24,19 @@ func (s *Snapshot) getSystemTemps(ctx context.Context, run bool) error { } } - return nil + if err == nil { + return nil + } + + var warns *host.Warnings + if !errors.As(err, &warns) { + return fmt.Errorf("unable to get sensor temperatures: %w", err) + } + + errs := make([]string, len(warns.List)) + for i, w := range warns.List { + errs[i] = fmt.Sprintf("warning %v: %v", i+1, w) + } + + return fmt.Errorf("getting sensor temperatures: %w: %s", err, strings.Join(errs, ", ")) } diff --git a/settings.sh b/settings.sh index edcf7b371..446880410 100644 --- a/settings.sh +++ b/settings.sh @@ -45,3 +45,12 @@ gpg --list-keys 2>/dev/null | grep -q B93DD66EF98E54E2EAE025BA0166AD34ABC5A57C export WINDOWS_LDFLAGS="" export MACAPP="Notifiarr" export EXTRA_FPM_FLAGS="--conflicts=discordnotifier-client>0.0.1 --provides=notifiarr --provides=discordnotifier-client" + + +# Make sure Docker builds work locally. +# These do not affect automated builds, just allow the docker build scripts to run from a local clone. +[ -n "$SOURCE_BRANCH" ] || export SOURCE_BRANCH=$BRANCH +[ -n "$DOCKER_TAG" ] || export DOCKER_TAG=$(echo $SOURCE_BRANCH | sed 's/^v*\([0-9].*\)/\1/') +[ -n "$DOCKER_REPO" ] || export DOCKER_REPO="golift/${BINARY}" +[ -n "$IMAGE_NAME" ] || export IMAGE_NAME="${DOCKER_REPO}:${DOCKER_TAG}" +[ -n "$DOCKERFILE_PATH" ] || export DOCKERFILE_PATH="init/docker/Dockerfile"