├── .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 | 
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="iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABIAAAAAQAAAEgAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAABKgAwAEAAAAAQAAABIAAAAAaZPfVgAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAA8NJREFUOBFllF1sFFUUx38zu7OdrXRx29puW5ZtqSY01RqiFFm0qY1KSCgJxqwPxJhYE58w0QdMQGuD+JkoMSgPhCeJDzZEEsRAGj9e7JZQxNC0pSJ27Yfb3QpdW2x3tjuz67lTWhO8ycncuffc/z3n/P/navw3DJnm7/x2yjfGk4dbCdQ2YN2GTCLB5c8GKRS+kr1v7vitndHuWmjCX3OSF85EqX1YYLOQzUCgDkpKYEkAJy8KzGsDzI10ydlrYi6YZ3Ui3w6iB+J0XYjgLNv8et5mfgpt8ZaulVWDWepglNhUNxbZ8lKE5dwrTMf75dzvCkMBFcSaXJDdH3oZPmMxO2Zom9q8eEt08ktQVgO+dbrmOB4cNEwjR+MzPrKL+wTstJxPr6Tmr+nnUDLKiIB4fCY1LfDLl4Qjj2AE66G8kbyhM2XJnZouVzuSqsdiKWtybGtc0tyhIuqk6/sDFGyb9DWD+u0aPx0j9NRBknodmbEEmd+us2D4CVUE+Mcugi5gTsGD3+cQfCDC1VNXZEXYqX4QrvcVtMZ2TUUS2vUOqcQNevzdjL6vM3p0PT0Vx0ldGSBUIkkUVX5FTdIssPExASbmoa37A+p3VODk5JZlPRyocCPpqfiEtw9/zH1VG8TqaG/vQBs+wblUE+HKAPMFiUGFYUgdFzKmzrqqBgFAuzfsYT4pNYnA2BCxWEy84M1DB11T89hze2RvBCMvtVqYRcukPFgikWBDg45jg9cU5FKIRN3CYi3KMXXd3UMAzHtgThifiIvOhFHbAo8h3pnxBKafYjDkEKhy2WFzM72nz7ooR959D2Vq9Pb2wuYW8pkJWF9L8e9JR1iGxdlxDzOD29j+eosSm9LJvLCi2Dk37HdrUl1Zxs30NMc/P0rPdBehUCXJoa/Rwq0wfdkm1OLhi50XlI46ebHvLM1P2+SEUk0XQgRM2ElNzkhNRpWq3UhCAS+p82/B4/uF5e+K3N/hcDvt5cS2PSuCLG/uZ/9glFK/Rc4x0UVexQJh4cItrNQkn/mDqYmfYcs+mBlSDFs07zU5EopjpV1BSmP+NeD2jpK9T7eU2JROFMUZYSeTHGLBX4m2YSvc+KGIV1rkoWdNvn3DJtG3S7K6udq0abcBVe9sfMLnKlbTbUWctmzpmipu9pbD3LjNpjbFrs8FiX+0U0AGxYyV1GQiP+otaqK86SSdn0ZdxZaWQU6EuvAn+IPi5YfkVTi1N0525mXxX3tGVoFkbQ1MzXdLNM/z6KutBOsbMAMClkzwY/cl2RMN/P9h+xey524kHpLVfwAAAABJRU5ErkJggg=="
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
--------------------------------------------------------------------------------