├── .bash_aliases ├── .env.sample ├── .gitignore ├── LICENSE ├── README.md ├── Vagrantfile ├── ansible.sh ├── caddy └── Dockerfile ├── defaults ├── borgmatic │ ├── config.yaml │ ├── crontab.txt │ └── upload_backup.sh ├── caddy │ └── .htpasswd ├── radarr │ └── config.xml ├── rclone │ ├── excludes │ └── rclone.conf.sample ├── sonarr │ └── config.xml └── tautulli │ └── config.ini ├── docker-compose.yml ├── hosts ├── localtime ├── mount_gmedia.sh ├── playbook.yml ├── plexbar ├── README.md ├── plexbar.5s.rb └── testdata │ ├── anime.json │ ├── movie.json │ └── tvshow.json ├── rclone-gdrive ├── Dockerfile ├── README.md └── root │ ├── etc │ ├── cont-finish.d │ │ └── umount_all │ ├── cont-init.d │ │ ├── init_fuse │ │ ├── init_paths │ │ └── setup_rclone_config │ └── services.d │ │ ├── cron │ │ └── run │ │ └── rclone_mount │ │ └── run │ └── scripts │ └── upload_cloud ├── slack_send.py └── transmission-scripts ├── transmission-magnet-watcher ├── transmission-post-start.sh └── transmission-pre-stop.sh /.bash_aliases: -------------------------------------------------------------------------------- 1 | queue_length() { 2 | find ~/plexflix/data/local -type f | grep -v downloads | wc -l 3 | } 4 | queue_size() { 5 | du -sh ~/plexflix/data/local --exclude ~/plexflix/data/local/downloads | cut -f1 6 | } 7 | upload_queue() { 8 | echo "Upload Queue: $(queue_length) files, $(queue_size)" 9 | } 10 | alias queue_info='watch -t -n 10 -d -x $SHELL -c "source ~/.custom; upload_queue"' 11 | 12 | #alias ctop='docker run --rm -ti --name=ctop -v /var/run/docker.sock:/var/run/docker.sock quay.io/vektorlab/ctop:latest' 13 | alias ddc='f() { (cd ~/plexflix && docker-compose $@) }; f' 14 | alias borgmatic='ddc run --rm borgmatic borgmatic' 15 | alias borg='ddc run --rm borgmatic borg' 16 | alias logerr='ddc logs -f -t --tail=1 | egrep -i "(warn|error|fail)"' 17 | alias rclone='ddc exec rclone rclone' 18 | alias up='ddc up -d --build' 19 | alias wls='f() { watch -d -c ls -l --color \"$@\"; }; f' 20 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # this is the minimum configuration needed. For additional configuration, please check the 2 | # documentation of each individual component used in this project (links are found in the README). 3 | 4 | # Global config 5 | PUID=1000 6 | PGID=1000 7 | TZ=America/New_York 8 | SLACK_URL=https://hooks.slack.com/services/your-service-identifier-part-here 9 | 10 | 11 | # Caddy 12 | DOMAIN=my.domain.com 13 | AUTH_USER=username 14 | AUTH_PASSWD=password 15 | 16 | # Let's Encrypt provider setup for Caddy See the provider list in https://caddyserver.com/docs/automatic-https 17 | TLS_DNS_PROVIDER=cloudflare 18 | CLOUDFLARE_EMAIL=me@domain.com 19 | CLOUDFLARE_API_KEY=your_cloudflare_api_key 20 | 21 | # Uncomment this line for creating test domains SSL certificates 22 | # CA_URL=-ca https://acme-staging-v02.api.letsencrypt.org/directory 23 | 24 | # RCLONE config 25 | REMOTE_PATH=gcrypt: 26 | 27 | # OpenVPN config 28 | OPENVPN_PROVIDER=CUSTOM # for CUSTOM, put VPN secrets in /config/secrets 29 | OPENVPN_USERNAME=username 30 | OPENVPN_PASSWORD=password 31 | OPENVPN_OPTS=--inactive 3600 --ping 10 --ping-exit 60 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.code-workspace 2 | .vagrant 3 | .env 4 | config 5 | data 6 | logs 7 | secrets 8 | /*.retry 9 | *.log 10 | docker-compose.override.yml 11 | .vscode 12 | homescripts -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Corintio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlexFlix 2 | 3 | This is my personal Mediaserver setup, based on [Dockerized](http://docker.com) 4 | apps. 5 | 6 | ### Features: 7 | - Fully automated solution, with automatic download for TV shows and movies 8 | - Google Drive integration, for "unlimited" storage in the cloud (encrypted) 9 | - VPN integration for protected torrent downloading 10 | - Automatic SSL (https) certificate generation 11 | - Automatic daily backups using borg (only for configuration and app data, as the 12 | media will be saved in Google Drive) 13 | - Automatic update of docker images using watchtower 14 | 15 | ### Applications (and their respective docker projects): 16 | - Plex - https://github.com/linuxserver/docker-plex 17 | - Sonarr - https://github.com/linuxserver/docker-sonarr 18 | - Radarr - https://github.com/linuxserver/docker-radarr 19 | - Tautulli - https://github.com/linuxserver/docker-tautulli 20 | - Ombi - https://github.com/linuxserver/docker-ombi 21 | - Jackett - https://github.com/linuxserver/docker-jackett 22 | - Caddy - [Sub project `caddy`](caddy) 23 | - Rclone - [Sub project `rclone-gdrive`](rclone-gdrive) 24 | - Transmission (+VPN) - https://github.com/haugene/docker-transmission-openvpn 25 | - Borgmatic - https://github.com/b3vis/docker-borgmatic 26 | - Watchtower - https://github.com/containrrr/watchtower 27 | - Portainer - https://hub.docker.com/r/portainer/portainer 28 | 29 | ### Requirements: 30 | - Google Drive account (may not be necessary, but will require some changes) 31 | - VPN account (if using the provided Transmission setup) 32 | 33 | # Folder structure 34 | Important files and folders: 35 | ``` 36 | plexflix 37 | ├── .env - Environment configuration (options, servers, etc..) 38 | ├── docker-compose.yml - Main Compose services configuration. Don't change! 39 | ├── docker-compose.override.yml - Compose overrides. Add customizations here 40 | ├── config - Folder with config and critical data for all services 41 | │   ├── borgmatic 42 | │   │   ├── config.yml - Borgmatic backup configuration 43 | │   │   └── crontab.txt - Schedule for backup 44 | │   ├── rclone 45 | │   │   ├── rclone.conf - Configuration for rclone's remotes 46 | │   │   └── excludes - List of files that should not be uploaded to Google Drive 47 | │   ├── plex 48 | │   ├── sonarr 49 | │   ├── radarr 50 | │   └── ... 51 | ├── data 52 | │   ├── gmedia - This is where your media will be mounted from GDrive 53 | │   ├── local - New files are staged here before being uploaded 54 | │   │   └── downloads - Used by transmission for downloads 55 | │   ├── backups - Folder that store the daily backups 56 | │   └── ... 57 | └── logs - Logs for most apps 58 | ``` 59 | 60 | # Setup 61 | 62 | ## Install required software 63 | 1. Create a user, preferable with uid=1000/gid=1000 (if not, change these values in 64 | the `.env` file, see Initial Configuration bellow) 65 | 2. Login with the newly created user 66 | 3. Install Docker: 67 | ``` 68 | sudo curl -L https://get.docker.com | bash 69 | sudo usermod -aG docker $USER 70 | ``` 71 | 4. Install Docker Compose (requires Python): 72 | ``` 73 | pip install docker-compose 74 | ``` 75 | 5. Clone this repo: 76 | ``` 77 | git clone https://github.com/corintio/plexflix 78 | cd plexflix 79 | ``` 80 | 81 | NOTE: All steps and examples bellow assume you are in the project folder. 82 | 83 | That's all you need to install. All required software (i.e. Plex, Sonarr, etc..) will be 84 | download and installed by Docker. But before starting all apps, **you must complete the 85 | initial configuration**. 86 | 87 | 88 | ## Initial Configuration 89 | 1. Create a `.env` file with your configuration (see `.env.sample`) 90 | 2. Create a copy of `./defaults` folder called `./config` 91 | 92 | You can change some Docker configurations (ex: volume paths) by creating a 93 | `docker-compose.override.yml`. See [Docker Compose documentation](https://docs.docker.com/compose/extends/#adding-and-overriding-configuration) for details 94 | 95 | If you don't want or need one of the service in this project, say *Transmission* for 96 | example, just disable it by adding the following in your override file: 97 | ``` 98 | version: '3.7' 99 | services: 100 | transmission: 101 | entrypoint: ["echo", "Service disabled"] 102 | restart: "no" 103 | ``` 104 | 105 | ## Caddy 106 | 107 | You'll need to create a user/password to restrict access to your server. Use the following commands to do so (replace `username` and `your.domain.com` with your own): 108 | 109 | ``` 110 | cd ~/plexflix 111 | htpasswd -B -C 15 -c ./config/caddy/.htpasswd username 112 | echo "your.domain.com" > ./config/caddy/redirect_hosts.txt 113 | ``` 114 | 115 | ## Rclone 116 | Create a configuration for Rclone in the `./config/rclone` folder. You need to create a 117 | configuration for your remote Goggle Drive with the `gcrypt:` name. If you want a 118 | different name, set the `REMOTE_PATH` env var in `.env` with the new value 119 | 120 | Make sure Rclone is starting and mounting your remote correctly. To test it, run 121 | `docker-compose up --build rclone` and check for any errors. Go to a different terminal 122 | and try to access the mountpoint (default: `./data/gmedia`), check if your files are 123 | there. 124 | 125 | 126 | ## Plex 127 | Start Plex with `docker-compose up plex`, go to http://your_ip:32400/web and follow the 128 | instructions. If you are installing in a remote server (different network), please follow 129 | [these instructions](https://support.plex.tv/articles/200288586-installation/#toc-2) 130 | (see *"On a Different Network"* section). 131 | 132 | After Plex is up and running, change the Transcoding path to `/transcode`, so it uses a 133 | RAM disk to do the transcoding, which is much faster and less of a toll to your HDD/SDD 134 | 135 | ## Transmission + VPN 136 | See https://github.com/haugene/docker-transmission-openvpn for details on how to 137 | configure the VPN access. If you are using a custom VPN, copy your VPN Config 138 | to `./config/vpn` 139 | 140 | ## Borgmatic (backup) 141 | Before borgmatic can do its magic, you need to create a new borg repository. Make sure 142 | to set your password in `./config/borgmatic/config.yml` first 143 | 144 | To simplify access to your backups, create the following aliases in our `.bashrc`: 145 | ``` 146 | alias borgmatic='docker-compose run --rm borgmatic borgmatic' 147 | alias borg='docker-compose run --rm borgmatic borg' 148 | ``` 149 | 150 | Command to initialize a new repo: 151 | ``` 152 | borgmatic --init --encryption repokey-blake2 153 | ``` 154 | 155 | ## Other apps 156 | 157 | ### Initial BasePath configuration 158 | Some of the applications in this project need to be configured before being able to be 159 | properly proxied (ex: add base path). To be able to do these configurations, create a 160 | `docker-compose.override.yml` exposing the ports for the app. 161 | 162 | Example: Jackett 163 | ``` 164 | --- 165 | version: '3.7' 166 | services: 167 | jackett: 168 | ports: 169 | - 9117:9117 170 | ``` 171 | 172 | After starting the app container, you should be able to go to 173 | http://your.domain.com:9117 and configure Jackett to the correct base path `/jackett` 174 | 175 | Apps that require this workaround, and their respective ports that need to be open: 176 | - Jackett: 9117 177 | - Ombi: 3579 178 | 179 | Remember to remove this override after the app is properly configured, as the ports will 180 | be exposed to external access 181 | 182 | # To Do 183 | - ~~Fix Rclone logging~~ 184 | - ~~Use mergerfs in rclone container~~ 185 | - ~~Use Transmission-OpenVPN's proxy in Sonarr/Radar/Jackett~~ 186 | - ~~Use Plex Autoscan: https://github.com/l3uddz/plex_autoscan/~~ Not necessary for now 187 | - Expose Plex through port 80/443 188 | - Monitoring / Notifications 189 | - Last updated containers 190 | - Last backup status 191 | - Errors in logs 192 | - Container's health 193 | - CronJobs: https://hub.docker.com/r/willfarrell/crontab? 194 | - Auto clean .trash (remove older than 1 month) 195 | - Docker-GC: https://github.com/spotify/docker-gc 196 | - Log rotate https://hub.docker.com/r/blacklabelops/logrotate 197 | - Call watchtower(?) 198 | - Replace htpasswd with Google Authentication oAuth (https://github.com/tarent/loginsrv/blob/master/caddy/README.md#example-caddyfile-with-google-login) 199 | - Finish Ansible setup 200 | - ~~Investigate use of hardlinks and moves in Sonarr/Radarr~~ 201 | 202 | # Future 203 | - Move Rclone to own project and publish the image in Docker Hub 204 | - Automate full restore 205 | - ~~Auto convert magnet links to .torrent files in the watch folder~~ 206 | - Calibre https://hub.docker.com/r/linuxserver/calibre-web/ 207 | - Cockpit https://cockpit-project.org/ 208 | - Investigate qBittorrent+SOCKS5 209 | - Add more info in PlexBar (vnstat, upload_queue, load average, disk usage, real mem, 210 | recent errors, gdrive quota, sensors) -------------------------------------------------------------------------------- /Vagrantfile: -------------------------------------------------------------------------------- 1 | Vagrant.configure("2") do |config| 2 | config.vm.box = "ubuntu/xenial64" 3 | config.vm.network "forwarded_port", guest: 22, host: 2222 4 | config.vm.network "forwarded_port", guest: 2015, host: 2015 5 | config.vm.network "forwarded_port", guest: 8000, host: 8000 6 | config.vm.network "forwarded_port", guest: 32400, host: 32400 7 | config.vm.network "forwarded_port", guest: 32400, host:32400, protocol:"udp" 8 | config.vm.network "forwarded_port", guest: 32469, host:32469 9 | config.vm.network "forwarded_port", guest: 32469, host:32469, protocol:"udp" 10 | config.vm.network "forwarded_port", guest: 5353, host:5353, protocol:"udp" 11 | config.vm.network "forwarded_port", guest: 1900, host:1900, protocol:"udp" 12 | config.vm.network "private_network", ip: "192.168.33.10" 13 | config.vm.provision "shell", inline: <<-SHELL 14 | test -e /usr/bin/python || (apt-get update && apt-get install -y python-minimal python-pip) 15 | SHELL 16 | end 17 | -------------------------------------------------------------------------------- /ansible.sh: -------------------------------------------------------------------------------- 1 | ansible-playbook --private-key=.vagrant/machines/default/virtualbox/private_key -u vagrant -i hosts $* playbook.yml 2 | -------------------------------------------------------------------------------- /caddy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine as builder 2 | 3 | RUN apk add -U --no-cache ca-certificates bash gnupg 4 | RUN CADDY_TELEMETRY=on wget -O- https://getcaddy.com | bash -s personal \ 5 | http.cache,http.cgi,http.jwt,http.login,http.realip,tls.dns.cloudflare,docker 6 | 7 | FROM scratch 8 | EXPOSE 80 443 2015 9 | ENV HOME /root 10 | 11 | WORKDIR / 12 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 13 | COPY --from=builder /usr/local/bin/caddy /bin/ 14 | 15 | ENTRYPOINT ["/bin/caddy"] -------------------------------------------------------------------------------- /defaults/borgmatic/config.yaml: -------------------------------------------------------------------------------- 1 | location: 2 | source_directories: 3 | - /mnt/source/.env 4 | - /mnt/source/docker-compose.override.yml 5 | - /mnt/source/config/borgmatic 6 | - /mnt/source/config/caddy 7 | - /mnt/source/config/jackett 8 | - /mnt/source/config/ombi 9 | - /mnt/source/config/portainer 10 | - /mnt/source/config/radarr 11 | - /mnt/source/config/rclone 12 | - /mnt/source/config/sonarr 13 | - /mnt/source/config/tautulli 14 | - /mnt/source/config/plex/Library/Application Support/Plex Media Server/Preferences.xml 15 | - /mnt/source/config/plex/Library/Application Support/Plex Media Server/Plug-in Support/Databases 16 | repositories: 17 | - /mnt/repository 18 | one_file_system: true 19 | 20 | exclude_patterns: 21 | - '/**/ombi/*.db-journal' 22 | - '/**/MediaCover/*' 23 | - '/**/Backups/*' 24 | - '/**/UpdateLogs/*' 25 | - '/**/backups/*' 26 | - '/**/cache/*' 27 | - '/**/logs/*' 28 | - '**/log*.txt' 29 | 30 | storage: 31 | encryption_passphrase: "DonNotMissToChangeYourPassphrase" 32 | compression: lz4 33 | archive_name_format: 'backup-{now}' 34 | 35 | retention: 36 | keep_hourly: 2 37 | keep_daily: 7 38 | keep_weekly: 4 39 | keep_monthly: 12 40 | keep_yearly: 10 41 | prefix: 'backup-' 42 | 43 | consistency: 44 | checks: 45 | - repository 46 | - archives 47 | check_last: 3 48 | prefix: 'backup-' 49 | 50 | hooks: 51 | before_backup: 52 | - echo "Starting a backup job." 53 | - date "+%Y%m%d%H%M%S" > /tmp/jobname 54 | after_backup: 55 | - /etc/borgmatic.d/upload_backup.sh 2>&1 | tee $(cat /tmp/jobname) 56 | - /etc/borgmatic.d/slack_send.py ":information_source:" "Backup Successful" "$(cat $(cat /tmp/jobname))" 57 | on_error: 58 | - echo "Error while creating a backup." 59 | - /etc/borgmatic.d/slack_send.py ":bangbang:" "Error while creating a backup" "$(cat $(cat /tmp/jobname))" 60 | -------------------------------------------------------------------------------- /defaults/borgmatic/crontab.txt: -------------------------------------------------------------------------------- 1 | 0 3 * * * PATH=$PATH:/usr/bin /usr/bin/borgmatic --stats -v 0 2>&1 -------------------------------------------------------------------------------- /defaults/borgmatic/upload_backup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Install docker if not available in this container 4 | if ! which docker; then apk add --no-cache docker; fi 5 | 6 | # Execute the command rclone sync in the rclone containter 7 | docker exec rclone rclone sync -v \ 8 | --checksum \ 9 | --transfers 3 \ 10 | --checkers 3 \ 11 | --tpslimit 3 \ 12 | /data/backups gcrypt:/backups -------------------------------------------------------------------------------- /defaults/caddy/.htpasswd: -------------------------------------------------------------------------------- 1 | bob:$2y$15$W2UAb.Qo3H7Yhd77oU252OrgkZaYZEbLU9q3le5pdwKCKcm6ovri. 2 | -------------------------------------------------------------------------------- /defaults/radarr/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 7878 3 | radarr 4 | * 5 | 9797 6 | False 7 | Info 8 | master 9 | False 10 | BuiltIn 11 | False 12 | None 13 | 14 | -------------------------------------------------------------------------------- /defaults/rclone/excludes: -------------------------------------------------------------------------------- 1 | *partial~ 2 | downloads/** -------------------------------------------------------------------------------- /defaults/rclone/rclone.conf.sample: -------------------------------------------------------------------------------- 1 | [gd] 2 | type = drive 3 | scope = drive 4 | client_id = 5 | client_secret = 6 | token = {"access_token":"","token_type":"Bearer","refresh_token":"","expiry":"2018-09-17T11:30:55.640150617-04:00"} 7 | 8 | [gcrypt] 9 | type = crypt 10 | remote = gd:media 11 | filename_encryption = standard 12 | directory_name_encryption = true 13 | password = 14 | password2 = -------------------------------------------------------------------------------- /defaults/sonarr/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 8989 3 | sonarr 4 | * 5 | 9898 6 | False 7 | Info 8 | phantom-develop 9 | False 10 | None 11 | 12 | Docker 13 | -------------------------------------------------------------------------------- /defaults/tautulli/config.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | http_root = /tautulli 3 | http_proxy = 1 4 | show_advanced_settings = 1 5 | launch_browser = 0 6 | http_host = 0.0.0.0 7 | https_domain = localhost 8 | backup_dir = /config/backups 9 | http_port = 8181 10 | log_dir = /config/logs 11 | cache_dir = /config/cache 12 | geoip_db = /config/GeoLite2-City.mmdb 13 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.7' 3 | services: 4 | caddy: 5 | build: caddy 6 | container_name: caddy 7 | ports: 8 | - 80:80 9 | - 443:443 10 | command: -agree=true ${CA_URL:-} 11 | env_file: 12 | - .env 13 | labels: 14 | com.centurylinklabs.watchtower.enable: false 15 | caddy: '${DOMAIN}' 16 | caddy.tls.dns: '${TLS_DNS_PROVIDER}' 17 | caddy.log: /var/log/access.log 18 | caddy.login.redirect_check_referer: false 19 | caddy.login.redirect_host_file: /root/.caddy/redirect_hosts.txt 20 | caddy.login.htpasswd: file=/root/.caddy/.htpasswd 21 | volumes: 22 | - /var/run/docker.sock:/var/run/docker.sock:ro 23 | - ./config/caddy:/root/.caddy 24 | - ./logs/caddy:/var/log 25 | restart: always 26 | 27 | transmission: 28 | image: haugene/transmission-openvpn 29 | container_name: transmission 30 | cap_add: 31 | - NET_ADMIN 32 | devices: 33 | - /dev/net/tun 34 | labels: 35 | caddy.address: '${DOMAIN}/transmission' 36 | caddy.targetport: 9091 37 | caddy.targetpath: /transmission 38 | caddy.proxy.transparent: 39 | dns: 40 | - 8.8.8.8 41 | - 8.8.4.4 42 | volumes: 43 | - ./config/transmission:/config 44 | - ./localtime:/etc/localtime:ro 45 | - ./data/local/downloads/:/data 46 | - ./config/vpn:/etc/openvpn/custom 47 | - ./transmission-scripts:/scripts 48 | env_file: .env 49 | restart: unless-stopped 50 | 51 | radarr: 52 | image: linuxserver/radarr 53 | container_name: radarr 54 | env_file: .env 55 | labels: 56 | caddy.address: '${DOMAIN}/radarr' 57 | caddy.targetport: 7878 58 | caddy.targetpath: /radarr 59 | caddy.proxy.transparent: 60 | caddy.jwt.path: / 61 | caddy.jwt.redirect: /login?backTo=/radarr 62 | caddy.jwt.except: /api 63 | volumes: 64 | - ./config/radarr:/config 65 | - ./data:/data:shared 66 | - ./logs/radarr:/config/logs 67 | depends_on: 68 | - rclone 69 | restart: unless-stopped 70 | 71 | sonarr: 72 | image: linuxserver/sonarr:preview 73 | container_name: sonarr 74 | env_file: .env 75 | labels: 76 | caddy.address: '${DOMAIN}/sonarr' 77 | caddy.targetport: 8989 78 | caddy.targetpath: /sonarr 79 | caddy.proxy.transparent: 80 | caddy.jwt.path: / 81 | caddy.jwt.redirect: /login?backTo=/sonarr 82 | caddy.jwt.except: /api 83 | volumes: 84 | - ./config/sonarr:/config 85 | - ./data:/data:shared 86 | - ./logs/sonarr:/config/logs 87 | depends_on: 88 | - rclone 89 | restart: unless-stopped 90 | 91 | jackett: 92 | image: linuxserver/jackett 93 | container_name: jackett 94 | env_file: .env 95 | labels: 96 | caddy.address: '${DOMAIN}/jackett' 97 | caddy.targetport: 9117 98 | caddy.targetpath: /jackett 99 | caddy.proxy.transparent: 100 | caddy.jwt.path: / 101 | caddy.jwt.redirect: /login?backTo=/jackett 102 | caddy.jwt.except: /api 103 | volumes: 104 | - ./config/jackett:/config/Jackett 105 | - ./data/local/downloads/watch:/downloads 106 | restart: unless-stopped 107 | 108 | ombi: 109 | image: linuxserver/ombi 110 | container_name: ombi 111 | env_file: .env 112 | labels: 113 | caddy.address: '${DOMAIN}/ombi' 114 | caddy.targetport: 3579 115 | caddy.targetpath: /ombi 116 | caddy.proxy.transparent: 117 | volumes: 118 | - ./config/ombi:/config 119 | - ./logs/ombi:/config/Logs 120 | restart: unless-stopped 121 | 122 | plex: 123 | image: linuxserver/plex 124 | container_name: plex 125 | env_file: .env 126 | environment: 127 | - VERSION=latest 128 | volumes: 129 | - ./config/plex:/config 130 | - ./logs/plex:/config/Library/Application Support/Plex Media Server/Logs 131 | - ./data:/data:shared 132 | - /tmp:/tmp 133 | tmpfs: /transcode:uid=${PUID},gid=${PGID} 134 | # ports: 135 | # - 32400:32400 136 | network_mode: host 137 | depends_on: 138 | - rclone 139 | restart: unless-stopped 140 | 141 | tautulli: 142 | image: linuxserver/tautulli 143 | container_name: tautulli 144 | labels: 145 | caddy.address: '${DOMAIN}/tautulli' 146 | caddy.targetport: 8181 147 | caddy.targetpath: /tautulli 148 | caddy.proxy.transparent: 149 | caddy.jwt.path: / 150 | caddy.jwt.redirect: /login?backTo=/tautulli 151 | caddy.jwt.except: /api 152 | env_file: .env 153 | volumes: 154 | - ./config/tautulli:/config 155 | - ./config/plex/Library/Application Support/Plex Media Server/Logs:/logs:ro 156 | - ./logs/tautulli:/config/logs 157 | restart: unless-stopped 158 | 159 | rclone: 160 | build : rclone-gdrive 161 | container_name: rclone 162 | labels: 163 | com.centurylinklabs.watchtower.enable: false 164 | cap_add: 165 | - SYS_ADMIN 166 | devices: 167 | - /dev/fuse 168 | security_opt: 169 | - apparmor:unconfined 170 | env_file: .env 171 | volumes: 172 | - ./config/rclone:/config 173 | - ./logs/rclone:/logs 174 | - ./data:/data:shared 175 | restart: always 176 | 177 | portainer: 178 | image: portainer/portainer 179 | container_name: portainer 180 | labels: 181 | caddy.address: '${DOMAIN}/portainer' 182 | caddy.proxy: '/ portainer:9000' 183 | caddy.proxy.without: /portainer 184 | caddy.proxy.transparent: 185 | caddy.proxy.websocket: 186 | volumes: 187 | - ./config/portainer:/data 188 | - /var/run/docker.sock:/var/run/docker.sock 189 | restart: always 190 | 191 | ##################################################################################### 192 | # Maintenance containers 193 | 194 | watchtower: 195 | image: containrrr/watchtower 196 | container_name: watchtower 197 | env_file: .env 198 | volumes: 199 | - /var/run/docker.sock:/var/run/docker.sock 200 | - ./localtime:/etc/localtime:ro 201 | command: --schedule ${WATCHTOWER_SCHEDULE:- "0 0 4 * * *"} 202 | restart: always 203 | 204 | borgmatic: 205 | image: b3vis/borgmatic 206 | container_name: borgmatic 207 | env_file: .env 208 | volumes: 209 | - ./.env:/mnt/source/.env:ro 210 | - ./docker-compose.override.yml:/mnt/source/docker-compose.override.yml:ro 211 | - ./config:/mnt/source/config:ro 212 | - ./data/backups:/mnt/repository 213 | - ./config/borgmatic:/etc/borgmatic.d/ 214 | - ./slack_send.py:/etc/borgmatic.d/slack_send.py 215 | - ./config/borgmatic/borg:/root/.config/borg 216 | - ./data/borgmatic/.cache:/root/.cache/borg 217 | - /var/run/docker.sock:/var/run/docker.sock:ro 218 | restart: always -------------------------------------------------------------------------------- /hosts: -------------------------------------------------------------------------------- 1 | # vagrant@192.168.33.10 2 | default ansible_ssh_host=127.0.0.1 ansible_ssh_port=2222 -------------------------------------------------------------------------------- /localtime: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/corintio/plexflix/333cd5ba71cd9d10ab3581fb7d2f7907eddd2c02/localtime -------------------------------------------------------------------------------- /mount_gmedia.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # docker build -t rclone rclone-gdrive 5 | 6 | # docker run -it --cap-add SYS_ADMIN --device /dev/fuse \ 7 | # --security-opt apparmor:unconfined \ 8 | # -e REMOTE_PATH=gcrypt: -e PUID:`id -u` -e PGID:`id -g` \ 9 | # -v ~/.config/rclone:/config -v ~/logs:/logs -v ~/gmedia:/data rclone 10 | 11 | 12 | mkdir -p ~/gmedia 13 | rclone mount gcrypt: ~/gmedia --log-level INFO --allow-other --attr-timeout 10s --dir-cache-time 96h \ 14 | --drive-chunk-size 32M \ 15 | --timeout 2h \ 16 | --umask 002 -------------------------------------------------------------------------------- /playbook.yml: -------------------------------------------------------------------------------- 1 | - name: Setup server for Plex and friends 2 | hosts: "all" 3 | become: false 4 | gather_facts: true 5 | 6 | tasks: 7 | - name: Install basic tools 8 | apt: name={{item}} state=latest update_cache=yes 9 | with_items: [wget, curl, make, ufw] 10 | become: true 11 | 12 | - name: Install Docker 13 | # apt: name=docker-ce state=latest 14 | # sudo apt-get upgrade docker-ce 15 | shell: curl -L https://get.docker.com | bash creates=/etc/docker/ warn=False 16 | become: true 17 | 18 | - name: Install Docker Compose 19 | pip: 20 | name: docker-compose 21 | state: latest 22 | become: true 23 | 24 | - name: Add docker-compose alias 25 | lineinfile: 26 | path: /home/vagrant/.bash_aliases 27 | create: true 28 | line: "alias ddc='f(){(cd /vagrant && docker-compose \"$@\")};f'" 29 | 30 | - name: Make sure we don't have the default ctop 31 | apt: name=ctop state=absent 32 | become: true 33 | 34 | - name: Add ctop alias 35 | lineinfile: 36 | path: /home/vagrant/.bash_aliases 37 | line: 'alias ctop="docker run --rm -ti --name=ctop -v /var/run/docker.sock:/var/run/docker.sock quay.io/vektorlab/ctop:latest"' 38 | 39 | - name: Add user to docker group 40 | user: 41 | name: '{{ ansible_user_id }}' 42 | groups: docker 43 | append: yes 44 | become: true 45 | 46 | # # Setup Firewall rules 47 | # - ufw: 48 | # logging: on 49 | # become: true 50 | # - ufw: 51 | # direction: incoming 52 | # policy: deny 53 | # become: true 54 | # - ufw: 55 | # direction: outgoing 56 | # policy: allow 57 | # become: true 58 | # - ufw: 59 | # rule: allow 60 | # port: "{{item}}" 61 | # proto: tcp 62 | # with_items: [80, 443, ssh, 32400, 2015] 63 | # become: true 64 | # - ufw: 65 | # rule: allow 66 | # src: '{{ item }}' 67 | # with_items: 68 | # - 172.16.0.0/12 69 | # become: true 70 | # - ufw: 71 | # state: enabled 72 | # become: true 73 | 74 | 75 | # - name: Create user 76 | # user: 77 | # name: plexflix 78 | # uid: 2000 79 | # groups: docker 80 | 81 | 82 | # Add my public-key 83 | # git clone plexflix 84 | # create configuration -------------------------------------------------------------------------------- /plexbar/README.md: -------------------------------------------------------------------------------- 1 | PlexBar 2 | ======= 3 | 4 | This is a plugin for [BitBar](https://getbitbar.com/). It allows you to keep an eye on your services: 5 | 6 | 7 | ![Screenshot](https://res.cloudinary.com/dmmwyzcym/image/upload/v1572967589/Screen_Shot_2019-11-03_at_23.54.31_a5tv3a.png) 8 | 9 | 10 | Configure your servers in `~/.plexbar.yml`. Sample config: 11 | ``` 12 | tautulli: 13 | url: https://server.mydomain.com/tautulli 14 | apikey: 5af0de9cf6f3befb43cb764174a952cd 15 | 16 | sonarr: 17 | url: https://server.mydomain.com/sonarr 18 | apikey: 7b99cb6242d0ece3a21dd661473a85dc 19 | 20 | radarr: 21 | url: https://server.mydomain.com/radarr 22 | apikey: 9e6f41fbb004e144ec9a0e69b0f0b011 23 | 24 | transmission: 25 | url: https://server.mydomain.com/transmission 26 | user: user 27 | password: password 28 | ``` -------------------------------------------------------------------------------- /plexbar/plexbar.5s.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # Plex Bar 4 | # v0.0.1 5 | # Corintio 6 | # corintio 7 | # Monitors your Media Server services 8 | # 9 | # tautulli 10 | # https://github.com/corintio/plexflix/tree/master/plexbar 11 | 12 | require 'open-uri' 13 | require 'json' 14 | require 'yaml' 15 | 16 | # External files 17 | CONFIG_FILE="~/.plexbar.yml" 18 | CACHE_FILE="/tmp/plex-info-cache" 19 | 20 | # Icons 21 | PMS_ICON="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" 22 | TAUTULLI_ICON="iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAA3hJREFUOBFFVF1MXEUU/mbm3gWyEJYfs/wUdVl0aQvdGtCWbfCBRuWBBxpZm9Q09cGXkrYmGmNSEx8MatO0VatGmlRNY0IkmxoiFtDUYFq6VRtrC+K2QKBKgaXWpXUL3b27M9Mz8NCTnJwzd+Z88835uQxrIshI49bW1YWg2B5yt5HWkDLSSWhEHy/CV0PRsYu0JglTTEQyhMmJRGRDQ4N9L5X+iGneKQSH1hpKqdWjnHMIzjA2T+vb+BwoPgD8nKVNYdAJIyxGx699LzhvlVKCMZaheM4YuNknPDmehD64TdrrSji6h/HD6J+lbQbEUEdWseN5ubm7Cgry05awuJPJWCZYE/1cATa9DP5chRQH2xdV8/oFR2hPYOBSpghYHLSqA4FG27b33f4vgbk/pi3C4xvqgmv0DQJxdMguO3SRVHwhkWPPLFK6vHp/q3/j1zw/N3938t4yfI9VOZFIRBw9egx/TfwNIQQ0BZs3Zm4w7GpycD5WiOePVPETl3Mym7wcN5b4HqugwB0avfo7Dr3/Lu/o6EAymcSZgUHErk+hdp0Hw6MSb4YdVBan0fZFIfHj2OzRLK2IFrCVp1IpUypMTk7yRCIBk0S/vxoLN2cwPJ2HwKMOttcvozfqprcwbClRWMlQcinLxNAvCj1Fb3nLKvL6B8/qL098xiyXjRd37MDc/Dz2Nk6g8wUPfp1gOHbaQtN6F1a0DSWlpkqZ190XpaXeMPkV3pJCOfW/zc9+1wt/cCteaQvhWfdJlLgXcJpa54qrHDl344hdu47yikqptDYsYpxxPWKa5MpSVoWfcqO5qRH/fPsqrPh5pEsP4I77NUjPFqjYRVT5avDSzp0Yu3oZtmUKhihaQxufRvEm/c7LtXrmpCc73l2vT3VCf9j1tk6lHZ3NZvXIhaju6enRs7OzeuX+iuzqes9kULe3tz9jDUXHLwHBTyuL9T6v546ML9k49GO1iLnOIRgaoaQq9PZ+g2BwM8rKypRlWVmfz+cigE/6+vp+W+UBWK8f/8mqSWcCrVNxhThzOxsyc7ylpWW1U+mwGTR1699bLn+13/XB4SNDtH6D1PTJ2lQBDTa1zMcoZ3vrHyF+TMBlW6ZasMnSzYiOnDMx3aT7SR8O00MQYHuovunmXbabKd3MOHvCRFBvTNB0Xqh90n+qv7//F/ONZPUX8ACCi2juYZgsCAAAAABJRU5ErkJggg==" 23 | SONARR_ICON="iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAAC90lEQVQ4EW1TW0hUURTdd7yO9lX2VVE2RBmJZJH5IF+hEH30ENMoQimCSPzwo8CgaBA0DBtQKTBRIe2jUosgAiUtrUgj00zJRzKOOs6M6cydcebO487s9r7i2EcbFmefw1lnv9YBINMDaHhls1qtiZIk1ROGCBaC1eFwjDidzgbyk9duEUevD3PCztLSksHn8yGb3+9Ht9utgn22QCCAdru98X+PACSefd3Y1IyS5Ah6ZNlPURW6HLQ7HEH23R6Pnx5U2js7EdIKetcfUdd5k8lwz1CHABny7fKboamJX+hyuVCWvSpWV1fROPMba6qr6E6uXHbrLk5NTKiZCFTzwS0xMSNOhz2kr6wWHkbuE3YMdUFxchzs0e0GjSDA3IIZng/Pwnh8JhTO98ODqgpl565YcWVlJQ3sklTL9Xl9Pv/o928IReWYfP8ZRQKE7EsIOcWqn1L5BKGwDAc+9aM/EPCHQiGkxjYBdXaQm8R1ko+l166qhPSGbswa8GLWoBfTmz/gJnrwfN4p/LO8zETF6/VSv6QxkYJvUxQFaBW02ijYG7cfIPsCaA6lgyeoEA0hOiEV5DPX4UD8ZoiOigZFCWgoKHN0IqUaHiMIABEa2mr4GEAgctioF6IYQYfhExDojMlWURR5g5zJnHkRoKcVfCOfiRCpIjA2APDqEcwYTcCRyVCr1fI6yz2op1SQ5zwzPYW6kxcwtaoVddzE/BKEc6W4nfxMQzvC8QL8+WMYqX61icRt0VA3m0h9nJnmZccLNB7OhS9fByE2KQFKYjxQutUDCRkp0NfbDZB6Ap62tVEWPrU6ynhNlaS42vYOUhjkyKcLL4bedb3FBbMZSYU0HQkXLRb8+L4Hiy5foTu5MivWZrM1cw0blpT/5sadCjTNGhWeMymRxxpi0NiCPireZrUEKmtqEY7m9RFxrZ3//qrpycm6YDCELBL+VCxnBs+cz9hMptkWIquTC3P1+o1R0o88QqJ6TBglSAQXYZyyaCHZH1tPeZ38F9gqHuoZR+ePAAAAAElFTkSuQmCC" 24 | RADARR_ICON="iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAAztJREFUOBFlU11oFFcU/mZmN5ukmgSLgvbFSK1WUsQHzYYY0ELVFq0tJC0IfVD6A31oH6wiFhRFRLQoxuw2a/tQiFQU05ag2WQTI/5EY+ODqW7NJpLEWPoiO5ndzOzOzO7c03NHNwo9cLgf557v3HPv/Q7AdvEiNLlKc+NYa6c6I9b00H3r6T3Ld8b50c6o24N1z7Ne4ZTIRNDSd1vbvcw0EXlU8ARlLcd3iWXMyzwlfbg9RtcQkIVKXPCuduUIene0bKeRRxPkuK6r63rBMGaE9BnGDgdHxyfFZ9sbqOsQEszxi6iy0o1TSlvf5Mebvt44ZXfsXyZS4xPB6uqqQFmwTJE+v6oqMDU5EWxaVUv1739u37e/ee/qcSUiuaDBLWu/+xDUfXVQkP3E+/vCJ9QUXkNX4r3UFo1RayRGiUQv1b1TRx3nzvHB5N28Myz2NnPjN9fXo+sgIgf276FMNuvq2RzlTYOGL58hWbu+GrRxCXx8qbOTbNsmwzDINC33xLEj9Mf3+DEw9hiNq5ubUFlerpjmLJxACHXv7sS/13TUFH6BqpiYEnvxxvoP4DoOhBAIhYJK3Zowkh1oVD0Ny6tqFnA/gr9SkQfDyc1icUUKFeUKJy/AipohUC4NoWhQOIWE0CrmzYcawlv+I8q38E3yuYgKwUuBsZQHY46Qwqn813P2Aqqah/GsoXNl1ZP7KnlQKl7HWCbMLT+DW6jE1LOFCAaDULQAn05+rpk1IByMqSvfxOBf924hxw+kaRrKQiH0J+JYsWk3tn1pYNtXI6jdGsXAhWMoK8xACVbCdlwaYc7yWgyCbm9et5u/MTFww5fbr+fP09LapRTv7qZTrVE6ebqNenp66bW3G+h66yKi2RGv7/qQ2PMRX+jWhrB/pYHjiO37dhedjp7Ns7y8ZDJJxWKRLMtkt8grFij5KEXNjfC6jyK/74vN1P8Dfnr5HizLy4eR+HTLano4+lg8l3K6mGEZS9f1dFFK+UFqQjS3tFDXYfSXpDw3EHJA9D8jZ0X2Hyk2KhQFZUzbd4llTO6l7575+X/DNDdV3FOuD+H82G/t1vSdBzzKTo7dfDL0MJ/6PZaLo6HUdonzHy7qBnX7KhkoAAAAAElFTkSuQmCC" 25 | SEEDBOX_ICON="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" 26 | TRANSMISSION_ICON="iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAA1VJREFUOBFVU11MFFcU/u7MMLvKdrGysCAgBhVQE60G/AlgooltbNPEPhjTpIlF2ibaVIl9UjRsk/rSmJo09S+KBrFPfehPWqBY26RsVoxoatOwNjUUtqssZlnWnVmYnb/Te0d46E3O3Hu/853vnnvOHQY+iEhijLkL6/W2ZXXMJZNtlparIALUkpLU0qrKqFLku8Z5f/4vRgQLQAzTss6mosM0fuok3eO6wwsm1uNdJyg1/BuZpvn5C/bCwYub3MzMD8b3373xsP2QqwLOkoPvMCVUxrif2em0O9/bRyYgb+y5Ki3Z99ZAsLT09cVY5HT97PS1HuoH5h/13XAdxyGrUCDbtj2zzAIJ7NFXN13BSV29QpqmnRMCCr/C+omfBj+eONRBxYBPz2TYbCYDXhT4/H7vkIJhQBRI+ARn7L333brq6s4CUY/0LJV61/rlNniutly/kmWPdSJWVobY3j3I53LIaxpib+5FLBRC9qOjkBtXMZlzzVtDyE5NtSv5xGSrNvQjeCUZzT6BrzaEZfvextzUUxj5PHjVEaxZiaXN22D0fwM3MQEWhJzr/xbK/gMtipV9Xm79HgdrqJXcvybh6z6Mqs7jmL57F3M8AyHwUnsHKrbvwGRpCEZ3BPK6OtjxcZjZ2XLFcV1HlBoEEvdUGtbBsm1Y/AHwAnkCtksepjQ0cBpPVX7RecclR2HFxRNq2/Z6c3hE+CCFw9BnZjCf/BfZB/c5IMG3vBTa6tWQysOCAko8JnXbZrBAYFKSKisH/Tt3CWXC7p2gQAAaFzBGR8G6ToGdOAnjwSj0dBrgPraHc3Nw/btehRwODyj2mjW9xpambp5Uib2lyXX9fknmqS8/8iHMTZuELNSWFu9kh2eDzU0uu/WrOt+8tfByff1NZQNjmXtjY2fCFy98Nn6my0o3NRcxEk3hI1wh2guKx70t8f9F+rrPqjv/pe95Y2OkjrFpRCIRjxy9c+d6/PIlGuL8AaDAzeKvzhEm1gITvvilixQdifUKRf4IGfM+fBLAYDR6OvD0yellj/8usv94CDuZEDCUqhooG1/B7Nq19lzlik9fa2v7ROBe7OJCzLzn9MXISHWtrn+g6vpuyTBqPL/fnzSDwZ//UdWeY62tCRG4yP8PUtanvCymhNMAAAAASUVORK5CYII=" 27 | NAVIDROME_ICON="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" 28 | 29 | STATE_ICON = { 30 | "playing" => "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAAplJREFUOBFlUz1oFEEUnje3Sa4wMSpaJbt7uxsvslgdYrtaqM0FQRtFCAhiJYiKSMA+RcDGQiysRAV/EAuFgLIIlgabiAmX8+5yqQ4koEk4b2bH781uIOCD2Tfve7/z3lsSOREYnywMwyNCqWtGUB2An+O0DtV7UupxY2OjW9iCCVMqBMNC6Hl3yIhFIjqNcD+FEZ8N0TIJM0aCZskZunVwfD/92txMYc8k+cOZReT5T6bDyARe5aHrugcY20vx4Xhf5PrzVdiEnv9mrw7O3hw7R657ZY/CiaJoJEkSJxHC2cUrbqWeB6ksWCwIAneqEiBqDsRxPAyFLa1w4jtXSRyQscD37x0NQk4YS9L6RpZloq8H91lZLpe5H1no+rMwvMl3HMZKjUbjL7hotlrzSustQfI2otMM1O+63e4Olzs6OsrGIBobLjkPEOhRLgvFINvkMr2A4QyXN4XLUmEkRJrfjDQDjcpI0nU07Yfv+yehyXq9Xv48Ml+llIfs+6QUtjTrmuQB8MX0ULvBRhBVYRiwpt/v26lJQwOWOcAG7KZZsJQWnJekxGsilmSmQ7z7Oe6liYkJzSD7oHd9BDCfINUZTNNUiYRvSJ+J4YEevFxrt2qrnU6zVqsNAdaw4aaCzAV8PopgMjhRxUgCz7vKMN5aLvg484JsKbu60PfPVwPszWTljH0PmvQMDblktIobnc53OHGnNbLajtvJpCn3V/HeyMy0s8wsNjutszZAAod1z/8mScba6IvNdvs1AvxHyHwOz/2A8rvl7e1jy73eHw7A5WnewJ2trVdDJaeulFpFF54akeFHwiAwBfTksuM4x5VWqVMemVlZWfnNvraC3SDgoshyF6M7hcOQHSWm+UWQWVhrtd5asEj8D30S9wRVOdHHAAAAAElFTkSuQmCC", 31 | "paused" => "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAAUVJREFUOBF9k00yBEEQRgsLrmAxoWdng2DvAByEG+AcbMZuVmZOQHAHRHAB005hIfhedX4V1R1NRrzO7PypzKquTqmT9dCoA3ErWvETYM8EMUupKYYiN8JFz7LnAbb95Fjq2vQkL0l3YuqMSuNbCHLI7Yk7X/S8KdGh10Xvl4JFyiTsy51lZtnW81hsBdj4LJ4knwkHxgI7jkqfh29XGoifCUsjA9+M8U7Ei1iJTYF8dyonkYjYR86HeBWnLDAR7wJZ69To0zHrN2VNhgc0WvmfkwU+xV4kedyxGses95XUssC9OBKN+BLIRqfyljyyfeQ04lA8iHw9WXXBSwifjE/312dcKkZNudq+SFySWphweE5X8lF8XSdi+yozyXQYDJ87P9bxuoMnoQN3Yx5g4xt2LrXFUBL74tdto4CiVfjKnvWea34BWShPN2evMNsAAAAASUVORK5CYII=", 32 | "buffering" => "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAABaklEQVQ4EY2TyypFURyHN4cRSlF0lEsmhiaewIgXoAwMztwDeQeZSDFTSiYoMTJxK2SgMHD3fWuv/24fUX71rfW/rr322msXRakhpnv4+ifW2lN0OaA3eIABuAHVAS6o6vYwvrX2JDXyvMD8ATPQCz5hMKNtzNwnWKtSbywwSsDkvJk/NEfcmrGcT71uT9QxrCcrr/7DXsM/zbF6X3UWSyR970kYh42M9gSYWwYV55ec2IHODtxBEy7gHEbgFnYhVO9Jsc6c6WE+hEvwaS24hiPoAxW1bdtwe+oZrmAaTsBid3MAj6CitvTyGKuu5oKpWlbbJnMqakuPMQ5kFtvCxZzxk8ZnNWbOGhU9yYkV9/D2oRs2wQbRNmbOGhU95W0i4Hu+wgpsgY36or0N5vSboBrVgOH19JD8jDa8gFdbtI2Ze4K2q4yffqIzZou8qu8QzTEbM2eNtf541Xt4IP0GkA1ekt8wp6xNh/gNyddfpPZ40EwAAAAASUVORK5CYII=" 33 | } 34 | 35 | $cache = Hash.new 36 | $error = false 37 | 38 | if ARGV.length != 0 && ARGV[0] == "refresh" 39 | File.delete(CACHE_FILE) 40 | exit 41 | end 42 | 43 | class Integer 44 | def to_filesize 45 | { 46 | 'B' => 1024, 47 | 'KB' => 1024 * 1024, 48 | 'MB' => 1024 * 1024 * 1024, 49 | 'GB' => 1024 * 1024 * 1024 * 1024, 50 | 'TB' => 1024 * 1024 * 1024 * 1024 * 1024 51 | }.each_pair { |e, s| return "#{(self.to_f / (s / 1024)).round(1)} #{e}" if self < s } 52 | end 53 | 54 | def to_speed 55 | "#{self.to_filesize}/s" 56 | end 57 | end 58 | 59 | class TautulliPlugin 60 | attr_reader :name 61 | 62 | def initialize(config) 63 | @name = 'tautulli' 64 | @output = Hash.new 65 | @base_url = config["url"] 66 | @api_key = config["apikey"] 67 | end 68 | 69 | def server_name 70 | @servers_info ||= tautulli("get_servers_info") 71 | @server_name ||= @servers_info["data"][0]["name"] 72 | @server_version ||= @servers_info["data"][0]["version"] 73 | 74 | out = ["#{@server_name} | image=#{TAUTULLI_ICON} href=https://app.plex.tv/desktop"] 75 | out << "#{@server_version} | image=#{TAUTULLI_ICON} href=#{@base_url}/home alternate=true" 76 | out 77 | end 78 | 79 | def num_sessions 80 | sessions.size 81 | end 82 | 83 | def total_bandwidth 84 | activity = tautulli("get_activity") 85 | @total_bandwidth ||= activity["data"]["total_bandwidth"] / 1000 86 | # Main icon / Tautulli 87 | if num_sessions == 0 88 | out = ["No sessions in progress"] 89 | else 90 | out = ["Total Bandwidth Used: #{@total_bandwidth} Mbps"] 91 | end 92 | out 93 | end 94 | 95 | def plex_sessions 96 | out = [] 97 | sessions.each do |session| 98 | out = out + format_session(session) 99 | end 100 | out = ["---"] + out if out.size > 0 101 | out << "---" if out.size > 0 102 | out 103 | end 104 | 105 | def recently_added 106 | out = [] 107 | recent = tautulli("get_recently_added", "count=20") 108 | if recent["data"]["recently_added"].size > 0 109 | out << "Recently added | color=white" 110 | recent["data"]["recently_added"].each do |item| 111 | out << "--#{get_title(item)} | href=#{@base_url}/info?rating_key=#{item["rating_key"]}" 112 | end 113 | end 114 | out 115 | end 116 | 117 | def libraries 118 | out = ["Libraries"] 119 | libs = tautulli("get_libraries") 120 | libs["data"].each do |lib| 121 | count = lib["count"] 122 | count += "/#{lib["parent_count"]}/#{lib["child_count"]}" unless lib["section_type"] == "movie" 123 | url = "#{@base_url}/library?section_id=#{lib["section_id"]}" 124 | out << "--%-14s %15s | color=white font=Courier href=%s" % [lib["section_name"], count, url] 125 | end 126 | out 127 | end 128 | 129 | private 130 | 131 | def tautulli(cmd, params = "") 132 | @output[cmd+params] ||= ( 133 | content = URI("#{@base_url}/api/v2?apikey=#{@api_key}&cmd=#{cmd}&#{params}").read 134 | JSON.parse(content)["response"] 135 | ) 136 | end 137 | 138 | def sessions 139 | activity = tautulli("get_activity") 140 | @sessions ||= activity["data"]["sessions"] 141 | end 142 | 143 | def get_title(item) 144 | return "#{item["title"]}" if item["media_type"] == "clip" 145 | return "#{item["title"]}" if item["media_type"] == "movie" 146 | return "#{item["full_title"]}" if item["media_type"] == "show" 147 | return sprintf("%s - %s", item["parent_title"], item["title"]) if item["media_type"] == "season" 148 | season = item["parent_media_index"].to_i 149 | episode = item["media_index"].to_i 150 | title = sprintf("%s - S%02dE%02d", item["grandparent_title"], season, episode) 151 | end 152 | 153 | def format_session(session) 154 | title = get_title(session) 155 | href="#{@base_url}/info?rating_key=#{session["rating_key"]}" 156 | user_url = "#{@base_url}/user?user_id=#{session["user_id"]}" 157 | icon = STATE_ICON[session["state"]] || STATE_ICON["buffering"] 158 | out = ["#{title} (#{session["progress_percent"]}%) | href=#{href} templateImage=#{icon}"] 159 | out << "--User: #{session["friendly_name"]} | href=#{user_url}" 160 | out << "--Quality: #{session["quality_profile"]}" 161 | out << "--Bandwidth: #{session["bandwidth"].to_i / 1000}Mbps" 162 | out << "--#{session["platform"]}/#{session["product"]}" if session["platform"] + session["product"] != "" 163 | out << "--#{session["player"]}" if session["player"] 164 | end 165 | 166 | end 167 | 168 | class PVRPlugin 169 | attr_reader :name 170 | 171 | def initialize(name, config, api_version = "") 172 | @output = Hash.new 173 | @base_url = config["url"] 174 | @api_url = "#{@base_url}/api" 175 | @api_url = "#{@api_url}/#{api_version}" if api_version != "" 176 | @user = config["user"] 177 | @password = config["password"] 178 | @api_key = config["apikey"] 179 | @name = name 180 | if name == "sonarr" 181 | @release_field = "airDate" 182 | @item_type = "episodes" 183 | @period = 6 184 | else 185 | @release_field = "physicalRelease" 186 | @item_type = "movies" 187 | @period = 30 188 | end 189 | end 190 | 191 | def missing 192 | @missing ||= call_api("wanted/missing") 193 | return if @missing["totalRecords"] == 0 194 | 195 | out = ["#{@missing["totalRecords"]} missing | href=#{@base_url}/wanted/missing"] 196 | @missing["records"].each do |record| 197 | out << "--#{build_title(record)} | color=white" 198 | end 199 | out << "--More... | href=#{@base_url}/wanted/missing" if @missing["totalRecords"] > 10 200 | out 201 | end 202 | 203 | def calendar 204 | now = Date.today 205 | dstart = now.strftime("%F") 206 | dend = (now + @period).strftime("%F") 207 | @calendar ||= call_api("calendar", "&start=#{dstart}&end=#{dend}") 208 | return if @calendar.size == 0 209 | 210 | out = ["Upcoming #{@item_type} | href=#{@base_url}/calendar"] 211 | @calendar.sort! {|x,y| (x[@release_field]||"") <=> (y[@release_field]||"") } 212 | @calendar.each do |record| 213 | if record[@release_field] 214 | airdate = Date.parse(Time.parse(record[@release_field]).localtime.to_s) 215 | if (airdate - now) > 6 216 | date = airdate.strftime("%b %e") 217 | else 218 | date = airdate.strftime("%a") 219 | end 220 | title = sprintf("%s - %s", date, build_title(record)) 221 | if record["hasFile"] 222 | out << "--#{title}" 223 | else 224 | out << "--#{title} | color=white" 225 | end 226 | end 227 | end 228 | out 229 | end 230 | 231 | private 232 | 233 | def call_api(cmd, args = "") 234 | @output[cmd+args] ||= ( 235 | options = @user ? {http_basic_authentication: [@user, @password]} : Hash.new 236 | content = URI.open("#{@api_url}/#{cmd}?apikey=#{@api_key}#{args}", options).read 237 | JSON.parse(content) 238 | ) 239 | end 240 | 241 | def build_title(record) 242 | if @name == "sonarr" 243 | season = record["seasonNumber"] 244 | episode = record["episodeNumber"] 245 | title = sprintf("%s - S%02dE%02d", record['series']['title'], season, episode) 246 | else 247 | title = record["title"] 248 | end 249 | end 250 | end 251 | 252 | class TransmissionPlugin 253 | attr_reader :name, :web_url 254 | 255 | def initialize(config) 256 | @name = "transmission" 257 | @base_url = config["url"] 258 | @web_url = "#{@base_url}/web/index.html" 259 | @uri = URI.parse("#{@base_url}/rpc") 260 | @user = config["user"] 261 | @password = config["password"] 262 | @output = Hash.new 263 | end 264 | 265 | def speed 266 | resp = call_rpc("torrent-get") 267 | if resp["arguments"]["torrents"].size == 0 268 | out = ["No downloads in progress"] 269 | else 270 | up = down = 0 271 | resp["arguments"]["torrents"].each do |torrent| 272 | up += torrent["rateUpload"] 273 | down += torrent["rateDownload"] 274 | end 275 | out = ["↓%s • %s↑" % [down.to_speed, up.to_speed]] 276 | end 277 | out 278 | end 279 | 280 | def queue 281 | resp = call_rpc("torrent-get") 282 | out = [] 283 | if resp["arguments"]["torrents"].size > 0 284 | out << "%d downloads" % resp["arguments"]["torrents"].size 285 | torrents = resp["arguments"]["torrents"].sort do |a, b| 286 | s = (a["isFinished"]?1:0) <=> (b["isFinished"]?1:0) 287 | s = ((a["isStalled"] || a["eta"]<0)?1:0) <=> ((b["isStalled"] || b["eta"]<0)?1:0) if s == 0 288 | s = a["eta"] <=> b["eta"] if s == 0 289 | s 290 | end 291 | torrents.each do |torrent| 292 | color = "blue" if torrent["isFinished"] 293 | color = "gray" if torrent["isStalled"] || torrent["eta"] < 0 294 | color = "white" if color.to_s.empty? 295 | percent = torrent["percentDone"].to_f * 100 296 | speed = torrent["rateDownload"].to_speed 297 | out << "--%-50s %2.1f%% | color=%s font=Courier " % [torrent["name"][0,50], percent, color] 298 | out << "--%-50s %s | color=%s font=Courier alternate=true" % [torrent["name"][0,50], speed, color] 299 | end 300 | end 301 | out 302 | end 303 | 304 | private 305 | 306 | def call_rpc(method) 307 | @output[method] ||= ( 308 | _, resp = with_cached_value("transmission_id") do 309 | raw_call_rpc(method) 310 | end 311 | resp 312 | ) 313 | end 314 | 315 | def raw_call_rpc(method, id="xxx", json = true) 316 | header = {'x-transmission-session-id': id} 317 | req = Net::HTTP::Post.new(@uri.path, header) 318 | req.basic_auth @user, @password unless @user.empty? 319 | req.body = '{"method":"' + method + '","arguments":{"fields":["id","name","error","errorString","eta","isFinished","isStalled","leftUntilDone","percentDone","rateDownload", "rateUpload"]}}' 320 | 321 | http = Net::HTTP.new(@uri.host, @uri.port) 322 | http.use_ssl = @uri.scheme == "https" 323 | resp = http.start {|http| http.request(req) }.body 324 | if resp.include? "409: Conflict" 325 | new_id = /\X-Transmission-Session-Id\: (.*)\<\/code\>/.match(resp)[1] 326 | id, resp = raw_call_rpc(method, new_id, false) 327 | end 328 | return id, JSON.parse(resp) if json 329 | return id, resp 330 | end 331 | end 332 | 333 | class NavidromePlugin 334 | attr_reader :name 335 | 336 | def initialize(config) 337 | @name = "navidrome" 338 | @base_url = config["url"] 339 | @user = config["user"] 340 | @password = config["password"] 341 | @output = Hash.new 342 | end 343 | 344 | def now_playing 345 | out = [] 346 | resp = call_rest("getNowPlaying") 347 | resp = resp["subsonic-response"]["nowPlaying"]["entry"] || [] 348 | if resp.size == 0 349 | out = ["Nothing is playing right now"] 350 | else 351 | out = [] 352 | resp.each do |entry| 353 | mins = entry["minutesAgo"] == 1 ? "min" : "mins" 354 | out << "#{entry["username"]} (#{entry["playerName"]}) - #{entry["minutesAgo"] || 0} #{mins} ago | color=white" 355 | out << "--\"#{entry["title"]}\" | color=white" 356 | out << "--from #{entry["album"]} | color=white" 357 | out << "--by #{entry["artist"]} | color=white" 358 | end 359 | out 360 | end 361 | end 362 | 363 | private 364 | 365 | def call_rest(method, args = "") 366 | default_args = "u=#{@user}&p=#{@password}&c=plexflix&f=json&v=1.12.0" 367 | @output[method+args] ||= ( 368 | content = URI.open("#{@base_url}/rest/#{method}?#{default_args}#{args}").read 369 | JSON.parse(content) 370 | ) 371 | end 372 | 373 | end 374 | 375 | def get_counter 376 | @counter ||= ( 377 | c = $cache['counter'] ? $cache['counter'] : 0 378 | $cache['counter'] = c + 1 379 | c 380 | ) 381 | end 382 | 383 | def with_cached_value(name) 384 | value = $cache[name] 385 | result = yield value 386 | new_value = Array(result)[0] 387 | $cache[name] = new_value 388 | result 389 | end 390 | 391 | def every(num, obj, func) 392 | name = func.to_s 393 | name = obj.name + "_" + name if obj != self 394 | begin 395 | if get_counter % num == 0 || $cache[name].nil? 396 | out = obj.send(func) 397 | out = [] if out.nil? 398 | $cache[name] = out.join("\n") 399 | end 400 | puts $cache[name] 401 | rescue => e 402 | $error = true 403 | puts "#{name} :exclamation: | color=red" 404 | puts "--Error: #{e} | color=white" 405 | e.backtrace.each {|line| puts "--#{line} | color=white"} 406 | end 407 | end 408 | 409 | def with_captured_stdout 410 | original_stdout = $stdout 411 | $stdout = StringIO.new 412 | yield 413 | $stdout.string 414 | ensure 415 | $stdout = original_stdout 416 | end 417 | 418 | ####################################################################################### 419 | ### MAIN 420 | 421 | # Load config if it exists 422 | if File.exist?(File.expand_path(CONFIG_FILE)) 423 | File.open(File.expand_path(CONFIG_FILE), "r:UTF-8") do |f| 424 | @config = YAML.load(f.read) 425 | end 426 | end 427 | 428 | # Load cache if it exists 429 | if File.exist?(CACHE_FILE) 430 | begin 431 | File.open(CACHE_FILE, "r:UTF-8") do |f| 432 | $cache = JSON.parse(f.read) 433 | end 434 | rescue 435 | $cache = Hash.new 436 | end 437 | end 438 | 439 | tautulli = TautulliPlugin.new(@config["tautulli"]) 440 | output = with_captured_stdout do 441 | every 1, tautulli, :server_name 442 | every 1, tautulli, :total_bandwidth 443 | every 24, tautulli, :libraries 444 | every 3, tautulli, :recently_added 445 | every 1, tautulli, :plex_sessions 446 | 447 | # Sonarr 448 | if @config["sonarr"] 449 | sonarr = PVRPlugin.new("sonarr", @config["sonarr"]) 450 | puts "---" 451 | puts "Sonarr | image=#{SONARR_ICON} href=#{@config["sonarr"]["url"]}" 452 | every 12, sonarr, :calendar 453 | every 6, sonarr, :missing 454 | end 455 | 456 | # Radarr 457 | if @config["radarr"] 458 | radarr = PVRPlugin.new("radarr", @config["radarr"], "v3") 459 | puts "---" 460 | puts "Radarr | image=#{RADARR_ICON} href=#{@config["radarr"]["url"]}" 461 | every 12, radarr, :calendar 462 | end 463 | 464 | # Transmission 465 | if @config["transmission"] 466 | transmission = TransmissionPlugin.new(@config["transmission"]) 467 | puts "---" 468 | puts "Transmission | image=#{TRANSMISSION_ICON} href=#{transmission.web_url}" 469 | every 1, transmission, :speed 470 | every 1, transmission, :queue 471 | end 472 | 473 | # Navidrome 474 | if @config["navidrome"] 475 | navidrome = NavidromePlugin.new(@config["navidrome"]) 476 | puts "---" 477 | puts "Navidrome | image=#{NAVIDROME_ICON} href=#{@config["navidrome"]["url"]}" 478 | every 1, navidrome, :now_playing 479 | end 480 | 481 | puts "---\nRefresh... | bash='#{$0}' param1=refresh terminal=false refresh=true" 482 | end 483 | 484 | begin 485 | num_sessions = tautulli.num_sessions 486 | num_sessions = "" if num_sessions == 0 487 | rescue => e 488 | num_sessions = ":interrobang:" 489 | end 490 | 491 | if $error 492 | puts ":exclamation: | image=#{PMS_ICON} color=red" 493 | else 494 | puts "#{num_sessions} | image=#{PMS_ICON}" 495 | end 496 | puts "---" 497 | 498 | STDOUT.puts output 499 | 500 | # Save cached info 501 | File.open(CACHE_FILE, "w:UTF-8") do |f| 502 | f.write $cache.to_json 503 | end 504 | -------------------------------------------------------------------------------- /plexbar/testdata/anime.json: -------------------------------------------------------------------------------- 1 | { 2 | "response": { 3 | "message": null, 4 | "data": { 5 | "sessions": [ 6 | { 7 | "rating": "", 8 | "transcode_width": "", 9 | "labels": [], 10 | "stream_bitrate": "4794", 11 | "bandwidth": "5169", 12 | "optimized_version": 0, 13 | "video_language": "", 14 | "parent_rating_key": "17075", 15 | "rating_key": "18883", 16 | "platform_version": "68.0", 17 | "transcode_hw_decoding": 0, 18 | "thumb": "/library/metadata/18883/thumb/1535253492", 19 | "title": "The 1,000,000,000 Man - The Strongest Sweet Commander, Katakuri", 20 | "video_codec_level": "40", 21 | "tagline": "", 22 | "last_viewed_at": "", 23 | "audio_sample_rate": "44100", 24 | "user_rating": "", 25 | "platform": "Chrome", 26 | "collections": [], 27 | "location": "wan", 28 | "transcode_container": "mp4", 29 | "audio_channel_layout": "stereo", 30 | "local": "0", 31 | "stream_subtitle_format": "", 32 | "stream_video_ref_frames": "", 33 | "transcode_hw_encode_title": "", 34 | "stream_container_decision": "transcode", 35 | "audience_rating": "", 36 | "full_title": "One Piece - The 1,000,000,000 Man - The Strongest Sweet Commander, Katakuri", 37 | "ip_address": "173.34.171.106", 38 | "subtitles": 1, 39 | "stream_subtitle_language": "English", 40 | "channel_stream": 0, 41 | "video_bitrate": "3111", 42 | "is_allow_sync": 1, 43 | "stream_video_bitrate": "4666", 44 | "summary": "", 45 | "stream_audio_decision": "copy", 46 | "aspect_ratio": "1.78", 47 | "audio_bitrate_mode": "", 48 | "transcode_hw_decode_title": "", 49 | "stream_audio_channel_layout": "Stereo", 50 | "deleted_user": 0, 51 | "library_name": "TV Shows", 52 | "art": "/library/metadata/16983/art/1535253493", 53 | "stream_video_resolution": "720", 54 | "video_profile": "high", 55 | "sort_title": "1,000,000,000 Man - The Strongest Sweet Commander, Katakuri", 56 | "stream_video_codec_level": "", 57 | "stream_video_height": "720", 58 | "year": "2018", 59 | "stream_duration": "1435185", 60 | "stream_audio_channels": "2", 61 | "video_language_code": "", 62 | "transcode_key": "/transcode/sessions/g4nsuj3mngt7h9ik27v1q9fk", 63 | "transcode_throttled": 0, 64 | "container": "mkv", 65 | "stream_audio_bitrate": "128", 66 | "user": "Corintio", 67 | "selected": 1, 68 | "product_version": "3.65.1", 69 | "subtitle_location": "embedded", 70 | "transcode_hw_requested": 0, 71 | "video_height": "720", 72 | "state": "playing", 73 | "is_restricted": 0, 74 | "email": "plex@domain.com", 75 | "stream_container": "mp4", 76 | "transcode_speed": "9.6", 77 | "video_bit_depth": "8", 78 | "stream_audio_sample_rate": "", 79 | "grandparent_title": "One Piece", 80 | "studio": "Fuji TV", 81 | "transcode_decision": "transcode", 82 | "video_width": "1280", 83 | "bitrate": "3111", 84 | "machine_id": "baigi6fc6fhl28b8sfqg1q6d", 85 | "originally_available_at": "2018-08-26", 86 | "video_frame_rate": "23.976", 87 | "synced_version_profile": "", 88 | "friendly_name": "Corintio", 89 | "audio_profile": "lc", 90 | "optimized_version_title": "", 91 | "platform_name": "chrome", 92 | "stream_video_language": "", 93 | "keep_history": 1, 94 | "stream_audio_codec": "aac", 95 | "stream_video_codec": "h264", 96 | "grandparent_thumb": "/library/metadata/16983/thumb/1535253493", 97 | "synced_version": 0, 98 | "transcode_hw_decode": "", 99 | "user_thumb": "https://plex.tv/users/90df34879a276d6d/avatar?c=1534448207", 100 | "stream_video_width": "1280", 101 | "height": "720", 102 | "stream_subtitle_decision": "burn", 103 | "audio_codec": "aac", 104 | "parent_title": "Season 19", 105 | "guid": "com.plexapp.agents.thetvdb://81797/19/72?lang=en", 106 | "audio_language_code": "jpn", 107 | "transcode_video_codec": "h264", 108 | "transcode_audio_codec": "aac", 109 | "stream_video_decision": "transcode", 110 | "user_id": 1247844, 111 | "transcode_height": "", 112 | "transcode_hw_full_pipeline": 0, 113 | "throttled": "0", 114 | "quality_profile": "Original", 115 | "width": "1280", 116 | "live": 0, 117 | "stream_subtitle_forced": 0, 118 | "media_type": "episode", 119 | "video_resolution": "720", 120 | "stream_subtitle_location": "segments-video", 121 | "do_notify": 1, 122 | "video_ref_frames": "6", 123 | "stream_subtitle_language_code": "eng", 124 | "audio_channels": "2", 125 | "stream_audio_language_code": "jpn", 126 | "optimized_version_profile": "", 127 | "relay": 0, 128 | "duration": "1435185", 129 | "rating_image": "", 130 | "is_home_user": 1, 131 | "is_admin": 1, 132 | "ip_address_public": "173.34.171.106", 133 | "allow_guest": 0, 134 | "transcode_audio_channels": "2", 135 | "stream_audio_channel_layout_": "", 136 | "media_index": "72", 137 | "stream_video_framerate": "24p", 138 | "transcode_hw_encode": "", 139 | "grandparent_rating_key": "16983", 140 | "original_title": "", 141 | "added_at": "1535252051", 142 | "banner": "/library/metadata/16983/banner/1535253493", 143 | "bif_thumb": "", 144 | "parent_media_index": "19", 145 | "live_uuid": "", 146 | "audio_language": "日本語", 147 | "stream_audio_bitrate_mode": "cbr", 148 | "username": "Corintio", 149 | "subtitle_decision": "burn", 150 | "children_count": "", 151 | "updated_at": "1535253492", 152 | "player": "Chrome", 153 | "subtitle_format": "", 154 | "file": "/home/plex/gmedia/tv/One Piece/Season 19/S19E72 - [HorribleSubs] One Piece - 851 [720p].mkv", 155 | "file_size": "558177094", 156 | "session_key": "91", 157 | "id": "79381", 158 | "subtitle_container": "", 159 | "genres": [ 160 | "Anime" 161 | ], 162 | "stream_video_language_code": "", 163 | "indexes": 0, 164 | "video_decision": "transcode", 165 | "stream_audio_language": "日本語", 166 | "writers": [], 167 | "actors": [ 168 | "Hirata Hiroaki", 169 | "Nakai Kazuya", 170 | "Mayumi Tanaka", 171 | "Okamura Akemi", 172 | "Ootani Ikue", 173 | "Yamaguchi Kappei", 174 | "Yao Kazuki", 175 | "Yamaguchi Yuriko", 176 | "Cho", 177 | "Hidekatsu Shibata", 178 | "Furukawa Toshio", 179 | "Ikeda Shuuichi", 180 | "Ikura Kazue", 181 | "Yamazaki Wakana", 182 | "Kobayashi Yuuko", 183 | "Nakao Michio", 184 | "Koyasu Takehito", 185 | "Hori Hideyuki", 186 | "Mitsuishi Kotono", 187 | "Chiba Shigeru", 188 | "Mika Doi", 189 | "Tanaka Hideyuki", 190 | "Aono Takeshi", 191 | "Arimoto Kinryuu", 192 | "Imamura Norio", 193 | "Iwata Mitsuo", 194 | "Morikawa Toshiyuki", 195 | "Namikawa Daisuke", 196 | "Toshihiko Seki", 197 | "Houki Katsuhisa", 198 | "Gouri Daisuke", 199 | "Unshou Ishizuka", 200 | "Ootsuka Akio", 201 | "Naka Hiroshi", 202 | "Watanabe Misa", 203 | "Seki Tomokazu", 204 | "Takkou Ishimori", 205 | "Sonobe Keiichi", 206 | "Ootomo Ryuuzaburou", 207 | "Matsuo Ginzou", 208 | "Mahito Ohba", 209 | "Noda Junko", 210 | "Hiroshi Kamiya", 211 | "Urawa, Megumi", 212 | "Ikura Kazue", 213 | "Hashimoto Kouichi", 214 | "Takagi Wataru", 215 | "Hiyama Nobuyuki", 216 | "Yoshida Hiroaki", 217 | "Kosugi Jurota", 218 | "Hidaka Noriko", 219 | "Masaya Onosaka", 220 | "Kouzou Shioya", 221 | "Takatsuka Masaya", 222 | "Tokuyama Yasuhiko", 223 | "Egawa Hisao", 224 | "Soya Shigenori", 225 | "Endo Moriya", 226 | "Inada Tetsu", 227 | "Matsuoka Yoko", 228 | "Suzuki Masami", 229 | "Yasuhiro Takato", 230 | "Makiko Oomoto", 231 | "Orikasa Fumiko", 232 | "Nakagawa Akiko", 233 | "Shimada Bin", 234 | "Kenichi Ono", 235 | "Kishio Daisuke", 236 | "Oikawa Izo", 237 | "Naomi Shindo", 238 | "Ryotaro Okiayu", 239 | "Chiwa Saito", 240 | "Hiroshi Iwasaki", 241 | "Kumiko Nishihara", 242 | "Reiko Kiuchi", 243 | "Masaki Aizawa", 244 | "Takahiro Yoshimizu", 245 | "Kenji Nojima", 246 | "Shinobu Satouchi", 247 | "Masakazu Morita", 248 | "Taiki Matsuno", 249 | "Banjou Ginga", 250 | "Michitaka Kobayashi", 251 | "Hiroaki Miura", 252 | "Mariko Kouda", 253 | "Haruna Ikezawa", 254 | "Tooru Oohira", 255 | "Kouji Yada", 256 | "Rieko Takahashi", 257 | "Sara Nakayama", 258 | "Seiji Sasaki", 259 | "Kumiko Watanabe", 260 | "Hidenobu Kiuchi", 261 | "Kouichi Nagano", 262 | "Yasunori Masutani", 263 | "Eiji Takemoto", 264 | "Kenji Hamada", 265 | "Takashi Nagasako", 266 | "Yasuhiko Kawazu", 267 | "Yuusei Oda", 268 | "Tomoko Naka", 269 | "Mami Kingetsu", 270 | "Hirohiko Kakegawa", 271 | "Rumi Kasahara", 272 | "Hiromi Tsuru", 273 | "Taiten Kusunoki", 274 | "Chafurin", 275 | "Yukimasa Kishino", 276 | "Kihachiro Uemura", 277 | "Haruh Terada", 278 | "Noriko Yoshitake", 279 | "Jouji Yanami", 280 | "Machiko Toyoshima", 281 | "Emi Uwagawa", 282 | "Yukari Hikida", 283 | "Jin Domon", 284 | "Hiromi Nishikawa", 285 | "Tomohisa Asou", 286 | "Rika Komatsu", 287 | "Goro Naya", 288 | "Hiroyuki Kawamoto", 289 | "Yuji Ueda", 290 | "Yuuko Sumitomo", 291 | "Osamu Ryutani", 292 | "Yuka Shioyama", 293 | "Masako Nozawa", 294 | "Shigeru Ushiyama", 295 | "Tōru Furuya", 296 | "Takeuchi Junko", 297 | "Neya Michiko", 298 | "Sugiyama Noriaki", 299 | "Tomokazu Sugita", 300 | "Shin-ichiro Miki" 301 | ], 302 | "progress_percent": "0", 303 | "audio_decision": "copy", 304 | "subtitle_forced": 0, 305 | "profile": "Web", 306 | "product": "Plex Web", 307 | "view_offset": "4000", 308 | "type": "3", 309 | "audience_rating_image": "", 310 | "audio_bitrate": "128", 311 | "section_id": "1", 312 | "stream_subtitle_codec": "", 313 | "subtitle_codec": "ass", 314 | "video_codec": "h264", 315 | "device": "OSX", 316 | "stream_video_bit_depth": "", 317 | "video_framerate": "24p", 318 | "transcode_hw_encoding": 0, 319 | "transcode_protocol": "dash", 320 | "shared_libraries": [ 321 | "2", 322 | "3", 323 | "1", 324 | "4" 325 | ], 326 | "stream_aspect_ratio": "", 327 | "content_rating": "TV-PG", 328 | "session_id": "k30h61eenaodr8xyz1kfaaru", 329 | "directors": [], 330 | "parent_thumb": "/library/metadata/17075/thumb/1535253493", 331 | "subtitle_language_code": "eng", 332 | "transcode_progress": 12, 333 | "subtitle_language": "English", 334 | "stream_subtitle_container": "" 335 | } 336 | ], 337 | "stream_count": "1", 338 | "total_bandwidth": 5169, 339 | "stream_count_transcode": 1, 340 | "wan_bandwidth": 5169, 341 | "stream_count_direct_play": 0, 342 | "lan_bandwidth": 0, 343 | "stream_count_direct_stream": 0 344 | }, 345 | "result": "success" 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /plexbar/testdata/movie.json: -------------------------------------------------------------------------------- 1 | { 2 | "response": { 3 | "message": null, 4 | "data": { 5 | "sessions": [ 6 | { 7 | "rating": "6.6", 8 | "transcode_width": "", 9 | "labels": [], 10 | "stream_bitrate": "2729", 11 | "bandwidth": "5731", 12 | "optimized_version": 0, 13 | "video_language": "", 14 | "parent_rating_key": "", 15 | "rating_key": "18888", 16 | "platform_version": "68.0", 17 | "transcode_hw_decoding": 0, 18 | "thumb": "/library/metadata/18888/thumb/1535342682", 19 | "title": "The Man from U.N.C.L.E.", 20 | "video_codec_level": "41", 21 | "tagline": "Saving the world never goes out of style.", 22 | "last_viewed_at": "", 23 | "audio_sample_rate": "48000", 24 | "user_rating": "", 25 | "platform": "Chrome", 26 | "collections": [], 27 | "location": "wan", 28 | "transcode_container": "ass", 29 | "audio_channel_layout": "5.1", 30 | "local": "0", 31 | "stream_subtitle_format": "srt", 32 | "stream_video_ref_frames": "4", 33 | "transcode_hw_encode_title": "", 34 | "stream_container_decision": "direct play", 35 | "audience_rating": "7.3", 36 | "full_title": "The Man from U.N.C.L.E.", 37 | "ip_address": "173.34.171.106", 38 | "subtitles": 1, 39 | "stream_subtitle_language": "English", 40 | "channel_stream": 0, 41 | "video_bitrate": "2499", 42 | "is_allow_sync": 1, 43 | "stream_video_bitrate": "2499", 44 | "summary": "At the height of the Cold War, a mysterious criminal organization plans to use nuclear weapons and technology to upset the fragile balance of power between the United States and Soviet Union. CIA agent Napoleon Solo and KGB agent Illya Kuryakin are forced to put aside their hostilities and work together to stop the evildoers in their tracks. The duo's only lead is the daughter of a missing German scientist, whom they must find soon to prevent a global catastrophe.", 45 | "stream_audio_decision": "direct play", 46 | "aspect_ratio": "2.35", 47 | "audio_bitrate_mode": "", 48 | "transcode_hw_decode_title": "", 49 | "stream_audio_channel_layout": "5.1", 50 | "deleted_user": 0, 51 | "library_name": "Movies", 52 | "art": "/library/metadata/18888/art/1535342682", 53 | "stream_video_resolution": "1080", 54 | "video_profile": "high", 55 | "sort_title": "Man from U.N.C.L.E.", 56 | "stream_video_codec_level": "41", 57 | "stream_video_height": "800", 58 | "year": "2015", 59 | "stream_duration": "6989803", 60 | "stream_audio_channels": "6", 61 | "video_language_code": "", 62 | "transcode_key": "/transcode/sessions/hzcfcvliqpqes0bzcwnezhts", 63 | "transcode_throttled": 0, 64 | "container": "mp4", 65 | "stream_audio_bitrate": "229", 66 | "user": "Corintio", 67 | "selected": 1, 68 | "product_version": "3.65.1", 69 | "subtitle_location": "external", 70 | "transcode_hw_requested": 0, 71 | "video_height": "800", 72 | "state": "playing", 73 | "is_restricted": 0, 74 | "email": "plex@domain.com", 75 | "stream_container": "mp4", 76 | "transcode_speed": "2411.4", 77 | "video_bit_depth": "8", 78 | "stream_audio_sample_rate": "48000", 79 | "grandparent_title": "", 80 | "studio": "Warner Bros. Pictures", 81 | "transcode_decision": "direct play", 82 | "video_width": "1920", 83 | "bitrate": "2729", 84 | "machine_id": "baigi6fc6fhl28b8sfqg1q6d", 85 | "originally_available_at": "2015-08-13", 86 | "video_frame_rate": "23.976", 87 | "synced_version_profile": "", 88 | "friendly_name": "Corintio", 89 | "audio_profile": "lc", 90 | "optimized_version_title": "", 91 | "platform_name": "chrome", 92 | "stream_video_language": "", 93 | "keep_history": 1, 94 | "stream_audio_codec": "aac", 95 | "stream_video_codec": "h264", 96 | "grandparent_thumb": "", 97 | "synced_version": 0, 98 | "transcode_hw_decode": "", 99 | "user_thumb": "https://plex.tv/users/90df34879a276d6d/avatar?c=1534448207", 100 | "stream_video_width": "1920", 101 | "height": "800", 102 | "stream_subtitle_decision": "", 103 | "audio_codec": "aac", 104 | "parent_title": "", 105 | "guid": "com.plexapp.agents.imdb://tt1638355?lang=en", 106 | "audio_language_code": "eng", 107 | "transcode_video_codec": "", 108 | "transcode_audio_codec": "", 109 | "stream_video_decision": "direct play", 110 | "user_id": 1247844, 111 | "transcode_height": "", 112 | "transcode_hw_full_pipeline": 0, 113 | "throttled": "0", 114 | "quality_profile": "Original", 115 | "width": "1920", 116 | "live": 0, 117 | "stream_subtitle_forced": 0, 118 | "media_type": "movie", 119 | "video_resolution": "1080", 120 | "stream_subtitle_location": "sidecar-subs", 121 | "do_notify": 1, 122 | "video_ref_frames": "4", 123 | "stream_subtitle_language_code": "eng", 124 | "audio_channels": "6", 125 | "stream_audio_language_code": "eng", 126 | "optimized_version_profile": "", 127 | "relay": 0, 128 | "duration": "6989803", 129 | "rating_image": "rottentomatoes://image.rating.ripe", 130 | "is_home_user": 1, 131 | "is_admin": 1, 132 | "ip_address_public": "173.34.171.106", 133 | "allow_guest": 0, 134 | "transcode_audio_channels": "", 135 | "stream_audio_channel_layout_": "5.1", 136 | "media_index": "", 137 | "stream_video_framerate": "24p", 138 | "transcode_hw_encode": "", 139 | "grandparent_rating_key": "", 140 | "original_title": "", 141 | "added_at": "1535342615", 142 | "banner": "", 143 | "bif_thumb": "", 144 | "parent_media_index": "", 145 | "live_uuid": "", 146 | "audio_language": "English", 147 | "stream_audio_bitrate_mode": "", 148 | "username": "Corintio", 149 | "subtitle_decision": "transcode", 150 | "children_count": "", 151 | "updated_at": "1535342682", 152 | "player": "Chrome", 153 | "subtitle_format": "srt", 154 | "file": "/home/plex/gmedia/movies/The Man from U.N.C.L.E. (2015)/The.Man.from.U.N.C.L.E.2015.1080p.BluRay.H264.AAC-RARBG.mp4", 155 | "file_size": "2384270820", 156 | "session_key": "84", 157 | "id": "79462", 158 | "subtitle_container": "", 159 | "genres": [ 160 | "Action", 161 | "Adventure", 162 | "Comedy" 163 | ], 164 | "stream_video_language_code": "", 165 | "indexes": 0, 166 | "video_decision": "", 167 | "stream_audio_language": "English", 168 | "writers": [ 169 | "Guy Ritchie", 170 | "Lionel Wigram" 171 | ], 172 | "actors": [ 173 | "Henry Cavill", 174 | "Armie Hammer", 175 | "Alicia Vikander", 176 | "Elizabeth Debicki", 177 | "Luca Calvani", 178 | "Sylvester Groth", 179 | "Hugh Grant", 180 | "Jared Harris", 181 | "Christian Berkel", 182 | "Misha Kuznetsov", 183 | "Guy Williams", 184 | "Marianna Di Martino", 185 | "Julian Michael Deuster", 186 | "Andrea Cagliesi", 187 | "Riccardo Calvanese", 188 | "Peter Stark", 189 | "David Menkin", 190 | "Pablo Scola", 191 | "Cesare Taurasi", 192 | "Riccardo Flammini", 193 | "Francesco De Vito", 194 | "Luca Della Valle", 195 | "Simona Caparrini", 196 | "David Beckham", 197 | "Alessandro Ananasso", 198 | "Joana Metrass", 199 | "Gabriel Farnese" 200 | ], 201 | "progress_percent": "0", 202 | "audio_decision": "", 203 | "subtitle_forced": 0, 204 | "profile": "Web", 205 | "product": "Plex Web", 206 | "view_offset": "8000", 207 | "type": "3", 208 | "audience_rating_image": "rottentomatoes://image.rating.upright", 209 | "audio_bitrate": "229", 210 | "section_id": "2", 211 | "stream_subtitle_codec": "srt", 212 | "subtitle_codec": "srt", 213 | "video_codec": "h264", 214 | "device": "OSX", 215 | "stream_video_bit_depth": "8", 216 | "video_framerate": "24p", 217 | "transcode_hw_encoding": 0, 218 | "transcode_protocol": "http", 219 | "shared_libraries": [ 220 | "2", 221 | "3", 222 | "1", 223 | "4" 224 | ], 225 | "stream_aspect_ratio": "2.35", 226 | "content_rating": "PG-13", 227 | "session_id": "k30h61eenaodr8xyz1kfaaru", 228 | "directors": [ 229 | "Guy Ritchie" 230 | ], 231 | "parent_thumb": "", 232 | "subtitle_language_code": "eng", 233 | "transcode_progress": 94, 234 | "subtitle_language": "English", 235 | "stream_subtitle_container": "" 236 | } 237 | ], 238 | "stream_count": "1", 239 | "total_bandwidth": 5731, 240 | "stream_count_transcode": 0, 241 | "wan_bandwidth": 5731, 242 | "stream_count_direct_play": 1, 243 | "lan_bandwidth": 0, 244 | "stream_count_direct_stream": 0 245 | }, 246 | "result": "success" 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /plexbar/testdata/tvshow.json: -------------------------------------------------------------------------------- 1 | { 2 | "response": { 3 | "message": null, 4 | "data": { 5 | "sessions": [ 6 | { 7 | "rating": "", 8 | "transcode_width": "", 9 | "labels": [], 10 | "stream_bitrate": "3223", 11 | "bandwidth": "6501", 12 | "optimized_version": 0, 13 | "video_language": "English", 14 | "parent_rating_key": "18773", 15 | "rating_key": "18900", 16 | "platform_version": "68.0", 17 | "transcode_hw_decoding": 0, 18 | "thumb": "/library/metadata/18900/thumb/1535359181", 19 | "title": "This Is Not Our World", 20 | "video_codec_level": "31", 21 | "tagline": "", 22 | "last_viewed_at": "", 23 | "audio_sample_rate": "48000", 24 | "user_rating": "", 25 | "platform": "Chrome", 26 | "collections": [], 27 | "location": "wan", 28 | "transcode_container": "mp4", 29 | "audio_channel_layout": "5.1(side)", 30 | "local": "0", 31 | "stream_subtitle_format": "ass", 32 | "stream_video_ref_frames": "", 33 | "transcode_hw_encode_title": "", 34 | "stream_container_decision": "transcode", 35 | "audience_rating": "", 36 | "full_title": "Ballers - This Is Not Our World", 37 | "ip_address": "173.34.171.106", 38 | "subtitles": 1, 39 | "stream_subtitle_language": "English", 40 | "channel_stream": 0, 41 | "video_bitrate": "2967", 42 | "is_allow_sync": 1, 43 | "stream_video_bitrate": "2967", 44 | "summary": "Spencer looks to reign in Lance’s excesses. Vernon gets carried away at a ceremony retiring his number at his old LA high school, to Reggie’s dismay.", 45 | "stream_audio_decision": "transcode", 46 | "aspect_ratio": "1.78", 47 | "audio_bitrate_mode": "", 48 | "transcode_hw_decode_title": "", 49 | "stream_audio_channel_layout": "Stereo", 50 | "deleted_user": 0, 51 | "library_name": "TV Shows", 52 | "art": "/library/metadata/18735/art/1535359181", 53 | "stream_video_resolution": "720", 54 | "video_profile": "high", 55 | "sort_title": "", 56 | "stream_video_codec_level": "", 57 | "stream_video_height": "720", 58 | "year": "2018", 59 | "stream_duration": "1754800", 60 | "stream_audio_channels": "2", 61 | "video_language_code": "eng", 62 | "transcode_key": "/transcode/sessions/p4uujm34zfnhoimsh8byv4ey", 63 | "transcode_throttled": 1, 64 | "container": "mkv", 65 | "stream_audio_bitrate": "256", 66 | "user": "Corintio", 67 | "selected": 1, 68 | "product_version": "3.65.1", 69 | "subtitle_location": "external", 70 | "transcode_hw_requested": 0, 71 | "video_height": "720", 72 | "state": "playing", 73 | "is_restricted": 0, 74 | "email": "plex@domain.com", 75 | "stream_container": "mp4", 76 | "transcode_speed": "28.8", 77 | "video_bit_depth": "8", 78 | "stream_audio_sample_rate": "", 79 | "grandparent_title": "Ballers", 80 | "studio": "Leverage", 81 | "transcode_decision": "transcode", 82 | "video_width": "1280", 83 | "bitrate": "3351", 84 | "machine_id": "baigi6fc6fhl28b8sfqg1q6d", 85 | "originally_available_at": "2018-08-26", 86 | "video_frame_rate": "25.000", 87 | "synced_version_profile": "", 88 | "friendly_name": "Corintio", 89 | "audio_profile": "", 90 | "optimized_version_title": "", 91 | "platform_name": "chrome", 92 | "stream_video_language": "English", 93 | "keep_history": 1, 94 | "stream_audio_codec": "aac", 95 | "stream_video_codec": "h264", 96 | "grandparent_thumb": "/library/metadata/18735/thumb/1535359181", 97 | "synced_version": 0, 98 | "transcode_hw_decode": "", 99 | "user_thumb": "https://plex.tv/users/90df34879a276d6d/avatar?c=1534448207", 100 | "stream_video_width": "1280", 101 | "height": "720", 102 | "stream_subtitle_decision": "transcode", 103 | "audio_codec": "ac3", 104 | "parent_title": "Season 4", 105 | "guid": "com.plexapp.agents.themoviedb://62704/4/3?lang=en", 106 | "audio_language_code": "eng", 107 | "transcode_video_codec": "h264", 108 | "transcode_audio_codec": "aac", 109 | "stream_video_decision": "copy", 110 | "user_id": 1247844, 111 | "transcode_height": "", 112 | "transcode_hw_full_pipeline": 0, 113 | "throttled": "1", 114 | "quality_profile": "Original", 115 | "width": "1280", 116 | "live": 0, 117 | "stream_subtitle_forced": 0, 118 | "media_type": "episode", 119 | "video_resolution": "720", 120 | "stream_subtitle_location": "segments-subs", 121 | "do_notify": 1, 122 | "video_ref_frames": "4", 123 | "stream_subtitle_language_code": "eng", 124 | "audio_channels": "6", 125 | "stream_audio_language_code": "eng", 126 | "optimized_version_profile": "", 127 | "relay": 0, 128 | "duration": "1754800", 129 | "rating_image": "", 130 | "is_home_user": 1, 131 | "is_admin": 1, 132 | "ip_address_public": "173.34.171.106", 133 | "allow_guest": 0, 134 | "transcode_audio_channels": "2", 135 | "stream_audio_channel_layout_": "", 136 | "media_index": "3", 137 | "stream_video_framerate": "PAL", 138 | "transcode_hw_encode": "", 139 | "grandparent_rating_key": "18735", 140 | "original_title": "", 141 | "added_at": "1535358697", 142 | "banner": "/library/metadata/18735/banner/1535359181", 143 | "bif_thumb": "", 144 | "parent_media_index": "4", 145 | "live_uuid": "", 146 | "audio_language": "English", 147 | "stream_audio_bitrate_mode": "cbr", 148 | "username": "Corintio", 149 | "subtitle_decision": "transcode", 150 | "children_count": "", 151 | "updated_at": "1535359181", 152 | "player": "Chrome", 153 | "subtitle_format": "srt", 154 | "file": "/home/plex/gmedia/tv/Ballers/Season 4/Ballers.2015.S04E03.720p.WEB.X264-METCON.mkv", 155 | "file_size": "734981579", 156 | "session_key": "83", 157 | "id": "79480", 158 | "subtitle_container": "", 159 | "genres": [ 160 | "Drama", 161 | "Comedy" 162 | ], 163 | "stream_video_language_code": "eng", 164 | "indexes": 0, 165 | "video_decision": "copy", 166 | "stream_audio_language": "English", 167 | "writers": [ 168 | "Stephen Levinson", 169 | "Rob Weiss" 170 | ], 171 | "actors": [ 172 | "Dwayne Johnson", 173 | "Rob Corddry", 174 | "John David Washington", 175 | "Omar Benson Miller", 176 | "Troy Garity", 177 | "Richard Schiff", 178 | "London Brown", 179 | "Dulé Hill", 180 | "Serinda Swan", 181 | "Jazmyn Simon" 182 | ], 183 | "progress_percent": "6", 184 | "audio_decision": "transcode", 185 | "subtitle_forced": 0, 186 | "profile": "Web", 187 | "product": "Plex Web", 188 | "view_offset": "99000", 189 | "type": "3", 190 | "audience_rating_image": "", 191 | "audio_bitrate": "384", 192 | "section_id": "1", 193 | "stream_subtitle_codec": "ass", 194 | "subtitle_codec": "srt", 195 | "video_codec": "h264", 196 | "device": "OSX", 197 | "stream_video_bit_depth": "", 198 | "video_framerate": "PAL", 199 | "transcode_hw_encoding": 0, 200 | "transcode_protocol": "dash", 201 | "shared_libraries": [ 202 | "2", 203 | "3", 204 | "1", 205 | "4" 206 | ], 207 | "stream_aspect_ratio": "", 208 | "content_rating": "TV-MA", 209 | "session_id": "k30h61eenaodr8xyz1kfaaru", 210 | "directors": [ 211 | "Julian Farino" 212 | ], 213 | "parent_thumb": "/library/metadata/18773/thumb/1535359181", 214 | "subtitle_language_code": "eng", 215 | "transcode_progress": 45, 216 | "subtitle_language": "English", 217 | "stream_subtitle_container": "ass" 218 | } 219 | ], 220 | "stream_count": "1", 221 | "total_bandwidth": 6501, 222 | "stream_count_transcode": 1, 223 | "wan_bandwidth": 6501, 224 | "stream_count_direct_play": 0, 225 | "lan_bandwidth": 0, 226 | "stream_count_direct_stream": 0 227 | }, 228 | "result": "success" 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /rclone-gdrive/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS builder 2 | 3 | RUN wget https://downloads.rclone.org/rclone-current-linux-amd64.zip && \ 4 | unzip rclone-current-linux-amd64.zip && \ 5 | cp rclone-*-linux-amd64/rclone /usr/bin/ && \ 6 | chown root:root /usr/bin/rclone && \ 7 | chmod 755 /usr/bin/rclone 8 | 9 | RUN apk add --update --no-cache \ 10 | g++ make python3 linux-headers git 11 | 12 | RUN wget https://github.com/trapexit/mergerfs/releases/download/2.28.3/mergerfs-2.28.3.tar.gz 13 | RUN tar xvfz mergerfs-2.28.3.tar.gz 14 | 15 | RUN cd mergerfs-2.28.3 && \ 16 | make STATIC=1 LTO=1 && \ 17 | mv build/mergerfs /usr/bin 18 | 19 | FROM lsiobase/alpine:3.15 20 | COPY --from=builder /usr/bin/rclone /usr/bin 21 | COPY --from=builder /usr/bin/mergerfs /usr/bin 22 | 23 | RUN apk add --no-cache --update fuse \ 24 | && rm -rf /tmp/* /var/cache/apk/* /var/lib/apk/lists/* 25 | 26 | # Default environment variables 27 | ENV CONFIG_FILE="/config/rclone.conf" \ 28 | REMOTE_PATH="gcrypt:" \ 29 | REMOTE_MOUNT="/GD" \ 30 | UPLOAD_PATH="/data/upload" \ 31 | LOCAL_MOUNT="/data/gmedia" \ 32 | RCLONE_LOG_LEVEL="INFO" \ 33 | RCLONE_MOUNT_OPTIONS="--allow-other \ 34 | --attr-timeout 10s \ 35 | --dir-cache-time 96h \ 36 | --drive-chunk-size 32M \ 37 | --timeout 2h \ 38 | --umask 002" \ 39 | RCLONE_UPLOAD_OPTIONS="-c \ 40 | --transfers 3 \ 41 | --checkers 3 \ 42 | --tpslimit 3 \ 43 | --no-traverse \ 44 | --delete-after \ 45 | --drive-chunk-size 32M \ 46 | --delete-empty-src-dirs" \ 47 | MERGERFS_OPTIONS="-o defaults,sync_read,auto_cache,use_ino,allow_other,func.getattr=newest,category.action=all,category.create=ff" \ 48 | UPLOAD_CRONTAB="* * * * *" \ 49 | UPLOAD_DELAY="5" 50 | 51 | COPY root/ / 52 | 53 | VOLUME ["/data", "/config", "/logs"] 54 | 55 | # Change home folder 56 | WORKDIR /rclone 57 | RUN usermod -d /rclone abc 58 | RUN chown -R abc:abc /rclone 59 | -------------------------------------------------------------------------------- /rclone-gdrive/README.md: -------------------------------------------------------------------------------- 1 | Rclone Gdrive 2 | ============= 3 | 4 | [Rclone](https://rclone.org) mount container, optmized to work with Google Drive and 5 | write-once, large files (like media files and backup archives). It uses 6 | [mergerfs](https://github.com/trapexit/mergerfs) to keep new files locally, and 7 | uploads them using a cronjob. This is similar to solutions using Plexdrive+UnionFS, but 8 | all code and configuration is self-contained, for easy setup and testing. 9 | 10 | This project does not provides anything new, and this approach has been implemented before many 11 | times, with slightly different solutions (see the *Credits* bellow). The goal of this project is 12 | to make the setup as straightforward as possible, thanks to the encapsulation provided by Docker. 13 | 14 | ## Requirements 15 | This has only been tested on Linux hosts. You'll need to have the following software 16 | installed in your computer (installation instructions depend on your Linux flavour, these are 17 | for Ubuntu): 18 | 19 | - **Docker**: 20 | ``` 21 | sudo curl -L https://get.docker.com | bash 22 | sudo usermod -aG docker $USER 23 | ``` 24 | - **Fuse**: 25 | ``` 26 | sudo apt-get update 27 | sudo apt-get install fuse 28 | ``` 29 | 30 | ## Usage 31 | By default, the container expects a `rclone.conf` file in the `/config` folder. It will 32 | fail if the config file is not found. This config must define the _remote_ used to store 33 | your data. The _remote_ name must match the env var $REMOTE_PATH (default `gcrypt:`). 34 | 35 | It will mount the merged folder in the path defined in the $LOCAL_MOUNT env var 36 | (default `/data/gmedia`). The local buffer for new files not uploaded yet is located in 37 | $UPLOAD_PATH (default `/data/upload`). 38 | 39 | New files will be uploaded based on the $UPLOAD_CRONTAB schedule. It will 40 | only upload files that are older than $UPLOAD_DELAY minutes. By default it checks every 41 | minute for files older than 5 minutes. 42 | 43 | Check the *Configuration* Section bellow to see all customizations available. 44 | 45 | **NOTE**: You need to specify the following Docker options for this container to work properly: 46 | `--cap-add SYS_ADMIN --device /dev/fuse --security-opt apparmor:unconfined` 47 | 48 | ### With Docker 49 | ``` 50 | $ docker build -t rclone . 51 | $ docker run -it --cap-add SYS_ADMIN --device /dev/fuse \ 52 | --security-opt apparmor:unconfined -e REMOTE_PATH=gcrypt: \ 53 | -v /config:/config -e /logs:/logs /data:/data rclone 54 | ``` 55 | 56 | ### With Docker Compose 57 | ```yml 58 | version: '3.7' 59 | services: 60 | rclone: 61 | build : /path/to/rclone-gdrive 62 | container_name: rclone 63 | cap_add: 64 | - SYS_ADMIN 65 | devices: 66 | - /dev/fuse 67 | security_opt: 68 | - apparmor:unconfined 69 | environment: 70 | - REMOTE_PATH=gcrypt: 71 | volumes: 72 | - ./config:/config 73 | - ./logs:/logs 74 | - ./data:/data:shared 75 | restart: always 76 | ``` 77 | 78 | ### Configuration 79 | Using the following environment variables, you can customize the behaviour of this 80 | container to your specific needs, change things like paths, schedule, tweaking 81 | rclone/mergerfs options and log level. 82 | 83 | Variable | Default | Usage 84 | ----------------------|---------------------|------ 85 | CONFIG_FILE | /config/rclone.conf | Path to `rclone` config file (inside the container) 86 | REMOTE_PATH | gcrypt: | Name of remote (as defined in `rclone.conf`) 87 | LOCAL_MOUNT | /data/gmedia | Location where the merged data will be mounted 88 | UPLOAD_PATH | /data/upload | Location where the new files will be stored 89 | UPLOAD_CRONTAB | * * * * * | Schedule to check for new files to be uploaded 90 | UPLOAD_DELAY | 5 | Minutes from last modification before file is uploaded to the remote 91 | RCLONE_LOG_LEVEL | INFO | Log level for all `rclone` commands 92 | RCLONE_MOUNT_OPTIONS | --allow-other --attr-timeout 10s --dir-cache-time 96h --drive-chunk-size 32M --timeout 2h --umask 002 | Options used for `rclone mount` (to mount the remote locally) 93 | RCLONE_UPLOAD_OPTIONS | -c --transfers=10 --checkers=10 --no-traverse --delete-after --drive-chunk-size 32M --delete-empty-src-dirs | Options used for `rclone move` (to upload files to Google Drive) 94 | MERGERFS_OPTIONS | -o defaults, sync_read, auto_cache, use_ino, allow_other, func.getattr=newest, category.action= all,category.create=ff | Options used for `mergerfs` to "join" the remote mount with the local files 95 | 96 | 97 | ## Credits 98 | This project *borrows* some ideas from: 99 | - https://github.com/animosity22/homescripts 100 | - https://hoarding.me/rclone-scripts/ 101 | - https://hub.docker.com/r/mumiehub/rclone-mount 102 | -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/cont-finish.d/umount_all: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | # Make sure we unmount all folders before exiting. 4 | fusermount -uz ${LOCAL_MOUNT} || : 5 | fusermount -uz ${REMOTE_MOUNT} || : 6 | -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/cont-init.d/init_fuse: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | echo "user_allow_other" >> /etc/fuse.conf -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/cont-init.d/init_paths: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | cd /logs 4 | touch cron.log rclone_mount.log upload_cloud.log 5 | chown -R abc:abc /logs 6 | 7 | mkdir -p ${UPLOAD_PATH} ${LOCAL_MOUNT} ${REMOTE_MOUNT} 8 | chown abc:abc ${UPLOAD_PATH} ${LOCAL_MOUNT} ${REMOTE_MOUNT} -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/cont-init.d/setup_rclone_config: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | # Saves a copy of the configuration to the defaut location, so it is not required to pass the 4 | # path on every invocation 5 | mkdir -p /root/.config/rclone 6 | cp $CONFIG_FILE /root/.config/rclone -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/services.d/cron/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | # Create a crontab for root user 4 | echo "${UPLOAD_CRONTAB} /usr/bin/with-contenv sh -c '/scripts/upload_cloud'" | crontab - 5 | 6 | # Start crond daemon 7 | exec /usr/sbin/crond -f -l 8 -L /logs/cron.log -------------------------------------------------------------------------------- /rclone-gdrive/root/etc/services.d/rclone_mount/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | function unmount_all { 4 | fusermount -uz ${LOCAL_MOUNT} || : 5 | fusermount -uz ${REMOTE_MOUNT} || : 6 | } 7 | unmount_all 2> /dev/null 8 | 9 | mergerfs ${MERGERFS_OPTIONS} ${UPLOAD_PATH}:${REMOTE_MOUNT} ${LOCAL_MOUNT} 10 | 11 | exec /usr/bin/rclone mount --uid $PUID --gid $PGID ${REMOTE_PATH} ${REMOTE_MOUNT} \ 12 | --cache-dir /data/rclone/cache ${RCLONE_MOUNT_OPTIONS} -------------------------------------------------------------------------------- /rclone-gdrive/root/scripts/upload_cloud: -------------------------------------------------------------------------------- 1 | #!/usr/bin/with-contenv sh 2 | 3 | set -e 4 | 5 | LOGFILE=/logs/upload_cloud.log 6 | FROM=${UPLOAD_PATH} 7 | TO=${REMOTE_PATH} 8 | LOCKFILE="/var/run/`basename $0`" 9 | 10 | log() { 11 | echo "$(date "+%Y/%m/%d %T") INFO : $*" | tee -a $LOGFILE 12 | } 13 | 14 | # Avoid multiple runs 15 | exec 200>$LOCKFILE 16 | flock -n 200 || (log "RCLONE UPLOAD IN PROGRESS" && exit 1) 17 | echo $$ 1>&200 18 | 19 | # MOVE FILES OLDER THEN UPLOAD_DELAY MINUTES 20 | if find $FROM -type f -mmin +${UPLOAD_DELAY} | grep -v downloads | read; then 21 | log "RCLONE UPLOAD STARTED" 22 | 23 | # Add excludes flag if excludes file is present in config 24 | if [ -f /config/excludes ]; then 25 | EXCLUDES="--exclude-from /config/excludes" 26 | fi 27 | 28 | /usr/bin/rclone move $FROM $TO --min-age ${UPLOAD_DELAY}m \ 29 | ${EXCLUDES} ${RCLONE_UPLOAD_OPTIONS} 2>&1 | tee -a $LOGFILE 30 | 31 | log "RCLONE UPLOAD ENDED" 32 | fi -------------------------------------------------------------------------------- /slack_send.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | import os 4 | 5 | # Posting to a Slack channel 6 | def send_message_to_slack(icon, msg, text): 7 | from urllib import request, parse 8 | import json 9 | attachment = "```{0}```".format(text) if text else "" 10 | post = {"text": "{0} {1} {2}".format(icon, msg, attachment)} 11 | 12 | try: 13 | json_data = json.dumps(post) 14 | req = request.Request(os.environ['SLACK_URL'], 15 | data=json_data.encode('ascii'), 16 | headers={'Content-Type': 'application/json'}) 17 | resp = request.urlopen(req) 18 | except Exception as em: 19 | print("EXCEPTION: " + str(em)) 20 | 21 | prog, icon, msg, *text = sys.argv 22 | send_message_to_slack(icon, msg, " ".join(text)) -------------------------------------------------------------------------------- /transmission-scripts/transmission-magnet-watcher: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ### BEGIN INIT INFO 3 | # Provides: 4 | # Default-Start: 2 3 4 5 5 | # Default-Stop: 0 1 6 6 | # Short-Description: Start transmission-magnet-watcher at boot time 7 | # Description: Enable service provided by transmission-magnet-watcher. 8 | ### END INIT INFO 9 | 10 | TRANSMISSION_HOST=localhost 11 | TRANSMISSION_RPC_PORT=$TRANSMISSION_RPC_PORT 12 | TRANSMISSION_RPC_USERNAME=$TRANSMISSION_RPC_USERNAME 13 | TRANSMISSION_RPC_PASSWORD=$TRANSMISSION_RPC_PASSWORD 14 | TRANSMISSION_WATCH_DIR=$TRANSMISSION_WATCH_DIR 15 | 16 | download_dir=/data/completed/direct 17 | 18 | name=`basename $0` 19 | pid_file="/var/run/$name.pid" 20 | stdout_log="/dev/stdout" 21 | stderr_log="/dev/stderr" 22 | 23 | process_file_found() { 24 | path=$1 25 | file=$2 26 | if [[ ${file} == *.magnet ]]; then 27 | echo "New MAGNET file '${file}' appeared in directory '${path}'. Adding to Transmission" 28 | FILE=`cat "${path}/${file}"` 29 | transmission-remote localhost:${TRANSMISSION_RPC_PORT} \ 30 | --auth ${TRANSMISSION_RPC_USERNAME}:${TRANSMISSION_RPC_PASSWORD} \ 31 | --download-dir ${download_dir} -a ${FILE} 32 | if [ $? == 0 ]; then 33 | mv "${path}/${file}" "${path}/${file}.added" 34 | else 35 | echo "ERROR submiting file to transmission: ${path}/${file}" 36 | fi 37 | fi 38 | } 39 | 40 | magnet_watch() { 41 | cd / 42 | if ! which inotifywait; then 43 | echo "Installing inotify-tools" 44 | export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 45 | apt-get update 46 | apt-get install -y inotify-tools 47 | apt clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 48 | fi 49 | 50 | echo "Processing any pending magnet files" 51 | find ${TRANSMISSION_WATCH_DIR} -name "*.magnet" -printf "%f\n" | 52 | while read file; do 53 | process_file_found "${TRANSMISSION_WATCH_DIR}" "${file}" 54 | done 55 | 56 | echo "Starting magnet file watcher" 57 | inotifywait -m -e create -e moved_to ${TRANSMISSION_WATCH_DIR} | 58 | while read path action file; do 59 | process_file_found "${path}" "${file}" 60 | done 61 | } 62 | 63 | get_pid() { 64 | cat "$pid_file" 65 | } 66 | 67 | is_running() { 68 | [ -f "$pid_file" ] && ps -p `get_pid` > /dev/null 2>&1 69 | } 70 | 71 | case "$1" in 72 | start) 73 | if is_running; then 74 | echo "Already started" 75 | else 76 | echo "Starting $name" 77 | magnet_watch >> "$stdout_log" 2>> "$stderr_log" & 78 | echo $! > "$pid_file" 79 | if ! is_running; then 80 | echo "Unable to start, see $stdout_log and $stderr_log" 81 | exit 1 82 | fi 83 | fi 84 | ;; 85 | stop) 86 | if is_running; then 87 | echo -n "Stopping $name.." 88 | pkill -P `get_pid` 89 | for i in 1 2 3 4 5 6 7 8 9 10 90 | # for i in `seq 10` 91 | do 92 | if ! is_running; then 93 | break 94 | fi 95 | 96 | echo -n "." 97 | sleep 1 98 | done 99 | echo 100 | 101 | if is_running; then 102 | echo "Not stopped; may still be shutting down or shutdown may have failed" 103 | exit 1 104 | else 105 | echo "Stopped" 106 | if [ -f "$pid_file" ]; then 107 | rm "$pid_file" 108 | fi 109 | fi 110 | else 111 | echo "Not running" 112 | fi 113 | ;; 114 | restart) 115 | $0 stop 116 | if is_running; then 117 | echo "Unable to stop, will not attempt to start" 118 | exit 1 119 | fi 120 | $0 start 121 | ;; 122 | status) 123 | if is_running; then 124 | echo "Running" 125 | else 126 | echo "Stopped" 127 | exit 1 128 | fi 129 | ;; 130 | *) 131 | echo "Usage: $0 {start|stop|restart|status}" 132 | exit 1 133 | ;; 134 | esac 135 | 136 | exit 0 -------------------------------------------------------------------------------- /transmission-scripts/transmission-post-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd `dirname $0` 4 | ./transmission-magnet-watcher start -------------------------------------------------------------------------------- /transmission-scripts/transmission-pre-stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd `dirname $0` 4 | ./transmission-magnet-watcher start --------------------------------------------------------------------------------