Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 47 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,57 @@

<h1 align="center">Alertle-V2</h1>

<p align="center">Sports alert notifications powered by ESPN, EPG, and your preferred push service.</p>
<p align="center"><em>He's slow. Your alerts aren't.</em></p>

<p align="center">A self-hosted sports alert system. NHL, NBA, NFL, Soccer, Golf, F1, and more — straight to your phone.</p>

---

## Meet the Alertle Turtle 🐢

The Alertle Turtle is our mascot, our spirit animal, and our greatest irony. He is notoriously slow. He does not rush for anyone. And yet somehow, he always makes sure your game alerts arrive on time.

Don't miss a game. The Alertle Turtle's got you.

## ⚡ Vibe Coded

This entire project was built through conversation with Claude AI — no code was written by hand. No documentation was read. No Stack Overflow tabs were opened. Just vibes, prompts, and one very good turtle idea.

If it works, great. If something breaks, that's also the vibes. PRs are welcome. So is asking an AI about it.

---

## Quick Start

```bash
# Pull and run with Docker Compose
curl -o docker-compose.yml https://raw.githubusercontent.com/Deekerman/Alertle-V2/main/docker-compose.yml
curl -o config.yaml https://raw.githubusercontent.com/Deekerman/Alertle-V2/main/config.yaml.example
# Edit config.yaml with your settings, then:
docker compose up -d
```yaml
services:
alertle-v2:
image: ghcr.io/deekerman/alertle-v2:b4f36b8
container_name: alertle-v2
pull_policy: always
restart: unless-stopped
environment:
TZ: America/Toronto
ports:
- 8888:8888
volumes:
- ./alertle-v2/config.yaml:/config/config.yaml
- alertle_data:/app/data
volumes:
alertle_data: null
networks: {}
```

Or pull the image directly:

```bash
docker pull ghcr.io/deekerman/alertle-v2:latest
# Create the config directory and grab the example config
mkdir -p alertle-v2
curl -o alertle-v2/config.yaml https://raw.githubusercontent.com/Deekerman/Alertle-V2/main/config.yaml.example

# Edit config.yaml, then:
docker compose up -d
```

The web UI will be available at `http://localhost:8888`.
The web UI will be available at `http://localhost:8888`. The first-start wizard will walk you through the rest.

---

Expand All @@ -39,6 +69,8 @@ The web UI will be available at `http://localhost:8888`.
- Fully configurable notification templates with per-endpoint overrides
- 7-day lookahead scan with configurable daily scan time
- Web UI for managing subscriptions, endpoints, settings, and viewing upcoming alerts
- First-start setup wizard so you're not staring at a blank config
- Backup and restore — including import from V1

## How It Works

Expand Down Expand Up @@ -80,22 +112,15 @@ sudo journalctl -u alertle-v2 -f
### Docker

```bash
git clone https://github.com/Deekerman/Alertle-V2.git
cd Alertle-V2
cp config.yaml.example config.yaml
# Edit config.yaml with your settings
mkdir -p alertle-v2
curl -o alertle-v2/config.yaml https://raw.githubusercontent.com/Deekerman/Alertle-V2/main/config.yaml.example
curl -o docker-compose.yml https://raw.githubusercontent.com/Deekerman/Alertle-V2/main/docker-compose.yml
# Edit alertle-v2/config.yaml, then:
docker compose up -d
```

The web UI will be available at `http://localhost:8888`.

```yaml
# docker-compose.yml mounts
volumes:
- ./config.yaml:/config/config.yaml
- alertle_data:/data
```

## Configuration

Copy `config.yaml.example` to `config.yaml` and edit. The web UI at `/settings` can manage most options after first launch.
Expand Down
8 changes: 7 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@


def _load_raw() -> dict:
if not CONFIG_PATH.exists():
if not CONFIG_PATH.exists() or CONFIG_PATH.is_dir():
return {}
with CONFIG_PATH.open() as f:
return yaml.safe_load(f) or {}


def _save_raw(data: dict) -> None:
if CONFIG_PATH.is_dir():
raise RuntimeError(
f"Config path '{CONFIG_PATH}' is a directory, not a file. "
"On the host, run: rm -rf ./alertle-v2/config.yaml && "
"curl -o alertle-v2/config.yaml <raw URL from README>"
)
with CONFIG_PATH.open("w") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)

Expand Down
13 changes: 6 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
version: "3.8"

services:
alertle-v2:
image: ghcr.io/deekerman/alertle-v2:latest
build: . # optional: remove this line to always pull from GHCR
container_name: alertle-v2
pull_policy: always
restart: unless-stopped
environment:
TZ: America/Toronto
ALERTLE_CONFIG: /config/config.yaml
ports:
- "8888:8888"
volumes:
- ./config.yaml:/config/config.yaml
- ./alertle-v2/config.yaml:/config/config.yaml
- alertle_data:/app/data
environment:
- ALERTLE_CONFIG=/config/config.yaml
restart: unless-stopped

volumes:
alertle_data:
27 changes: 14 additions & 13 deletions notifiers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,25 @@

_GAME_THUMBS_DEFAULT = "https://game-thumbs.swvn.io"

DEFAULT_TEMPLATE = """{time}
📺 {channels}
📍 {venue}
{context}
{odds}
{score}"""

DEFAULT_GAME_SUMMARY_TEMPLATE = """🏆 {score}
DEFAULT_TEMPLATE = """{description}
{time}
📺 {channels}
{description}"""
{venue}
{channels}"""

DEFAULT_DIGEST_GAME_TEMPLATE = """{context}
DEFAULT_GAME_SUMMARY_TEMPLATE = """{time}
{score}"""

DEFAULT_DIGEST_GAME_TEMPLATE = """{away} at {home}
Game Start:
{time}
📺 {channels}"""
{context}
Times & Channels
{channels}"""

DEFAULT_DIGEST_EVENT_TEMPLATE = """{context}
DEFAULT_DIGEST_EVENT_TEMPLATE = """{home}
Event Start:
{time}
Times & Channels:
{channels}"""


Expand Down
25 changes: 14 additions & 11 deletions templates/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,45 +37,48 @@ <h2>Notification Template</h2>
<label for="notification_template">Default Template</label>
<textarea id="notification_template" name="notification_template" rows="7"
style="font-family: monospace; font-size: 0.85rem; white-space: pre"
placeholder="{time}&#10;📺 {channels}&#10;📍 {venue}&#10;{context}&#10;{odds}&#10;{score}">{{ cfg.get('notification_defaults', {}).get('template', '') }}</textarea>
placeholder="{description}&#10;{time}&#10;{venue}&#10;{channels}">{{ cfg.get('notification_defaults', {}).get('template', '') }}</textarea>
<span class="hint">
Available variables: <code>{time}</code> <code>{channels}</code> <code>{broadcast}</code>
<code>{venue}</code> <code>{context}</code> <code>{odds}</code> <code>{score}</code>
<code>{description}</code> <code>{home}</code> <code>{away}</code> <code>{home_abbrev}</code> <code>{away_abbrev}</code>
<code>{league}</code> <code>{sport}</code>
<code>{venue}</code> <code>{context}</code> <code>{odds}</code> <code>{odds_spread}</code>
<code>{odds_ou}</code> <code>{odds_home_ml}</code> <code>{odds_away_ml}</code>
<code>{score}</code> <code>{home}</code> <code>{away}</code> <code>{home_abbrev}</code>
<code>{away_abbrev}</code> <code>{league}</code> <code>{sport}</code>
</span>
<span class="hint">Leave empty to use default: time, channels, venue, context, odds, score</span>
<span class="hint">Leave empty to use default: description, time, venue, channels</span>
</div>
<div class="form-row" style="margin-top:1rem">
<label for="game_summary_template">Game Summary Template</label>
<textarea id="game_summary_template" name="game_summary_template" rows="5"
style="font-family: monospace; font-size: 0.85rem; white-space: pre"
placeholder="🏆 {score}&#10;{time}&#10;📺 {channels}&#10;{description}">{{ cfg.get('notification_defaults', {}).get('game_summary_template', '') }}</textarea>
placeholder="{time}&#10;{score}">{{ cfg.get('notification_defaults', {}).get('game_summary_template', '') }}</textarea>
<span class="hint">
Used for Final Score notifications. Same variables as above, plus <code>{description}</code> (EPG program description).
Leave empty to use default: score, time, channels, description.
Used for Final Score notifications. Same variables as above, plus <code>{score}</code> for the final result.
Leave empty to use default: time, score.
</span>
</div>
<div class="form-row" style="margin-top:1rem">
<label for="digest_game_template">Digest Template — Team Sports</label>
<textarea id="digest_game_template" name="digest_game_template" rows="4"
<textarea id="digest_game_template" name="digest_game_template" rows="6"
style="font-family: monospace; font-size: 0.85rem; white-space: pre"
placeholder="{context}&#10;{time}&#10;📺 {channels}">{{ cfg.get('notification_defaults', {}).get('digest_game_template', '') }}</textarea>
placeholder="{away} at {home}&#10;Game Start:&#10;{time}&#10;{context}&#10;Times &amp; Channels&#10;{channels}">{{ cfg.get('notification_defaults', {}).get('digest_game_template', '') }}</textarea>
<span class="hint">
Formats each game in the digest for team sports (NHL, NBA, MLB, etc.).
Leave empty to use default: context, time, 📺 channels.
Leave empty to use default: matchup, game start time, context, channels.
</span>
</div>
<div class="form-row" style="margin-top:1rem">
<label for="digest_event_template">Digest Template — Events (Golf, F1…)</label>
<textarea id="digest_event_template" name="digest_event_template" rows="4"
<textarea id="digest_event_template" name="digest_event_template" rows="5"
style="font-family: monospace; font-size: 0.85rem; white-space: pre"
placeholder="{context}&#10;{time}&#10;{channels}">{{ cfg.get('notification_defaults', {}).get('digest_event_template', '') }}</textarea>
placeholder="{home}&#10;Event Start:&#10;{time}&#10;Times &amp; Channels:&#10;{channels}">{{ cfg.get('notification_defaults', {}).get('digest_event_template', '') }}</textarea>
<span class="hint">
Formats each event in the digest for event series (Golf, F1, Tennis, etc.).
The <code>{channels}</code> variable includes the full broadcast schedule with time slots.
Leave empty to use default: context, time, channels.
Leave empty to use default: event name, start time, channels.
</span>
</div>
</section>
Expand Down
Loading