Initial commit: Arr Summary source from server
This commit is contained in:
commit
c1e30bc8f0
37 changed files with 11661 additions and 0 deletions
29
.claude/settings.local.json
Normal file
29
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(docker build:*)",
|
||||
"Bash(docker compose:*)",
|
||||
"WebFetch(domain:www.imdb.com)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(pip3 install:*)",
|
||||
"WebFetch(domain:community-sitcom.fandom.com)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(./export-to-unraid.sh:*)",
|
||||
"Bash(arp:*)",
|
||||
"Bash(sshpass -p 'BroSoCo427666!' rsync:*)",
|
||||
"Bash(SSH_AUTH_SOCK=\"\" ssh:*)",
|
||||
"Bash(ssh-keyscan:*)",
|
||||
"Bash(~/.ssh/known_hosts)",
|
||||
"Bash(expect:*)",
|
||||
"Bash(bash export-to-unraid.sh root@192.168.86.33)",
|
||||
"Bash(scp:*)",
|
||||
"Bash(rsync:*)",
|
||||
"Bash(/Users/matt/Desktop/summary/arr-summary/gunicorn.conf.py:*)",
|
||||
"Bash(__NEW_LINE_58e4516f9721fb50__ ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes root@192.168.86.33 \"cd /mnt/user/appdata/greendale && docker compose build --no-cache 2>&1 | tail -3 && docker compose up -d 2>&1\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
56
.env.example
Normal file
56
.env.example
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# ══════════════════════════════════════════════════════════════════
|
||||
# Greendale Media Centre — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Login ─────────────────────────────────────────────────────────
|
||||
# Password for the dashboard login page
|
||||
LOGIN_PASSWORD=changeme
|
||||
|
||||
# Secret key for Flask session signing — generate a random string:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_this_to_a_random_secret_key
|
||||
|
||||
# ── Host port (what port Unraid exposes) ──────────────────────────
|
||||
# Default is 5055 — change if that port is already in use
|
||||
HOST_PORT=5055
|
||||
|
||||
# ── Media server host ─────────────────────────────────────────────
|
||||
# LAN IP of the machine running Sonarr, Radarr, etc.
|
||||
ARR_HOST=192.168.1.100
|
||||
|
||||
# ── Sonarr ────────────────────────────────────────────────────────
|
||||
SONARR_API_KEY=your_sonarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8989):
|
||||
# SONARR_HOST=192.168.1.100
|
||||
# SONARR_URL=http://192.168.1.100:8989
|
||||
|
||||
# ── Radarr ────────────────────────────────────────────────────────
|
||||
RADARR_API_KEY=your_radarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:7878):
|
||||
# RADARR_HOST=192.168.1.100
|
||||
# RADARR_URL=http://192.168.1.100:7878
|
||||
|
||||
# ── SABnzbd ───────────────────────────────────────────────────────
|
||||
SABNZBD_API_KEY=your_sabnzbd_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8080):
|
||||
# SABNZBD_HOST=192.168.1.100
|
||||
# SABNZBD_PORT=8080
|
||||
|
||||
# ── Tautulli ──────────────────────────────────────────────────────
|
||||
TAUTULLI_API_KEY=your_tautulli_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8181):
|
||||
# TAUTULLI_HOST=192.168.1.100
|
||||
# TAUTULLI_PORT=8181
|
||||
|
||||
# ── Prowlarr ──────────────────────────────────────────────────────
|
||||
PROWLARR_API_KEY=your_prowlarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:9696):
|
||||
# PROWLARR_HOST=192.168.1.100
|
||||
# PROWLARR_PORT=9696
|
||||
|
||||
# ── Ombi ──────────────────────────────────────────────────────────
|
||||
OMBI_API_KEY=your_ombi_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:3579):
|
||||
# OMBI_HOST=192.168.1.100
|
||||
# OMBI_PORT=3579
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
LABEL net.unraid.docker.icon="https://static.wikia.nocookie.net/community-sitcom/images/e/eb/Greendalelogo.png/revision/latest?cb=20120321140817"
|
||||
LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/"
|
||||
LABEL maintainer="Greendale Media Centre"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"]
|
||||
154
UNRAID-SETUP.md
Normal file
154
UNRAID-SETUP.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Greendale Media Centre — Unraid Setup Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Export to Unraid
|
||||
|
||||
```bash
|
||||
# Make the export script executable
|
||||
chmod +x export-to-unraid.sh
|
||||
|
||||
# Export (replace with your Unraid IP)
|
||||
./export-to-unraid.sh root@192.168.86.10
|
||||
|
||||
# Or with a custom destination path
|
||||
./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale
|
||||
```
|
||||
|
||||
### 2. Configure `.env` on Unraid
|
||||
|
||||
SSH into your Unraid server and edit the config:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.86.10
|
||||
nano /mnt/user/appdata/greendale/.env
|
||||
```
|
||||
|
||||
**Minimum required fields:**
|
||||
|
||||
```env
|
||||
LOGIN_PASSWORD=your_chosen_password
|
||||
SECRET_KEY=<random 32-char string>
|
||||
ARR_HOST=192.168.86.33
|
||||
SONARR_API_KEY=...
|
||||
RADARR_API_KEY=...
|
||||
SABNZBD_API_KEY=...
|
||||
TAUTULLI_API_KEY=...
|
||||
PROWLARR_API_KEY=...
|
||||
OMBI_API_KEY=...
|
||||
```
|
||||
|
||||
Generate a secure `SECRET_KEY`:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### 3. Start the Container
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/greendale
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The dashboard will be available at `http://YOUR_UNRAID_IP:5055`
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager (Recommended for Unraid)
|
||||
|
||||
If you're using **Nginx Proxy Manager** (NPM) on Unraid:
|
||||
|
||||
1. In NPM, go to **Proxy Hosts → Add Proxy Host**
|
||||
2. Fill in:
|
||||
- **Domain:** `media.yourdomain.com`
|
||||
- **Scheme:** `http`
|
||||
- **Forward Hostname/IP:** your Unraid IP (e.g. `192.168.86.10`)
|
||||
- **Forward Port:** `5055`
|
||||
- Enable **"Block Common Exploits"**
|
||||
- Enable **"Websockets Support"**
|
||||
3. On the **SSL tab:** request a Let's Encrypt certificate
|
||||
|
||||
That's it. NPM handles the SSL termination and reverse proxy automatically.
|
||||
|
||||
---
|
||||
|
||||
## Manual Nginx Config
|
||||
|
||||
If you're running Nginx directly (not NPM), use the configs in the `nginx/` folder:
|
||||
|
||||
| File | Use when |
|
||||
|------|----------|
|
||||
| `nginx/greendale-subdomain.conf` | You want `https://media.yourdomain.com` |
|
||||
| `nginx/greendale-subpath.conf` | You want `https://yourdomain.com/media/` |
|
||||
|
||||
Copy the appropriate file and replace the placeholders:
|
||||
- `YOUR_UNRAID_IP` → your Unraid server's LAN IP
|
||||
- `yourdomain.com` / `media.yourdomain.com` → your domain
|
||||
- `5055` → your `HOST_PORT` (if you changed it)
|
||||
|
||||
---
|
||||
|
||||
## Login Page
|
||||
|
||||
The dashboard is protected by a single-password login page themed to match Greendale.
|
||||
|
||||
- Set `LOGIN_PASSWORD` in `.env`
|
||||
- Sessions persist for the browser session (close browser to log out)
|
||||
- Visit `/logout` to manually log out
|
||||
- If `LOGIN_PASSWORD` is not set, the login is bypassed (dev mode)
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
To push code changes to Unraid after editing locally:
|
||||
|
||||
```bash
|
||||
# Re-run the export script
|
||||
./export-to-unraid.sh root@192.168.86.10
|
||||
|
||||
# On Unraid:
|
||||
cd /mnt/user/appdata/greendale
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unraid Community Applications (Manual Docker Template)
|
||||
|
||||
If you prefer to set up via the Unraid Docker UI instead of `docker compose`:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | `greendale-media-centre` |
|
||||
| Repository | `arr-summary-arr-summary` (after loading image) |
|
||||
| Port | Host: `5055` → Container: `5000` |
|
||||
| ENV: `LOGIN_PASSWORD` | your password |
|
||||
| ENV: `SECRET_KEY` | random 32-char string |
|
||||
| ENV: `ARR_HOST` | `192.168.86.33` |
|
||||
| ENV: `SONARR_API_KEY` | your key |
|
||||
| ENV: `RADARR_API_KEY` | your key |
|
||||
| ENV: `SABNZBD_API_KEY` | your key |
|
||||
| ENV: `TAUTULLI_API_KEY` | your key |
|
||||
| ENV: `PROWLARR_API_KEY` | your key |
|
||||
| ENV: `OMBI_API_KEY` | your key |
|
||||
| Restart Policy | `unless-stopped` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
docker logs arr-summary
|
||||
```
|
||||
|
||||
**Can't reach services (all cards show errors):**
|
||||
- Verify `ARR_HOST` is the correct LAN IP
|
||||
- Ensure services are running and accessible from Unraid
|
||||
- Check API keys are correct
|
||||
|
||||
**Login loop / session issues:**
|
||||
- Make sure `SECRET_KEY` is set and consistent (random restarts shouldn't clear sessions)
|
||||
- If running behind a proxy, confirm `X-Forwarded-Proto` header is being passed
|
||||
56
arr-summary/.env.example
Normal file
56
arr-summary/.env.example
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# ══════════════════════════════════════════════════════════════════
|
||||
# Greendale Media Centre — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Login ─────────────────────────────────────────────────────────
|
||||
# Password for the dashboard login page
|
||||
LOGIN_PASSWORD=changeme
|
||||
|
||||
# Secret key for Flask session signing — generate a random string:
|
||||
# python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_this_to_a_random_secret_key
|
||||
|
||||
# ── Host port (what port Unraid exposes) ──────────────────────────
|
||||
# Default is 5055 — change if that port is already in use
|
||||
HOST_PORT=5055
|
||||
|
||||
# ── Media server host ─────────────────────────────────────────────
|
||||
# LAN IP of the machine running Sonarr, Radarr, etc.
|
||||
ARR_HOST=192.168.1.100
|
||||
|
||||
# ── Sonarr ────────────────────────────────────────────────────────
|
||||
SONARR_API_KEY=your_sonarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8989):
|
||||
# SONARR_HOST=192.168.1.100
|
||||
# SONARR_URL=http://192.168.1.100:8989
|
||||
|
||||
# ── Radarr ────────────────────────────────────────────────────────
|
||||
RADARR_API_KEY=your_radarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:7878):
|
||||
# RADARR_HOST=192.168.1.100
|
||||
# RADARR_URL=http://192.168.1.100:7878
|
||||
|
||||
# ── SABnzbd ───────────────────────────────────────────────────────
|
||||
SABNZBD_API_KEY=your_sabnzbd_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8080):
|
||||
# SABNZBD_HOST=192.168.1.100
|
||||
# SABNZBD_PORT=8080
|
||||
|
||||
# ── Tautulli ──────────────────────────────────────────────────────
|
||||
TAUTULLI_API_KEY=your_tautulli_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:8181):
|
||||
# TAUTULLI_HOST=192.168.1.100
|
||||
# TAUTULLI_PORT=8181
|
||||
|
||||
# ── Prowlarr ──────────────────────────────────────────────────────
|
||||
PROWLARR_API_KEY=your_prowlarr_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:9696):
|
||||
# PROWLARR_HOST=192.168.1.100
|
||||
# PROWLARR_PORT=9696
|
||||
|
||||
# ── Ombi ──────────────────────────────────────────────────────────
|
||||
OMBI_API_KEY=your_ombi_api_key_here
|
||||
# Optional overrides (defaults to ARR_HOST:3579):
|
||||
# OMBI_HOST=192.168.1.100
|
||||
# OMBI_PORT=3579
|
||||
16
arr-summary/Dockerfile
Normal file
16
arr-summary/Dockerfile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
LABEL net.unraid.docker.icon="https://static.wikia.nocookie.net/community-sitcom/images/e/eb/Greendalelogo.png/revision/latest?cb=20120321140817"
|
||||
LABEL net.unraid.docker.webui="http://[IP]:[PORT:5000]/"
|
||||
LABEL maintainer="Greendale Media Centre"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["gunicorn", "--config=gunicorn.conf.py", "app:app"]
|
||||
154
arr-summary/UNRAID-SETUP.md
Normal file
154
arr-summary/UNRAID-SETUP.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Greendale Media Centre — Unraid Setup Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Export to Unraid
|
||||
|
||||
```bash
|
||||
# Make the export script executable
|
||||
chmod +x export-to-unraid.sh
|
||||
|
||||
# Export (replace with your Unraid IP)
|
||||
./export-to-unraid.sh root@192.168.86.10
|
||||
|
||||
# Or with a custom destination path
|
||||
./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale
|
||||
```
|
||||
|
||||
### 2. Configure `.env` on Unraid
|
||||
|
||||
SSH into your Unraid server and edit the config:
|
||||
|
||||
```bash
|
||||
ssh root@192.168.86.10
|
||||
nano /mnt/user/appdata/greendale/.env
|
||||
```
|
||||
|
||||
**Minimum required fields:**
|
||||
|
||||
```env
|
||||
LOGIN_PASSWORD=your_chosen_password
|
||||
SECRET_KEY=<random 32-char string>
|
||||
ARR_HOST=192.168.86.33
|
||||
SONARR_API_KEY=...
|
||||
RADARR_API_KEY=...
|
||||
SABNZBD_API_KEY=...
|
||||
TAUTULLI_API_KEY=...
|
||||
PROWLARR_API_KEY=...
|
||||
OMBI_API_KEY=...
|
||||
```
|
||||
|
||||
Generate a secure `SECRET_KEY`:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
|
||||
### 3. Start the Container
|
||||
|
||||
```bash
|
||||
cd /mnt/user/appdata/greendale
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The dashboard will be available at `http://YOUR_UNRAID_IP:5055`
|
||||
|
||||
---
|
||||
|
||||
## Nginx Proxy Manager (Recommended for Unraid)
|
||||
|
||||
If you're using **Nginx Proxy Manager** (NPM) on Unraid:
|
||||
|
||||
1. In NPM, go to **Proxy Hosts → Add Proxy Host**
|
||||
2. Fill in:
|
||||
- **Domain:** `media.yourdomain.com`
|
||||
- **Scheme:** `http`
|
||||
- **Forward Hostname/IP:** your Unraid IP (e.g. `192.168.86.10`)
|
||||
- **Forward Port:** `5055`
|
||||
- Enable **"Block Common Exploits"**
|
||||
- Enable **"Websockets Support"**
|
||||
3. On the **SSL tab:** request a Let's Encrypt certificate
|
||||
|
||||
That's it. NPM handles the SSL termination and reverse proxy automatically.
|
||||
|
||||
---
|
||||
|
||||
## Manual Nginx Config
|
||||
|
||||
If you're running Nginx directly (not NPM), use the configs in the `nginx/` folder:
|
||||
|
||||
| File | Use when |
|
||||
|------|----------|
|
||||
| `nginx/greendale-subdomain.conf` | You want `https://media.yourdomain.com` |
|
||||
| `nginx/greendale-subpath.conf` | You want `https://yourdomain.com/media/` |
|
||||
|
||||
Copy the appropriate file and replace the placeholders:
|
||||
- `YOUR_UNRAID_IP` → your Unraid server's LAN IP
|
||||
- `yourdomain.com` / `media.yourdomain.com` → your domain
|
||||
- `5055` → your `HOST_PORT` (if you changed it)
|
||||
|
||||
---
|
||||
|
||||
## Login Page
|
||||
|
||||
The dashboard is protected by a single-password login page themed to match Greendale.
|
||||
|
||||
- Set `LOGIN_PASSWORD` in `.env`
|
||||
- Sessions persist for the browser session (close browser to log out)
|
||||
- Visit `/logout` to manually log out
|
||||
- If `LOGIN_PASSWORD` is not set, the login is bypassed (dev mode)
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
To push code changes to Unraid after editing locally:
|
||||
|
||||
```bash
|
||||
# Re-run the export script
|
||||
./export-to-unraid.sh root@192.168.86.10
|
||||
|
||||
# On Unraid:
|
||||
cd /mnt/user/appdata/greendale
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unraid Community Applications (Manual Docker Template)
|
||||
|
||||
If you prefer to set up via the Unraid Docker UI instead of `docker compose`:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Name | `greendale-media-centre` |
|
||||
| Repository | `arr-summary-arr-summary` (after loading image) |
|
||||
| Port | Host: `5055` → Container: `5000` |
|
||||
| ENV: `LOGIN_PASSWORD` | your password |
|
||||
| ENV: `SECRET_KEY` | random 32-char string |
|
||||
| ENV: `ARR_HOST` | `192.168.86.33` |
|
||||
| ENV: `SONARR_API_KEY` | your key |
|
||||
| ENV: `RADARR_API_KEY` | your key |
|
||||
| ENV: `SABNZBD_API_KEY` | your key |
|
||||
| ENV: `TAUTULLI_API_KEY` | your key |
|
||||
| ENV: `PROWLARR_API_KEY` | your key |
|
||||
| ENV: `OMBI_API_KEY` | your key |
|
||||
| Restart Policy | `unless-stopped` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't start:**
|
||||
```bash
|
||||
docker logs arr-summary
|
||||
```
|
||||
|
||||
**Can't reach services (all cards show errors):**
|
||||
- Verify `ARR_HOST` is the correct LAN IP
|
||||
- Ensure services are running and accessible from Unraid
|
||||
- Check API keys are correct
|
||||
|
||||
**Login loop / session issues:**
|
||||
- Make sure `SECRET_KEY` is set and consistent (random restarts shouldn't clear sessions)
|
||||
- If running behind a proxy, confirm `X-Forwarded-Proto` header is being passed
|
||||
1371
arr-summary/app.py
Normal file
1371
arr-summary/app.py
Normal file
File diff suppressed because it is too large
Load diff
31
arr-summary/docker-compose.yml
Normal file
31
arr-summary/docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
services:
|
||||
arr-summary:
|
||||
build: .
|
||||
container_name: arr-summary
|
||||
ports:
|
||||
- "${HOST_PORT:-5055}:5000"
|
||||
environment:
|
||||
# ── Login / proxy ──────────────────────────────────────
|
||||
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SCRIPT_NAME=${SCRIPT_NAME:-}
|
||||
# ── Media services ─────────────────────────────────────
|
||||
- ARR_HOST=${ARR_HOST}
|
||||
- SONARR_API_KEY=${SONARR_API_KEY}
|
||||
- RADARR_API_KEY=${RADARR_API_KEY}
|
||||
- SABNZBD_HOST=${SABNZBD_HOST:-${ARR_HOST}}
|
||||
- SABNZBD_PORT=${SABNZBD_PORT:-8080}
|
||||
- SABNZBD_API_KEY=${SABNZBD_API_KEY}
|
||||
- TAUTULLI_HOST=${TAUTULLI_HOST:-${ARR_HOST}}
|
||||
- TAUTULLI_PORT=${TAUTULLI_PORT:-8181}
|
||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||
- PROWLARR_HOST=${PROWLARR_HOST:-${ARR_HOST}}
|
||||
- PROWLARR_PORT=${PROWLARR_PORT:-9696}
|
||||
- PROWLARR_API_KEY=${PROWLARR_API_KEY}
|
||||
- OMBI_HOST=${OMBI_HOST:-${ARR_HOST}}
|
||||
- OMBI_PORT=${OMBI_PORT:-3579}
|
||||
- OMBI_API_KEY=${OMBI_API_KEY}
|
||||
# ── Unraid ─────────────────────────────────────────────
|
||||
- UNRAID_HOST=${UNRAID_HOST:-${ARR_HOST}}
|
||||
- UNRAID_API_KEY=${UNRAID_API_KEY}
|
||||
restart: unless-stopped
|
||||
80
arr-summary/export-to-unraid.sh
Executable file
80
arr-summary/export-to-unraid.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Greendale Media Centre — Export to Unraid
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x export-to-unraid.sh
|
||||
# ./export-to-unraid.sh [unraid-user@unraid-ip] [destination-path]
|
||||
#
|
||||
# Examples:
|
||||
# ./export-to-unraid.sh root@192.168.86.10
|
||||
# ./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale
|
||||
#
|
||||
# What it does:
|
||||
# 1. Builds the Docker image locally and saves it as a tarball
|
||||
# 2. rsyncs the project files (minus secrets & cache) to Unraid
|
||||
# 3. Copies the image tarball to Unraid
|
||||
# 4. Loads the image on Unraid via SSH
|
||||
# Leaves your .env on Unraid untouched if it already exists.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${1:-}"
|
||||
DEST="${2:-/mnt/user/appdata/greendale}"
|
||||
IMAGE_NAME="arr-summary-arr-summary"
|
||||
TARBALL="/tmp/greendale-image.tar"
|
||||
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
echo "Usage: $0 <user@unraid-ip> [destination-path]"
|
||||
echo "Example: $0 root@192.168.86.10 /mnt/user/appdata/greendale"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ Building Docker image..."
|
||||
docker compose build
|
||||
|
||||
echo "▶ Saving image to tarball (this may take a moment)..."
|
||||
docker save "$IMAGE_NAME" -o "$TARBALL"
|
||||
|
||||
echo "▶ Syncing project files to $REMOTE:$DEST ..."
|
||||
rsync -avz --progress \
|
||||
--exclude='.env' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.git' \
|
||||
--exclude='nginx' \
|
||||
--exclude='export-to-unraid.sh' \
|
||||
--exclude="$(basename "$TARBALL")" \
|
||||
./ "$REMOTE:$DEST/"
|
||||
|
||||
echo "▶ Copying Docker image to Unraid..."
|
||||
scp "$TARBALL" "$REMOTE:/tmp/greendale-image.tar"
|
||||
|
||||
echo "▶ Loading image on Unraid..."
|
||||
ssh "$REMOTE" "docker load -i /tmp/greendale-image.tar && rm /tmp/greendale-image.tar"
|
||||
|
||||
echo "▶ Checking if .env exists on Unraid..."
|
||||
if ssh "$REMOTE" "test -f $DEST/.env"; then
|
||||
echo " ✓ .env already exists on Unraid — leaving it untouched."
|
||||
else
|
||||
echo " ⚠ No .env found. Copying .env.example as a starting point..."
|
||||
ssh "$REMOTE" "cp $DEST/.env.example $DEST/.env"
|
||||
echo " → Edit $DEST/.env on Unraid before starting the container!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " ✅ Export complete!"
|
||||
echo ""
|
||||
echo " On Unraid, to start:"
|
||||
echo " cd $DEST"
|
||||
echo " nano .env # set LOGIN_PASSWORD, SECRET_KEY, etc."
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo " Or add via Unraid's Docker UI:"
|
||||
echo " Repository: arr-summary-arr-summary (already loaded)"
|
||||
echo " Port mapping: HOST_PORT (default 5055) → 5000"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
|
||||
rm -f "$TARBALL"
|
||||
22
arr-summary/gunicorn.conf.py
Normal file
22
arr-summary/gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Gunicorn configuration
|
||||
workers = 2 # 2 workers × 4 threads = 8 concurrent requests; fewer cold-cache workers
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
bind = "0.0.0.0:5000"
|
||||
timeout = 30 # fail fast — app has its own 5s internal deadline
|
||||
keepalive = 5
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""Pre-warm the slow caches in each worker right after fork.
|
||||
This runs in the background so the worker is ready instantly."""
|
||||
import threading
|
||||
def _warm():
|
||||
try:
|
||||
import app as a
|
||||
a.get_radarr_summary()
|
||||
a.get_sonarr_summary()
|
||||
a.get_tautulli_summary()
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=_warm, daemon=True)
|
||||
t.start()
|
||||
68
arr-summary/nginx/greendale-subdomain.conf
Normal file
68
arr-summary/nginx/greendale-subdomain.conf
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# Greendale Media Centre — Nginx Reverse Proxy Config
|
||||
# SUBDOMAIN variant: https://media.yourdomain.com
|
||||
#
|
||||
# 1. Copy this file to /etc/nginx/conf.d/ (or your Nginx proxy
|
||||
# manager's custom config directory) on your Unraid server.
|
||||
# 2. Replace "media.yourdomain.com" with your actual domain.
|
||||
# 3. Replace "YOUR_UNRAID_IP" with your Unraid server's LAN IP.
|
||||
# 4. Replace "5055" if you changed HOST_PORT in .env.
|
||||
# 5. SSL is handled by Nginx Proxy Manager (recommended on Unraid)
|
||||
# — point it at this container on port 5055.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name media.yourdomain.com;
|
||||
|
||||
# Redirect all HTTP → HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name media.yourdomain.com;
|
||||
|
||||
# ── SSL certificates (managed by NPM or Certbot) ──
|
||||
# ssl_certificate /etc/letsencrypt/live/media.yourdomain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/media.yourdomain.com/privkey.pem;
|
||||
|
||||
# ── Security headers ──
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
# ── Proxy to Flask container ──
|
||||
location / {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (future-proof)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_read_timeout 90;
|
||||
proxy_connect_timeout 90;
|
||||
proxy_send_timeout 90;
|
||||
|
||||
# Buffer settings for smooth streaming
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 8k;
|
||||
proxy_buffers 8 8k;
|
||||
}
|
||||
|
||||
# ── Static asset caching ──
|
||||
location ~* \.(css|js|png|jpg|ico|woff2?)$ {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
34
arr-summary/nginx/greendale-subpath.conf
Normal file
34
arr-summary/nginx/greendale-subpath.conf
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# Greendale Media Centre — Nginx Reverse Proxy Config
|
||||
# SUBPATH variant: https://yourdomain.com/media/
|
||||
#
|
||||
# Use this if you want to host under a path on an existing domain
|
||||
# instead of a dedicated subdomain.
|
||||
#
|
||||
# NOTE: The Flask app already serves from "/" — Nginx strips the
|
||||
# /media prefix and the app sees clean paths.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
# ... your existing SSL/other config above ...
|
||||
|
||||
# ── Proxy to Greendale Media Centre ──
|
||||
location /media/ {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_read_timeout 90;
|
||||
|
||||
# Required so cookies set at "/" work on the /media/ sub-path
|
||||
proxy_cookie_path / /media/;
|
||||
}
|
||||
}
|
||||
3
arr-summary/requirements.txt
Normal file
3
arr-summary/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask==3.1.0
|
||||
requests==2.32.3
|
||||
gunicorn==23.0.0
|
||||
BIN
arr-summary/static/favicon.ico
Normal file
BIN
arr-summary/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
arr-summary/static/favicon.png
Normal file
BIN
arr-summary/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
arr-summary/static/logo.png
Normal file
BIN
arr-summary/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
1964
arr-summary/templates/dashboard.html
Normal file
1964
arr-summary/templates/dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
203
arr-summary/templates/login.html
Normal file
203
arr-summary/templates/login.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Greendale Media Centre — Login</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--gold-dk: #c49a2a;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ── Logo / brand ── */
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.brand .logo-badge {
|
||||
display: inline-block;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.brand h1 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.6rem;
|
||||
color: var(--gold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.brand p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.3rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.login-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--gold);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.field { margin-bottom: 1.1rem; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus { border-color: var(--gold); }
|
||||
.field input::placeholder { color: var(--muted); }
|
||||
|
||||
/* ── Error ── */
|
||||
.error-msg {
|
||||
background: rgba(232,75,75,0.1);
|
||||
border: 1px solid rgba(232,75,75,0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
color: #ff8080;
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Submit button ── */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Special Elite', cursive;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.btn-login:hover { opacity: 0.88; }
|
||||
.btn-login:active { transform: scale(0.98); }
|
||||
|
||||
/* ── Footer quote ── */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale Community College" style="width:90px; height:90px; object-fit:contain; margin-bottom:0.75rem; filter:drop-shadow(0 2px 8px rgba(0,0,0,0.4));">
|
||||
<h1>Greendale Media Centre</h1>
|
||||
<p>Human beings welcome. Robots too.</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h2>🔒 Study Room F — Access Required</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-msg">⚠️ {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
autofocus
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">Enter Study Room →</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
"I discovered at a young age that I have… a gift." — Jeff Winger
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
486
arr-summary/templates/settings.html
Normal file
486
arr-summary/templates/settings.html
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
|
||||
{% elif theme == 'parks' %}<title>Settings — Pawnee Parks Media Centre</title>
|
||||
{% elif theme == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
|
||||
{% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
|
||||
{% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
|
||||
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
|
||||
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
|
||||
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
|
||||
{% if theme == 'office' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'parks' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'potter' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'starwars' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
--brand-font: 'Special Elite', cursive;
|
||||
}
|
||||
{% if theme == 'office' %}
|
||||
:root {
|
||||
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
|
||||
--navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
|
||||
--border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
|
||||
--brand-font: 'Libre Baskerville', serif;
|
||||
}
|
||||
{% elif theme == 'parks' %}
|
||||
:root {
|
||||
--gold: #5aad4e; --gold-dk: #3d8c33; --teal: #c8842a; --teal-lt: #e09a3a;
|
||||
--navy: #1a2213; --navy-lt: #222e19; --navy-mid: #1d2a16; --panel: #1a2615;
|
||||
--border: #2d4220; --text: #d8edd0; --muted: #7a9a72; --red: #e84b4b; --green: #5aad4e;
|
||||
--brand-font: 'Playfair Display', serif;
|
||||
}
|
||||
{% elif theme == 'potter' %}
|
||||
:root {
|
||||
--gold: #d4af37; --gold-dk: #b8961e; --teal: #7b2d8b; --teal-lt: #9b3dab;
|
||||
--navy: #1a1228; --navy-lt: #231934; --navy-mid: #1d1530; --panel: #1a1228;
|
||||
--border: #3d2a5a; --text: #e8dff8; --muted: #9080b8; --red: #8b1a1a; --green: #2d6e3a;
|
||||
--brand-font: 'MedievalSharp', cursive;
|
||||
}
|
||||
{% elif theme == 'starwars' %}
|
||||
:root {
|
||||
--gold: #ffe81f; --gold-dk: #d4c000; --teal: #4fc3f7; --teal-lt: #81d4fa;
|
||||
--navy: #080c14; --navy-lt: #0f1620; --navy-mid: #0a1018; --panel: #0d1422;
|
||||
--border: #1e2d42; --text: #d0e8ff; --muted: #607d9e; --red: #e84b4b; --green: #00e676;
|
||||
--brand-font: 'Orbitron', sans-serif;
|
||||
}
|
||||
{% endif %}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: var(--navy); color: var(--text); min-height: 100vh; }
|
||||
|
||||
header {
|
||||
background: var(--navy-mid);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 1.2rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header img { width: 40px; height: 40px; object-fit: contain; }
|
||||
header h1 { font-family: var(--brand-font); font-size: 1.3rem; color: var(--gold); flex: 1; }
|
||||
.back-btn {
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.back-btn:hover { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
main { padding: 2rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
.card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--gold); }
|
||||
.field select option { background: var(--navy-mid); }
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--brand-font);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.88; }
|
||||
.save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
|
||||
|
||||
.error-inline {
|
||||
font-size: 0.78rem;
|
||||
color: var(--red);
|
||||
display: none;
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||
background: var(--green); color: #fff;
|
||||
padding: 0.7rem 1.2rem; border-radius: 10px;
|
||||
font-size: 0.88rem; font-weight: 700;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.error { background: var(--red); }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Theme preview chips */
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.theme-chip {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.6rem 0.6rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
background: var(--navy-lt);
|
||||
}
|
||||
.theme-chip:hover { border-color: var(--gold); }
|
||||
.theme-chip.active { border-color: var(--gold); background: rgba(232,184,75,0.08); }
|
||||
.theme-chip .tc-emoji { font-size: 1.6rem; margin-bottom: 0.35rem; }
|
||||
.theme-chip .tc-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--brand-font);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.theme-chip .tc-sub {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
|
||||
<h1>Settings</h1>
|
||||
<a href="/" class="back-btn">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="settings-grid">
|
||||
|
||||
<!-- General -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🌍</span>
|
||||
<h2>General</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Timezone</label>
|
||||
<select id="timezone">
|
||||
{% set tzs = [
|
||||
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
|
||||
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
|
||||
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
|
||||
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
|
||||
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
|
||||
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
|
||||
] %}
|
||||
{% for tz in tzs %}
|
||||
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Host IP (ARR_HOST)</label>
|
||||
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveGeneral()">Save General</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🎨</span>
|
||||
<h2>Theme</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="theme-options" id="theme-options">
|
||||
<div class="theme-chip {% if theme == 'community' %}active{% endif %}" data-theme="community" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🏫</div>
|
||||
<div class="tc-name">Community</div>
|
||||
<div class="tc-sub">Greendale College</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'office' %}active{% endif %}" data-theme="office" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">📎</div>
|
||||
<div class="tc-name">The Office</div>
|
||||
<div class="tc-sub">Dunder Mifflin</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'parks' %}active{% endif %}" data-theme="parks" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌳</div>
|
||||
<div class="tc-name">Parks & Rec</div>
|
||||
<div class="tc-sub">Pawnee, Indiana</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'potter' %}active{% endif %}" data-theme="potter" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">⚡</div>
|
||||
<div class="tc-name">Harry Potter</div>
|
||||
<div class="tc-sub">Hogwarts</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'starwars' %}active{% endif %}" data-theme="starwars" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌌</div>
|
||||
<div class="tc-name">Star Wars</div>
|
||||
<div class="tc-sub">A long time ago…</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveTheme()">Apply Theme</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🔐</span>
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="pw_current" placeholder="Enter current password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="pw_new" placeholder="At least 6 characters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw_confirm" placeholder="Repeat new password">
|
||||
<div class="error-inline" id="pw-error"></div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sonarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📺</span><h2>Sonarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sonarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎬</span><h2>Radarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="radarr_api_key" value="{{ radarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('radarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SABnzbd -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⬇️</span><h2>SABnzbd</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sabnzbd_api_key" value="{{ sabnzbd_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sabnzbd')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tautulli -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>▶️</span><h2>Tautulli</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="tautulli_api_key" value="{{ tautulli_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('tautulli')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prowlarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🔍</span><h2>Prowlarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="prowlarr_api_key" value="{{ prowlarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('prowlarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ombi -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎟️</span><h2>Ombi</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="ombi_api_key" value="{{ ombi_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('ombi')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast">✓ Saved</div>
|
||||
|
||||
<script>
|
||||
let _selectedTheme = '{{ theme }}';
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg || '✓ Saved';
|
||||
t.classList.toggle('error', !!isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2800);
|
||||
}
|
||||
|
||||
async function saveGeneral() {
|
||||
const payload = {
|
||||
timezone: document.getElementById('timezone').value,
|
||||
arr_host: document.getElementById('arr_host').value.trim(),
|
||||
};
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ General saved — reload dashboard for clock update');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
async function saveService(name) {
|
||||
const keyEl = document.getElementById(name + '_api_key');
|
||||
const payload = { [name + '_api_key']: keyEl.value.trim() };
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
function selectTheme(el) {
|
||||
document.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
_selectedTheme = el.dataset.theme;
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ theme: _selectedTheme })
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('✓ Theme applied! Heading to dashboard…');
|
||||
setTimeout(() => { window.location.href = '/'; }, 900);
|
||||
} else {
|
||||
showToast('✗ Save failed', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const current = document.getElementById('pw_current').value;
|
||||
const newPw = document.getElementById('pw_new').value;
|
||||
const confirm = document.getElementById('pw_confirm').value;
|
||||
const errEl = document.getElementById('pw-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const resp = await fetch('/api/change-password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('pw_current').value = '';
|
||||
document.getElementById('pw_new').value = '';
|
||||
document.getElementById('pw_confirm').value = '';
|
||||
showToast('✓ Password updated successfully');
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Failed to update password';
|
||||
errEl.style.display = 'block';
|
||||
showToast('✗ ' + (data.error || 'Failed'), true);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1964
dashboard.html
Normal file
1964
dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
services:
|
||||
arr-summary:
|
||||
build: .
|
||||
container_name: arr-summary
|
||||
ports:
|
||||
- "${HOST_PORT:-5055}:5000"
|
||||
environment:
|
||||
# ── Login / proxy ──────────────────────────────────────
|
||||
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- SCRIPT_NAME=${SCRIPT_NAME:-}
|
||||
# ── Media services ─────────────────────────────────────
|
||||
- ARR_HOST=${ARR_HOST}
|
||||
- SONARR_API_KEY=${SONARR_API_KEY}
|
||||
- RADARR_API_KEY=${RADARR_API_KEY}
|
||||
- SABNZBD_HOST=${SABNZBD_HOST:-${ARR_HOST}}
|
||||
- SABNZBD_PORT=${SABNZBD_PORT:-8080}
|
||||
- SABNZBD_API_KEY=${SABNZBD_API_KEY}
|
||||
- TAUTULLI_HOST=${TAUTULLI_HOST:-${ARR_HOST}}
|
||||
- TAUTULLI_PORT=${TAUTULLI_PORT:-8181}
|
||||
- TAUTULLI_API_KEY=${TAUTULLI_API_KEY}
|
||||
- PROWLARR_HOST=${PROWLARR_HOST:-${ARR_HOST}}
|
||||
- PROWLARR_PORT=${PROWLARR_PORT:-9696}
|
||||
- PROWLARR_API_KEY=${PROWLARR_API_KEY}
|
||||
- OMBI_HOST=${OMBI_HOST:-${ARR_HOST}}
|
||||
- OMBI_PORT=${OMBI_PORT:-3579}
|
||||
- OMBI_API_KEY=${OMBI_API_KEY}
|
||||
# ── Unraid ─────────────────────────────────────────────
|
||||
- UNRAID_HOST=${UNRAID_HOST:-${ARR_HOST}}
|
||||
- UNRAID_API_KEY=${UNRAID_API_KEY}
|
||||
restart: unless-stopped
|
||||
80
export-to-unraid.sh
Executable file
80
export-to-unraid.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# Greendale Media Centre — Export to Unraid
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x export-to-unraid.sh
|
||||
# ./export-to-unraid.sh [unraid-user@unraid-ip] [destination-path]
|
||||
#
|
||||
# Examples:
|
||||
# ./export-to-unraid.sh root@192.168.86.10
|
||||
# ./export-to-unraid.sh root@192.168.86.10 /mnt/user/appdata/greendale
|
||||
#
|
||||
# What it does:
|
||||
# 1. Builds the Docker image locally and saves it as a tarball
|
||||
# 2. rsyncs the project files (minus secrets & cache) to Unraid
|
||||
# 3. Copies the image tarball to Unraid
|
||||
# 4. Loads the image on Unraid via SSH
|
||||
# Leaves your .env on Unraid untouched if it already exists.
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${1:-}"
|
||||
DEST="${2:-/mnt/user/appdata/greendale}"
|
||||
IMAGE_NAME="arr-summary-arr-summary"
|
||||
TARBALL="/tmp/greendale-image.tar"
|
||||
|
||||
if [[ -z "$REMOTE" ]]; then
|
||||
echo "Usage: $0 <user@unraid-ip> [destination-path]"
|
||||
echo "Example: $0 root@192.168.86.10 /mnt/user/appdata/greendale"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ Building Docker image..."
|
||||
docker compose build
|
||||
|
||||
echo "▶ Saving image to tarball (this may take a moment)..."
|
||||
docker save "$IMAGE_NAME" -o "$TARBALL"
|
||||
|
||||
echo "▶ Syncing project files to $REMOTE:$DEST ..."
|
||||
rsync -avz --progress \
|
||||
--exclude='.env' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.git' \
|
||||
--exclude='nginx' \
|
||||
--exclude='export-to-unraid.sh' \
|
||||
--exclude="$(basename "$TARBALL")" \
|
||||
./ "$REMOTE:$DEST/"
|
||||
|
||||
echo "▶ Copying Docker image to Unraid..."
|
||||
scp "$TARBALL" "$REMOTE:/tmp/greendale-image.tar"
|
||||
|
||||
echo "▶ Loading image on Unraid..."
|
||||
ssh "$REMOTE" "docker load -i /tmp/greendale-image.tar && rm /tmp/greendale-image.tar"
|
||||
|
||||
echo "▶ Checking if .env exists on Unraid..."
|
||||
if ssh "$REMOTE" "test -f $DEST/.env"; then
|
||||
echo " ✓ .env already exists on Unraid — leaving it untouched."
|
||||
else
|
||||
echo " ⚠ No .env found. Copying .env.example as a starting point..."
|
||||
ssh "$REMOTE" "cp $DEST/.env.example $DEST/.env"
|
||||
echo " → Edit $DEST/.env on Unraid before starting the container!"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " ✅ Export complete!"
|
||||
echo ""
|
||||
echo " On Unraid, to start:"
|
||||
echo " cd $DEST"
|
||||
echo " nano .env # set LOGIN_PASSWORD, SECRET_KEY, etc."
|
||||
echo " docker compose up -d"
|
||||
echo ""
|
||||
echo " Or add via Unraid's Docker UI:"
|
||||
echo " Repository: arr-summary-arr-summary (already loaded)"
|
||||
echo " Port mapping: HOST_PORT (default 5055) → 5000"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
|
||||
rm -f "$TARBALL"
|
||||
22
gunicorn.conf.py
Normal file
22
gunicorn.conf.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Gunicorn configuration
|
||||
workers = 2 # 2 workers × 4 threads = 8 concurrent requests; fewer cold-cache workers
|
||||
threads = 4
|
||||
worker_class = "gthread"
|
||||
bind = "0.0.0.0:5000"
|
||||
timeout = 30 # fail fast — app has its own 5s internal deadline
|
||||
keepalive = 5
|
||||
|
||||
def post_fork(server, worker):
|
||||
"""Pre-warm the slow caches in each worker right after fork.
|
||||
This runs in the background so the worker is ready instantly."""
|
||||
import threading
|
||||
def _warm():
|
||||
try:
|
||||
import app as a
|
||||
a.get_radarr_summary()
|
||||
a.get_sonarr_summary()
|
||||
a.get_tautulli_summary()
|
||||
except Exception:
|
||||
pass
|
||||
t = threading.Thread(target=_warm, daemon=True)
|
||||
t.start()
|
||||
203
login.html
Normal file
203
login.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Greendale Media Centre — Login</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--gold-dk: #c49a2a;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ── Logo / brand ── */
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.brand .logo-badge {
|
||||
display: inline-block;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.brand h1 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.6rem;
|
||||
color: var(--gold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.brand p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.3rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.login-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--gold);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.field { margin-bottom: 1.1rem; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus { border-color: var(--gold); }
|
||||
.field input::placeholder { color: var(--muted); }
|
||||
|
||||
/* ── Error ── */
|
||||
.error-msg {
|
||||
background: rgba(232,75,75,0.1);
|
||||
border: 1px solid rgba(232,75,75,0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
color: #ff8080;
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Submit button ── */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Special Elite', cursive;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.btn-login:hover { opacity: 0.88; }
|
||||
.btn-login:active { transform: scale(0.98); }
|
||||
|
||||
/* ── Footer quote ── */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale Community College" style="width:90px; height:90px; object-fit:contain; margin-bottom:0.75rem; filter:drop-shadow(0 2px 8px rgba(0,0,0,0.4));">
|
||||
<h1>Greendale Media Centre</h1>
|
||||
<p>Human beings welcome. Robots too.</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h2>🔒 Study Room F — Access Required</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-msg">⚠️ {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
autofocus
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">Enter Study Room →</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
"I discovered at a young age that I have… a gift." — Jeff Winger
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
68
nginx/greendale-subdomain.conf
Normal file
68
nginx/greendale-subdomain.conf
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# Greendale Media Centre — Nginx Reverse Proxy Config
|
||||
# SUBDOMAIN variant: https://media.yourdomain.com
|
||||
#
|
||||
# 1. Copy this file to /etc/nginx/conf.d/ (or your Nginx proxy
|
||||
# manager's custom config directory) on your Unraid server.
|
||||
# 2. Replace "media.yourdomain.com" with your actual domain.
|
||||
# 3. Replace "YOUR_UNRAID_IP" with your Unraid server's LAN IP.
|
||||
# 4. Replace "5055" if you changed HOST_PORT in .env.
|
||||
# 5. SSL is handled by Nginx Proxy Manager (recommended on Unraid)
|
||||
# — point it at this container on port 5055.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name media.yourdomain.com;
|
||||
|
||||
# Redirect all HTTP → HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name media.yourdomain.com;
|
||||
|
||||
# ── SSL certificates (managed by NPM or Certbot) ──
|
||||
# ssl_certificate /etc/letsencrypt/live/media.yourdomain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/media.yourdomain.com/privkey.pem;
|
||||
|
||||
# ── Security headers ──
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
# ── Proxy to Flask container ──
|
||||
location / {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (future-proof)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_read_timeout 90;
|
||||
proxy_connect_timeout 90;
|
||||
proxy_send_timeout 90;
|
||||
|
||||
# Buffer settings for smooth streaming
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 8k;
|
||||
proxy_buffers 8 8k;
|
||||
}
|
||||
|
||||
# ── Static asset caching ──
|
||||
location ~* \.(css|js|png|jpg|ico|woff2?)$ {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
34
nginx/greendale-subpath.conf
Normal file
34
nginx/greendale-subpath.conf
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# ─────────────────────────────────────────────────────────────
|
||||
# Greendale Media Centre — Nginx Reverse Proxy Config
|
||||
# SUBPATH variant: https://yourdomain.com/media/
|
||||
#
|
||||
# Use this if you want to host under a path on an existing domain
|
||||
# instead of a dedicated subdomain.
|
||||
#
|
||||
# NOTE: The Flask app already serves from "/" — Nginx strips the
|
||||
# /media prefix and the app sees clean paths.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name yourdomain.com;
|
||||
|
||||
# ... your existing SSL/other config above ...
|
||||
|
||||
# ── Proxy to Greendale Media Centre ──
|
||||
location /media/ {
|
||||
proxy_pass http://YOUR_UNRAID_IP:5055/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_redirect off;
|
||||
proxy_read_timeout 90;
|
||||
|
||||
# Required so cookies set at "/" work on the /media/ sub-path
|
||||
proxy_cookie_path / /media/;
|
||||
}
|
||||
}
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask==3.1.0
|
||||
requests==2.32.3
|
||||
gunicorn==23.0.0
|
||||
486
settings.html
Normal file
486
settings.html
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
|
||||
{% elif theme == 'parks' %}<title>Settings — Pawnee Parks Media Centre</title>
|
||||
{% elif theme == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
|
||||
{% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
|
||||
{% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
|
||||
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
|
||||
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
|
||||
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
|
||||
{% if theme == 'office' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'parks' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'potter' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'starwars' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
--brand-font: 'Special Elite', cursive;
|
||||
}
|
||||
{% if theme == 'office' %}
|
||||
:root {
|
||||
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
|
||||
--navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
|
||||
--border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
|
||||
--brand-font: 'Libre Baskerville', serif;
|
||||
}
|
||||
{% elif theme == 'parks' %}
|
||||
:root {
|
||||
--gold: #5aad4e; --gold-dk: #3d8c33; --teal: #c8842a; --teal-lt: #e09a3a;
|
||||
--navy: #1a2213; --navy-lt: #222e19; --navy-mid: #1d2a16; --panel: #1a2615;
|
||||
--border: #2d4220; --text: #d8edd0; --muted: #7a9a72; --red: #e84b4b; --green: #5aad4e;
|
||||
--brand-font: 'Playfair Display', serif;
|
||||
}
|
||||
{% elif theme == 'potter' %}
|
||||
:root {
|
||||
--gold: #d4af37; --gold-dk: #b8961e; --teal: #7b2d8b; --teal-lt: #9b3dab;
|
||||
--navy: #1a1228; --navy-lt: #231934; --navy-mid: #1d1530; --panel: #1a1228;
|
||||
--border: #3d2a5a; --text: #e8dff8; --muted: #9080b8; --red: #8b1a1a; --green: #2d6e3a;
|
||||
--brand-font: 'MedievalSharp', cursive;
|
||||
}
|
||||
{% elif theme == 'starwars' %}
|
||||
:root {
|
||||
--gold: #ffe81f; --gold-dk: #d4c000; --teal: #4fc3f7; --teal-lt: #81d4fa;
|
||||
--navy: #080c14; --navy-lt: #0f1620; --navy-mid: #0a1018; --panel: #0d1422;
|
||||
--border: #1e2d42; --text: #d0e8ff; --muted: #607d9e; --red: #e84b4b; --green: #00e676;
|
||||
--brand-font: 'Orbitron', sans-serif;
|
||||
}
|
||||
{% endif %}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: var(--navy); color: var(--text); min-height: 100vh; }
|
||||
|
||||
header {
|
||||
background: var(--navy-mid);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 1.2rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header img { width: 40px; height: 40px; object-fit: contain; }
|
||||
header h1 { font-family: var(--brand-font); font-size: 1.3rem; color: var(--gold); flex: 1; }
|
||||
.back-btn {
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.back-btn:hover { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
main { padding: 2rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
.card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--gold); }
|
||||
.field select option { background: var(--navy-mid); }
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--brand-font);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.88; }
|
||||
.save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
|
||||
|
||||
.error-inline {
|
||||
font-size: 0.78rem;
|
||||
color: var(--red);
|
||||
display: none;
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||
background: var(--green); color: #fff;
|
||||
padding: 0.7rem 1.2rem; border-radius: 10px;
|
||||
font-size: 0.88rem; font-weight: 700;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.error { background: var(--red); }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Theme preview chips */
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.theme-chip {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.6rem 0.6rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
background: var(--navy-lt);
|
||||
}
|
||||
.theme-chip:hover { border-color: var(--gold); }
|
||||
.theme-chip.active { border-color: var(--gold); background: rgba(232,184,75,0.08); }
|
||||
.theme-chip .tc-emoji { font-size: 1.6rem; margin-bottom: 0.35rem; }
|
||||
.theme-chip .tc-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--brand-font);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.theme-chip .tc-sub {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
|
||||
<h1>Settings</h1>
|
||||
<a href="/" class="back-btn">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="settings-grid">
|
||||
|
||||
<!-- General -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🌍</span>
|
||||
<h2>General</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Timezone</label>
|
||||
<select id="timezone">
|
||||
{% set tzs = [
|
||||
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
|
||||
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
|
||||
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
|
||||
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
|
||||
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
|
||||
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
|
||||
] %}
|
||||
{% for tz in tzs %}
|
||||
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Host IP (ARR_HOST)</label>
|
||||
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveGeneral()">Save General</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🎨</span>
|
||||
<h2>Theme</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="theme-options" id="theme-options">
|
||||
<div class="theme-chip {% if theme == 'community' %}active{% endif %}" data-theme="community" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🏫</div>
|
||||
<div class="tc-name">Community</div>
|
||||
<div class="tc-sub">Greendale College</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'office' %}active{% endif %}" data-theme="office" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">📎</div>
|
||||
<div class="tc-name">The Office</div>
|
||||
<div class="tc-sub">Dunder Mifflin</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'parks' %}active{% endif %}" data-theme="parks" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌳</div>
|
||||
<div class="tc-name">Parks & Rec</div>
|
||||
<div class="tc-sub">Pawnee, Indiana</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'potter' %}active{% endif %}" data-theme="potter" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">⚡</div>
|
||||
<div class="tc-name">Harry Potter</div>
|
||||
<div class="tc-sub">Hogwarts</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'starwars' %}active{% endif %}" data-theme="starwars" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌌</div>
|
||||
<div class="tc-name">Star Wars</div>
|
||||
<div class="tc-sub">A long time ago…</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveTheme()">Apply Theme</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🔐</span>
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="pw_current" placeholder="Enter current password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="pw_new" placeholder="At least 6 characters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw_confirm" placeholder="Repeat new password">
|
||||
<div class="error-inline" id="pw-error"></div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sonarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📺</span><h2>Sonarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sonarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎬</span><h2>Radarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="radarr_api_key" value="{{ radarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('radarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SABnzbd -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⬇️</span><h2>SABnzbd</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sabnzbd_api_key" value="{{ sabnzbd_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sabnzbd')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tautulli -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>▶️</span><h2>Tautulli</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="tautulli_api_key" value="{{ tautulli_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('tautulli')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prowlarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🔍</span><h2>Prowlarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="prowlarr_api_key" value="{{ prowlarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('prowlarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ombi -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎟️</span><h2>Ombi</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="ombi_api_key" value="{{ ombi_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('ombi')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast">✓ Saved</div>
|
||||
|
||||
<script>
|
||||
let _selectedTheme = '{{ theme }}';
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg || '✓ Saved';
|
||||
t.classList.toggle('error', !!isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2800);
|
||||
}
|
||||
|
||||
async function saveGeneral() {
|
||||
const payload = {
|
||||
timezone: document.getElementById('timezone').value,
|
||||
arr_host: document.getElementById('arr_host').value.trim(),
|
||||
};
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ General saved — reload dashboard for clock update');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
async function saveService(name) {
|
||||
const keyEl = document.getElementById(name + '_api_key');
|
||||
const payload = { [name + '_api_key']: keyEl.value.trim() };
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
function selectTheme(el) {
|
||||
document.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
_selectedTheme = el.dataset.theme;
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ theme: _selectedTheme })
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('✓ Theme applied! Heading to dashboard…');
|
||||
setTimeout(() => { window.location.href = '/'; }, 900);
|
||||
} else {
|
||||
showToast('✗ Save failed', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const current = document.getElementById('pw_current').value;
|
||||
const newPw = document.getElementById('pw_new').value;
|
||||
const confirm = document.getElementById('pw_confirm').value;
|
||||
const errEl = document.getElementById('pw-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const resp = await fetch('/api/change-password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('pw_current').value = '';
|
||||
document.getElementById('pw_new').value = '';
|
||||
document.getElementById('pw_confirm').value = '';
|
||||
showToast('✓ Password updated successfully');
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Failed to update password';
|
||||
errEl.style.display = 'block';
|
||||
showToast('✗ ' + (data.error || 'Failed'), true);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
static/favicon.png
Normal file
BIN
static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
static/logo.png
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
1964
templates/dashboard.html
Normal file
1964
templates/dashboard.html
Normal file
File diff suppressed because it is too large
Load diff
203
templates/login.html
Normal file
203
templates/login.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Greendale Media Centre — Login</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--gold-dk: #c49a2a;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* ── Logo / brand ── */
|
||||
.brand {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.brand .logo-badge {
|
||||
display: inline-block;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
padding: 0.5rem 1.2rem;
|
||||
border-radius: 8px;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.brand h1 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1.6rem;
|
||||
color: var(--gold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.brand p {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.3rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.login-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--gold);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 2rem 1.75rem;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 1rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Form fields ── */
|
||||
.field { margin-bottom: 1.1rem; }
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus { border-color: var(--gold); }
|
||||
.field input::placeholder { color: var(--muted); }
|
||||
|
||||
/* ── Error ── */
|
||||
.error-msg {
|
||||
background: rgba(232,75,75,0.1);
|
||||
border: 1px solid rgba(232,75,75,0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1rem;
|
||||
color: #ff8080;
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Submit button ── */
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-family: 'Special Elite', cursive;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.btn-login:hover { opacity: 0.88; }
|
||||
.btn-login:active { transform: scale(0.98); }
|
||||
|
||||
/* ── Footer quote ── */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
font-family: 'Special Elite', cursive;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale Community College" style="width:90px; height:90px; object-fit:contain; margin-bottom:0.75rem; filter:drop-shadow(0 2px 8px rgba(0,0,0,0.4));">
|
||||
<h1>Greendale Media Centre</h1>
|
||||
<p>Human beings welcome. Robots too.</p>
|
||||
</div>
|
||||
|
||||
<div class="login-card">
|
||||
<h2>🔒 Study Room F — Access Required</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-msg">⚠️ {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('login', next=request.args.get('next', '')) }}">
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter password"
|
||||
autofocus
|
||||
autocomplete="current-password"
|
||||
>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">Enter Study Room →</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
"I discovered at a young age that I have… a gift." — Jeff Winger
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
486
templates/settings.html
Normal file
486
templates/settings.html
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% if theme == 'office' %}<title>Settings — Dunder Mifflin Media Centre</title>
|
||||
{% elif theme == 'parks' %}<title>Settings — Pawnee Parks Media Centre</title>
|
||||
{% elif theme == 'potter' %}<title>Settings — Hogwarts Media Vault</title>
|
||||
{% elif theme == 'starwars' %}<title>Settings — Rebel Alliance Media Archive</title>
|
||||
{% else %}<title>Settings — Greendale Media Centre</title>{% endif %}
|
||||
{% if theme == 'office' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📎</text></svg>">
|
||||
{% elif theme == 'parks' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌳</text></svg>">
|
||||
{% elif theme == 'potter' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
{% elif theme == 'starwars' %}<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
{% else %}<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">{% endif %}
|
||||
{% if theme == 'office' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'parks' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'potter' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=MedievalSharp&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% elif theme == 'starwars' %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Special+Elite&family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--gold: #e8b84b;
|
||||
--teal: #2a9d8f;
|
||||
--teal-lt: #3dbdb0;
|
||||
--navy: #1b2a3b;
|
||||
--navy-lt: #243447;
|
||||
--navy-mid:#1e3050;
|
||||
--panel: #192433;
|
||||
--border: #2e4460;
|
||||
--text: #d8e4f0;
|
||||
--muted: #7a93b0;
|
||||
--red: #e84b4b;
|
||||
--green: #27c98f;
|
||||
--brand-font: 'Special Elite', cursive;
|
||||
}
|
||||
{% if theme == 'office' %}
|
||||
:root {
|
||||
--gold: #4a90d9; --gold-dk: #357abd; --teal: #6c757d; --teal-lt: #8e9aa8;
|
||||
--navy: #1c2128; --navy-lt: #252d38; --navy-mid: #1a2232; --panel: #1e2530;
|
||||
--border: #334155; --text: #dce6f0; --muted: #7a8fa6; --red: #e05252; --green: #3aad74;
|
||||
--brand-font: 'Libre Baskerville', serif;
|
||||
}
|
||||
{% elif theme == 'parks' %}
|
||||
:root {
|
||||
--gold: #5aad4e; --gold-dk: #3d8c33; --teal: #c8842a; --teal-lt: #e09a3a;
|
||||
--navy: #1a2213; --navy-lt: #222e19; --navy-mid: #1d2a16; --panel: #1a2615;
|
||||
--border: #2d4220; --text: #d8edd0; --muted: #7a9a72; --red: #e84b4b; --green: #5aad4e;
|
||||
--brand-font: 'Playfair Display', serif;
|
||||
}
|
||||
{% elif theme == 'potter' %}
|
||||
:root {
|
||||
--gold: #d4af37; --gold-dk: #b8961e; --teal: #7b2d8b; --teal-lt: #9b3dab;
|
||||
--navy: #1a1228; --navy-lt: #231934; --navy-mid: #1d1530; --panel: #1a1228;
|
||||
--border: #3d2a5a; --text: #e8dff8; --muted: #9080b8; --red: #8b1a1a; --green: #2d6e3a;
|
||||
--brand-font: 'MedievalSharp', cursive;
|
||||
}
|
||||
{% elif theme == 'starwars' %}
|
||||
:root {
|
||||
--gold: #ffe81f; --gold-dk: #d4c000; --teal: #4fc3f7; --teal-lt: #81d4fa;
|
||||
--navy: #080c14; --navy-lt: #0f1620; --navy-mid: #0a1018; --panel: #0d1422;
|
||||
--border: #1e2d42; --text: #d0e8ff; --muted: #607d9e; --red: #e84b4b; --green: #00e676;
|
||||
--brand-font: 'Orbitron', sans-serif;
|
||||
}
|
||||
{% endif %}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: var(--navy); color: var(--text); min-height: 100vh; }
|
||||
|
||||
header {
|
||||
background: var(--navy-mid);
|
||||
border-bottom: 3px solid var(--gold);
|
||||
padding: 1.2rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
header img { width: 40px; height: 40px; object-fit: contain; }
|
||||
header h1 { font-family: var(--brand-font); font-size: 1.3rem; color: var(--gold); flex: 1; }
|
||||
.back-btn {
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.back-btn:hover { border-color: var(--gold); color: var(--gold); }
|
||||
|
||||
main { padding: 2rem; max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.9rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.card-header h2 {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 1rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
.card-body { padding: 1.1rem 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.field input, .field select {
|
||||
width: 100%;
|
||||
background: var(--navy-lt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text);
|
||||
font-family: 'Inter', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.field input:focus, .field select:focus { border-color: var(--gold); }
|
||||
.field select option { background: var(--navy-mid); }
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-family: var(--brand-font);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.save-btn:hover { opacity: 0.88; }
|
||||
.save-btn:disabled { background: var(--border); color: var(--muted); cursor: default; }
|
||||
|
||||
.error-inline {
|
||||
font-size: 0.78rem;
|
||||
color: var(--red);
|
||||
display: none;
|
||||
margin-top: -0.3rem;
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
||||
background: var(--green); color: #fff;
|
||||
padding: 0.7rem 1.2rem; border-radius: 10px;
|
||||
font-size: 0.88rem; font-weight: 700;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast.error { background: var(--red); }
|
||||
.toast.show { opacity: 1; }
|
||||
|
||||
.section-label {
|
||||
font-family: var(--brand-font);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Theme preview chips */
|
||||
.theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.theme-chip {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.6rem 0.6rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
background: var(--navy-lt);
|
||||
}
|
||||
.theme-chip:hover { border-color: var(--gold); }
|
||||
.theme-chip.active { border-color: var(--gold); background: rgba(232,184,75,0.08); }
|
||||
.theme-chip .tc-emoji { font-size: 1.6rem; margin-bottom: 0.35rem; }
|
||||
.theme-chip .tc-name {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-family: var(--brand-font);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.theme-chip .tc-sub {
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<img src="{{ url_for('static', filename='logo.png') }}" alt="Greendale" id="header-logo">
|
||||
<h1>Settings</h1>
|
||||
<a href="/" class="back-btn">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="settings-grid">
|
||||
|
||||
<!-- General -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🌍</span>
|
||||
<h2>General</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Timezone</label>
|
||||
<select id="timezone">
|
||||
{% set tzs = [
|
||||
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
|
||||
'America/Toronto','America/Vancouver','America/Phoenix','America/Halifax',
|
||||
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Amsterdam',
|
||||
'Europe/Stockholm','Europe/Rome','Europe/Madrid','Europe/Zurich',
|
||||
'Australia/Sydney','Australia/Melbourne','Australia/Brisbane','Australia/Perth',
|
||||
'Pacific/Auckland','Asia/Tokyo','Asia/Singapore','Asia/Dubai','Asia/Kolkata'
|
||||
] %}
|
||||
{% for tz in tzs %}
|
||||
<option value="{{ tz }}" {% if tz == timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Host IP (ARR_HOST)</label>
|
||||
<input type="text" id="arr_host" value="{{ arr_host }}" placeholder="e.g. 192.168.86.33">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveGeneral()">Save General</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🎨</span>
|
||||
<h2>Theme</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="theme-options" id="theme-options">
|
||||
<div class="theme-chip {% if theme == 'community' %}active{% endif %}" data-theme="community" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🏫</div>
|
||||
<div class="tc-name">Community</div>
|
||||
<div class="tc-sub">Greendale College</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'office' %}active{% endif %}" data-theme="office" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">📎</div>
|
||||
<div class="tc-name">The Office</div>
|
||||
<div class="tc-sub">Dunder Mifflin</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'parks' %}active{% endif %}" data-theme="parks" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌳</div>
|
||||
<div class="tc-name">Parks & Rec</div>
|
||||
<div class="tc-sub">Pawnee, Indiana</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'potter' %}active{% endif %}" data-theme="potter" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">⚡</div>
|
||||
<div class="tc-name">Harry Potter</div>
|
||||
<div class="tc-sub">Hogwarts</div>
|
||||
</div>
|
||||
<div class="theme-chip {% if theme == 'starwars' %}active{% endif %}" data-theme="starwars" onclick="selectTheme(this)">
|
||||
<div class="tc-emoji">🌌</div>
|
||||
<div class="tc-name">Star Wars</div>
|
||||
<div class="tc-sub">A long time ago…</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveTheme()">Apply Theme</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card" style="grid-column: 1 / -1;">
|
||||
<div class="card-header">
|
||||
<span>🔐</span>
|
||||
<h2>Change Password</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>Current Password</label>
|
||||
<input type="password" id="pw_current" placeholder="Enter current password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>New Password</label>
|
||||
<input type="password" id="pw_new" placeholder="At least 6 characters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Confirm New Password</label>
|
||||
<input type="password" id="pw_confirm" placeholder="Repeat new password">
|
||||
<div class="error-inline" id="pw-error"></div>
|
||||
</div>
|
||||
<button class="save-btn" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sonarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>📺</span><h2>Sonarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sonarr_api_key" value="{{ sonarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sonarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Radarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎬</span><h2>Radarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="radarr_api_key" value="{{ radarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('radarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SABnzbd -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>⬇️</span><h2>SABnzbd</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="sabnzbd_api_key" value="{{ sabnzbd_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('sabnzbd')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tautulli -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>▶️</span><h2>Tautulli</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="tautulli_api_key" value="{{ tautulli_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('tautulli')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prowlarr -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🔍</span><h2>Prowlarr</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="prowlarr_api_key" value="{{ prowlarr_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('prowlarr')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ombi -->
|
||||
<div class="card">
|
||||
<div class="card-header"><span>🎟️</span><h2>Ombi</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="ombi_api_key" value="{{ ombi_api_key }}">
|
||||
</div>
|
||||
<button class="save-btn" onclick="saveService('ombi')">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast">✓ Saved</div>
|
||||
|
||||
<script>
|
||||
let _selectedTheme = '{{ theme }}';
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg || '✓ Saved';
|
||||
t.classList.toggle('error', !!isError);
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 2800);
|
||||
}
|
||||
|
||||
async function saveGeneral() {
|
||||
const payload = {
|
||||
timezone: document.getElementById('timezone').value,
|
||||
arr_host: document.getElementById('arr_host').value.trim(),
|
||||
};
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ General saved — reload dashboard for clock update');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
async function saveService(name) {
|
||||
const keyEl = document.getElementById(name + '_api_key');
|
||||
const payload = { [name + '_api_key']: keyEl.value.trim() };
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.ok) showToast('✓ ' + name.charAt(0).toUpperCase() + name.slice(1) + ' saved — restart container to apply');
|
||||
else showToast('✗ Save failed', true);
|
||||
}
|
||||
|
||||
function selectTheme(el) {
|
||||
document.querySelectorAll('.theme-chip').forEach(c => c.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
_selectedTheme = el.dataset.theme;
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const resp = await fetch('/api/settings', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ theme: _selectedTheme })
|
||||
});
|
||||
if (resp.ok) {
|
||||
showToast('✓ Theme applied! Heading to dashboard…');
|
||||
setTimeout(() => { window.location.href = '/'; }, 900);
|
||||
} else {
|
||||
showToast('✗ Save failed', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const current = document.getElementById('pw_current').value;
|
||||
const newPw = document.getElementById('pw_new').value;
|
||||
const confirm = document.getElementById('pw_confirm').value;
|
||||
const errEl = document.getElementById('pw-error');
|
||||
errEl.style.display = 'none';
|
||||
|
||||
const resp = await fetch('/api/change-password', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'include', body: JSON.stringify({ current, new: newPw, confirm })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
document.getElementById('pw_current').value = '';
|
||||
document.getElementById('pw_new').value = '';
|
||||
document.getElementById('pw_confirm').value = '';
|
||||
showToast('✓ Password updated successfully');
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Failed to update password';
|
||||
errEl.style.display = 'block';
|
||||
showToast('✗ ' + (data.error || 'Failed'), true);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue