diff --git a/.env.example b/.env.example
index 0b82824..03c29ec 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@ DB_NAME=eventlens
MYSQL_ROOT_PASSWORD=rootsecret
TICKETMASTER_API_KEY=
+BANDSINTOWN_APP_ID=eventlens
+EVENTIM_ENABLED=true
POLL_INTERVAL_HOURS=6
REMINDER_INTERVAL_HOURS=12
@@ -19,4 +21,3 @@ SMTP_PASS=
SMTP_SENDER=eventlens@example.local
NOTIFICATION_EMAIL_TO=
SMTP_STARTTLS=true
-
diff --git a/README.md b/README.md
index 8cec6d4..8b58fa6 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
- Watchlist fuer Kuenstler oder Events
- Regionen `hamburg` und `germany`
-- Geplanter Event-Abgleich ueber die Ticketmaster Discovery API
+- Geplanter Event-Abgleich ueber mehrere Quellen
- E-Mail-Benachrichtigung bei neu gefundenen Terminen
- Markierung, ob Tickets gekauft wurden
- Erinnerung etwa eine Woche vor dem Termin
@@ -32,7 +32,9 @@ API-Statusinfo findest du unter `http://127.0.0.1:8000/api`.
## Wichtige Umgebungsvariablen
-- `TICKETMASTER_API_KEY`: noetig fuer die externe Eventsuche
+- `TICKETMASTER_API_KEY`: Ticketmaster Discovery API
+- `BANDSINTOWN_APP_ID`: Bandsintown App-ID fuer Artist-Events
+- `EVENTIM_ENABLED`: aktiviert den Eventim-Website-Provider
- `NOTIFICATION_EMAIL_TO`: Empfaenger fuer Benachrichtigungen
- `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`: SMTP-Zugang fuer E-Mails
@@ -77,9 +79,34 @@ curl -X PATCH http://127.0.0.1:8000/events/1/purchase \
- Fuer produktiven Betrieb solltest du TLS im NGINX-Terminator aktivieren.
- Das Frontend wird direkt vom FastAPI-Container ausgeliefert, es ist kein Node- oder Build-Container noetig.
+## Bekannte Betriebsfalle
+
+Wenn MariaDB bereits mit aelteren Zugangsdaten initialisiert wurde, reicht eine Aenderung in `.env` allein nicht aus. In dem Fall bleibt das Docker-Volume bestehen und der App-User in MariaDB hat noch das alte Passwort.
+
+Zum Angleichen auf die aktuellen Werte aus `.env`:
+
+```bash
+cd /opt/eventlens
+sudo bash scripts/fix-db-user.sh
+```
+
+Wenn dir die Datenbank egal ist und du komplett frisch starten willst:
+
+```bash
+cd /opt/eventlens
+sudo docker compose down -v
+sudo docker compose up -d --build
+```
+
## Naechste sinnvolle Ausbaustufen
- Web-Frontend fuer Watchlist und Events
- Weitere Datenquellen neben Ticketmaster
- Telegram oder Push-Benachrichtigungen
- Nutzerverwaltung
+
+## Provider-Hinweise
+
+- Ticketmaster nutzt die offizielle Discovery API.
+- Bandsintown nutzt die offizielle Artist-Events-API und arbeitet deshalb vor allem fuer Watchlist-Eintraege vom Typ `artist`.
+- Eventim nutzt eine beobachtete oeffentliche JSON-Web-API von eventim.de. Das ist keine offiziell dokumentierte Developer-API, aber stabiler als HTML-Scraping. Aenderungen an diesem Web-Endpoint koennen trotzdem Anpassungen noetig machen.
diff --git a/backend/app/config.py b/backend/app/config.py
index 2c05f1f..018590d 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -12,6 +12,8 @@ class Settings:
db_name = os.getenv("DB_NAME", "eventlens")
ticketmaster_api_key = os.getenv("TICKETMASTER_API_KEY", "")
+ bandsintown_app_id = os.getenv("BANDSINTOWN_APP_ID", "eventlens")
+ eventim_enabled = os.getenv("EVENTIM_ENABLED", "true").lower() == "true"
poll_interval_hours = int(os.getenv("POLL_INTERVAL_HOURS", "6"))
reminder_interval_hours = int(os.getenv("REMINDER_INTERVAL_HOURS", "12"))
@@ -25,4 +27,3 @@ class Settings:
settings = Settings()
-
diff --git a/backend/app/database.py b/backend/app/database.py
index 87dc811..8cbb1b4 100644
--- a/backend/app/database.py
+++ b/backend/app/database.py
@@ -1,12 +1,16 @@
-from sqlalchemy import create_engine
+from sqlalchemy import URL, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from app.config import settings
-DATABASE_URL = (
- f"mysql+pymysql://{settings.db_user}:{settings.db_password}"
- f"@{settings.db_host}:{settings.db_port}/{settings.db_name}"
+DATABASE_URL = URL.create(
+ "mysql+pymysql",
+ username=settings.db_user,
+ password=settings.db_password,
+ host=settings.db_host,
+ port=int(settings.db_port),
+ database=settings.db_name,
)
engine = create_engine(
@@ -24,4 +28,3 @@ def get_db():
yield db
finally:
db.close()
-
diff --git a/backend/app/frontend/index.html b/backend/app/frontend/index.html
index b1dbeb3..63a60ef 100644
--- a/backend/app/frontend/index.html
+++ b/backend/app/frontend/index.html
@@ -45,6 +45,12 @@
Letzte Aktion
Noch kein Sync ausgefuehrt
+
+
Quellen
+
+ Noch keine Daten
+
+
@@ -104,6 +110,12 @@
+
diff --git a/backend/app/frontend/static/app.js b/backend/app/frontend/static/app.js
index 439723f..b065306 100644
--- a/backend/app/frontend/static/app.js
+++ b/backend/app/frontend/static/app.js
@@ -9,8 +9,10 @@ const eventsEl = document.querySelector("#events");
const notificationsEl = document.querySelector("#notifications");
const watchForm = document.querySelector("#watch-form");
const eventFilter = document.querySelector("#event-filter");
+const providerFilter = document.querySelector("#provider-filter");
const syncButton = document.querySelector("#sync-button");
const toastEl = document.querySelector("#toast");
+const providerSummaryEl = document.querySelector("#provider-summary");
function showToast(message) {
toastEl.textContent = message;
@@ -79,6 +81,51 @@ function renderStats() {
document.querySelector("#ticket-count").textContent = state.events.filter(
(event) => event.is_ticket_purchased
).length;
+ renderProviderSummary();
+}
+
+function prettifyProviderName(value) {
+ const names = {
+ ticketmaster: "Ticketmaster",
+ bandsintown: "Bandsintown",
+ eventim: "Eventim",
+ };
+
+ return names[value] || value;
+}
+
+function getProviderClass(value) {
+ const classes = {
+ ticketmaster: "provider-ticketmaster",
+ bandsintown: "provider-bandsintown",
+ eventim: "provider-eventim",
+ };
+
+ return classes[value] || "";
+}
+
+function renderProviderSummary() {
+ if (!state.events.length) {
+ providerSummaryEl.innerHTML = 'Noch keine Daten';
+ return;
+ }
+
+ const counts = state.events.reduce((accumulator, event) => {
+ const key = event.source || "unknown";
+ accumulator[key] = (accumulator[key] || 0) + 1;
+ return accumulator;
+ }, {});
+
+ providerSummaryEl.innerHTML = Object.entries(counts)
+ .sort((left, right) => right[1] - left[1])
+ .map(
+ ([provider, count]) => `
+
+ ${escapeHtml(prettifyProviderName(provider))}: ${count}
+
+ `
+ )
+ .join("");
}
function renderWatchItems() {
@@ -126,13 +173,21 @@ function getWatchNameById(id) {
function renderEvents() {
const filterValue = eventFilter.value;
+ const providerValue = providerFilter.value;
const filteredEvents = state.events.filter((event) => {
if (filterValue === "ticketed") {
- return event.is_ticket_purchased;
+ if (!event.is_ticket_purchased) {
+ return false;
+ }
}
- if (filterValue === "open") {
- return !event.is_ticket_purchased;
+ if (filterValue === "open" && event.is_ticket_purchased) {
+ return false;
}
+
+ if (providerValue !== "all" && event.source !== providerValue) {
+ return false;
+ }
+
return true;
});
@@ -153,6 +208,9 @@ function renderEvents() {
${escapeHtml(getWatchNameById(event.watch_item_id))}
${escapeHtml(event.city || "ohne Stadt")}
+
+ ${escapeHtml(prettifyProviderName(event.source))}
+
${event.is_ticket_purchased ? "Ticket markiert" : "ohne Ticket"}
@@ -291,6 +349,7 @@ syncButton.addEventListener("click", async () => {
});
eventFilter.addEventListener("change", renderEvents);
+providerFilter.addEventListener("change", renderEvents);
document.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
diff --git a/backend/app/frontend/static/styles.css b/backend/app/frontend/static/styles.css
index b194144..9fcccfb 100644
--- a/backend/app/frontend/static/styles.css
+++ b/backend/app/frontend/static/styles.css
@@ -143,6 +143,12 @@ h1 {
line-height: 1.5;
}
+.provider-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -324,6 +330,33 @@ button:hover,
color: var(--danger);
}
+.provider-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 32px;
+ padding: 0 12px;
+ border-radius: 999px;
+ background: rgba(46, 39, 30, 0.08);
+ color: var(--text);
+ font-family: var(--mono);
+ font-size: 0.78rem;
+}
+
+.provider-ticketmaster {
+ background: rgba(36, 94, 63, 0.12);
+ color: var(--success);
+}
+
+.provider-bandsintown {
+ background: rgba(194, 77, 44, 0.12);
+ color: var(--primary-dark);
+}
+
+.provider-eventim {
+ background: rgba(86, 60, 153, 0.14);
+ color: #4f3790;
+}
+
.muted {
color: var(--muted);
}
diff --git a/backend/app/providers/bandsintown.py b/backend/app/providers/bandsintown.py
new file mode 100644
index 0000000..7bd3ebc
--- /dev/null
+++ b/backend/app/providers/bandsintown.py
@@ -0,0 +1,85 @@
+from datetime import datetime
+from urllib.parse import quote
+
+import requests
+
+from app.config import settings
+from app.models import RegionScope, WatchType
+
+
+class BandsintownProvider:
+ base_url = "https://rest.bandsintown.com"
+ source_name = "bandsintown"
+
+ def is_configured(self) -> bool:
+ return bool(settings.bandsintown_app_id)
+
+ def search_events(
+ self,
+ term: str,
+ watch_type: WatchType,
+ region_scope: RegionScope,
+ ) -> list[dict]:
+ if not self.is_configured():
+ return []
+
+ if watch_type != WatchType.artist:
+ return []
+
+ artist_name = quote(term, safe="")
+ url = f"{self.base_url}/artists/{artist_name}/events"
+ response = requests.get(
+ url,
+ params={"app_id": settings.bandsintown_app_id, "date": "upcoming"},
+ timeout=30,
+ )
+
+ if response.status_code == 404:
+ return []
+
+ response.raise_for_status()
+ payload = response.json()
+ if not isinstance(payload, list):
+ return []
+
+ results: list[dict] = []
+ for item in payload:
+ venue = item.get("venue") or {}
+ city = venue.get("city")
+ country = venue.get("country")
+
+ if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
+ continue
+ if region_scope == RegionScope.germany and (country or "").casefold() != "germany":
+ continue
+
+ datetime_value = item.get("datetime")
+ event_date = None
+ if datetime_value:
+ try:
+ event_date = datetime.fromisoformat(
+ datetime_value.replace("Z", "+00:00")
+ ).replace(tzinfo=None)
+ except ValueError:
+ event_date = None
+
+ offers = item.get("offers") or []
+ ticket_url = next((offer.get("url") for offer in offers if offer.get("url")), None)
+
+ results.append(
+ {
+ "external_id": str(item.get("id")),
+ "title": item.get("title") or f"{term} live",
+ "matched_term": term,
+ "venue_name": venue.get("name"),
+ "city": city,
+ "country_code": "DE" if country == "Germany" else country,
+ "event_date": event_date,
+ "ticket_url": ticket_url or item.get("url"),
+ "image_url": item.get("artist", {}).get("thumb_url"),
+ "raw_payload": item,
+ }
+ )
+
+ return results
+
diff --git a/backend/app/providers/eventim.py b/backend/app/providers/eventim.py
new file mode 100644
index 0000000..6715368
--- /dev/null
+++ b/backend/app/providers/eventim.py
@@ -0,0 +1,115 @@
+from datetime import datetime
+
+import requests
+
+from app.config import settings
+from app.models import RegionScope, WatchType
+
+
+class EventimProvider:
+ base_url = "https://public-api.eventim.com/websearch/search/api/exploration/v1/products"
+ source_name = "eventim"
+
+ def is_configured(self) -> bool:
+ return settings.eventim_enabled
+
+ def search_events(
+ self,
+ term: str,
+ watch_type: WatchType,
+ region_scope: RegionScope,
+ ) -> list[dict]:
+ if not self.is_configured():
+ return []
+
+ params = {
+ "webId": "web__eventim-de",
+ "language": "de",
+ "page": 1,
+ "sort": "DateAsc",
+ "top": 50,
+ "search_term": term,
+ }
+
+ if region_scope == RegionScope.hamburg:
+ params["city_names"] = "Hamburg"
+
+ response = requests.get(
+ self.base_url,
+ params=params,
+ headers={
+ "Accept": "application/json",
+ "User-Agent": (
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
+ ),
+ },
+ timeout=30,
+ )
+ response.raise_for_status()
+ payload = response.json()
+ products = payload.get("products") or []
+
+ results: list[dict] = []
+ normalized_term = term.casefold()
+
+ for product in products:
+ title = product.get("name") or ""
+ attractions = product.get("attractions") or []
+ attraction_names = [entry.get("name", "") for entry in attractions]
+
+ if watch_type == WatchType.artist:
+ haystack = " ".join(attraction_names + [title]).casefold()
+ if normalized_term not in haystack:
+ continue
+ elif normalized_term not in title.casefold():
+ continue
+
+ live_data = product.get("typeAttributes", {}).get("liveEntertainment", {})
+ location = live_data.get("location") or {}
+ city = location.get("city")
+
+ if region_scope == RegionScope.hamburg and (city or "").casefold() != "hamburg":
+ continue
+
+ event_date = None
+ start_date = live_data.get("startDate")
+ if start_date:
+ try:
+ event_date = datetime.fromisoformat(
+ start_date.replace("Z", "+00:00")
+ ).replace(tzinfo=None)
+ except ValueError:
+ event_date = None
+
+ url = self._build_url(product)
+ country_code = "DE" if region_scope == RegionScope.germany or city else None
+
+ results.append(
+ {
+ "external_id": str(product.get("productId") or url or title),
+ "title": title,
+ "matched_term": term,
+ "venue_name": location.get("name"),
+ "city": city,
+ "country_code": country_code,
+ "event_date": event_date,
+ "ticket_url": url,
+ "image_url": None,
+ "raw_payload": product,
+ }
+ )
+
+ return results
+
+ def _build_url(self, product: dict) -> str | None:
+ direct_link = product.get("link")
+ if direct_link:
+ return direct_link
+
+ url = product.get("url") or {}
+ domain = url.get("domain")
+ path = url.get("path")
+ if domain and path:
+ return f"{domain}{path}"
+ return None
diff --git a/backend/app/providers/registry.py b/backend/app/providers/registry.py
new file mode 100644
index 0000000..685bf9a
--- /dev/null
+++ b/backend/app/providers/registry.py
@@ -0,0 +1,12 @@
+from app.providers.bandsintown import BandsintownProvider
+from app.providers.eventim import EventimProvider
+from app.providers.ticketmaster import TicketmasterProvider
+
+
+def get_providers():
+ return [
+ TicketmasterProvider(),
+ BandsintownProvider(),
+ EventimProvider(),
+ ]
+
diff --git a/backend/app/services.py b/backend/app/services.py
index 6adccaf..fd09663 100644
--- a/backend/app/services.py
+++ b/backend/app/services.py
@@ -1,20 +1,47 @@
from datetime import datetime, timedelta
+import re
from sqlalchemy import desc, select
from sqlalchemy.orm import Session
from app.models import NotificationStatus, NotificationType, TrackedEvent, WatchItem
from app.notifications import send_email_notification
-from app.providers.ticketmaster import TicketmasterProvider
+from app.providers.registry import get_providers
from app.schemas import SyncResult
+PROVIDER_PRIORITY = {
+ "ticketmaster": 3,
+ "eventim": 2,
+ "bandsintown": 1,
+}
+
+
def list_watch_items(db: Session) -> list[WatchItem]:
return list(db.scalars(select(WatchItem).order_by(WatchItem.name)))
def list_events(db: Session) -> list[TrackedEvent]:
- return list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
+ events = list(db.scalars(select(TrackedEvent).order_by(desc(TrackedEvent.event_date))))
+ deduped: list[TrackedEvent] = []
+
+ for event in events:
+ duplicate_index = next(
+ (
+ index
+ for index, existing in enumerate(deduped)
+ if events_are_equivalent(existing, event)
+ ),
+ None,
+ )
+ if duplicate_index is None:
+ deduped.append(event)
+ continue
+
+ if is_preferred_event(event, deduped[duplicate_index]):
+ deduped[duplicate_index] = event
+
+ return deduped
def list_notifications(db: Session):
@@ -23,6 +50,72 @@ def list_notifications(db: Session):
return list(db.scalars(select(NotificationLog).order_by(desc(NotificationLog.created_at))))
+def normalize_event_text(value: str | None) -> str:
+ if not value:
+ return ""
+ cleaned = re.sub(r"[^a-z0-9]+", " ", value.casefold())
+ return " ".join(cleaned.split())
+
+
+def titles_match(left: str | None, right: str | None) -> bool:
+ normalized_left = normalize_event_text(left)
+ normalized_right = normalize_event_text(right)
+ if not normalized_left or not normalized_right:
+ return False
+ return (
+ normalized_left == normalized_right
+ or normalized_left in normalized_right
+ or normalized_right in normalized_left
+ )
+
+
+def get_event_date_key(value: datetime | None):
+ return value.date() if value else None
+
+
+def events_are_equivalent(left: TrackedEvent, right: TrackedEvent) -> bool:
+ if left.watch_item_id != right.watch_item_id:
+ return False
+
+ if get_event_date_key(left.event_date) != get_event_date_key(right.event_date):
+ return False
+
+ left_city = normalize_event_text(left.city)
+ right_city = normalize_event_text(right.city)
+ if left_city and right_city and left_city != right_city:
+ return False
+
+ title_matches = titles_match(left.title, right.title)
+ venue_matches = titles_match(left.venue_name, right.venue_name)
+
+ return title_matches or venue_matches
+
+
+def is_preferred_event(candidate: TrackedEvent, current: TrackedEvent) -> bool:
+ candidate_score = (
+ 1 if candidate.is_ticket_purchased else 0,
+ PROVIDER_PRIORITY.get(candidate.source, 0),
+ 1 if candidate.ticket_url else 0,
+ candidate.last_seen_at or datetime.min,
+ )
+ current_score = (
+ 1 if current.is_ticket_purchased else 0,
+ PROVIDER_PRIORITY.get(current.source, 0),
+ 1 if current.ticket_url else 0,
+ current.last_seen_at or datetime.min,
+ )
+ return candidate_score > current_score
+
+
+def has_equivalent_existing_event(db: Session, tracked_event: TrackedEvent) -> bool:
+ stmt = select(TrackedEvent).where(
+ TrackedEvent.watch_item_id == tracked_event.watch_item_id,
+ TrackedEvent.id != tracked_event.id,
+ )
+ existing_events = list(db.scalars(stmt))
+ return any(events_are_equivalent(tracked_event, existing) for existing in existing_events)
+
+
def upsert_event(
db: Session,
watch_item: WatchItem,
@@ -53,6 +146,7 @@ def upsert_event(
raw_payload=event_data.get("raw_payload"),
)
db.add(tracked_event)
+ db.flush()
else:
tracked_event.title = event_data["title"]
tracked_event.matched_term = event_data["matched_term"]
@@ -69,7 +163,7 @@ def upsert_event(
def run_sync(db: Session) -> SyncResult:
- provider = TicketmasterProvider()
+ providers = get_providers()
active_items = list(
db.scalars(select(WatchItem).where(WatchItem.is_active.is_(True)).order_by(WatchItem.name))
)
@@ -80,50 +174,56 @@ def run_sync(db: Session) -> SyncResult:
notifications_skipped = 0
for watch_item in active_items:
- try:
- events = provider.search_events(
- term=watch_item.name,
- watch_type=watch_item.watch_type,
- region_scope=watch_item.region_scope,
- )
- except Exception:
- db.rollback()
- continue
- for event_data in events:
- tracked_event, is_new = upsert_event(
- db=db,
- watch_item=watch_item,
- provider_name=provider.source_name,
- event_data=event_data,
- )
-
- if is_new:
- new_events += 1
- else:
- updated_events += 1
-
- if is_new and tracked_event.discovery_notified_at is None:
- status = send_email_notification(
- db=db,
- tracked_event=tracked_event,
- notification_type=NotificationType.discovery,
- subject=f"Neuer Termin fuer {watch_item.name}",
- body=(
- f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
- f"Titel: {tracked_event.title}\n"
- f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
- f"Stadt: {tracked_event.city or 'unbekannt'}\n"
- f"Datum: {tracked_event.event_date or 'unbekannt'}\n"
- f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
- ),
+ for provider in providers:
+ try:
+ events = provider.search_events(
+ term=watch_item.name,
+ watch_type=watch_item.watch_type,
+ region_scope=watch_item.region_scope,
+ )
+ except Exception:
+ db.rollback()
+ continue
+ for event_data in events:
+ tracked_event, is_new = upsert_event(
+ db=db,
+ watch_item=watch_item,
+ provider_name=provider.source_name,
+ event_data=event_data,
)
- if status == NotificationStatus.sent:
- tracked_event.discovery_notified_at = datetime.utcnow()
- notifications_sent += 1
- else:
- notifications_skipped += 1
- db.commit()
+ if is_new:
+ new_events += 1
+ else:
+ updated_events += 1
+
+ should_notify = (
+ is_new
+ and tracked_event.discovery_notified_at is None
+ and not has_equivalent_existing_event(db, tracked_event)
+ )
+ if should_notify:
+ status = send_email_notification(
+ db=db,
+ tracked_event=tracked_event,
+ notification_type=NotificationType.discovery,
+ subject=f"Neuer Termin fuer {watch_item.name}",
+ body=(
+ f"Es wurde ein neuer Termin fuer '{watch_item.name}' gefunden.\n\n"
+ f"Quelle: {tracked_event.source}\n"
+ f"Titel: {tracked_event.title}\n"
+ f"Ort: {tracked_event.venue_name or 'unbekannt'}\n"
+ f"Stadt: {tracked_event.city or 'unbekannt'}\n"
+ f"Datum: {tracked_event.event_date or 'unbekannt'}\n"
+ f"Tickets: {tracked_event.ticket_url or 'keine URL'}\n"
+ ),
+ )
+ if status == NotificationStatus.sent:
+ tracked_event.discovery_notified_at = datetime.utcnow()
+ notifications_sent += 1
+ else:
+ notifications_skipped += 1
+ db.commit()
return SyncResult(
scanned_watch_items=len(active_items),
diff --git a/docker-compose.yml b/docker-compose.yml
index 1871469..03aa307 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,6 +3,8 @@ services:
image: mariadb:11
container_name: eventlens_db
restart: unless-stopped
+ env_file:
+ - .env
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootsecret}
MYSQL_DATABASE: ${DB_NAME:-eventlens}
@@ -26,7 +28,7 @@ services:
env_file:
- .env
ports:
- - "127.0.0.1:8000:8000"
+ - "127.0.0.1:8001:8000"
volumes:
db_data:
diff --git a/scripts/fix-db-user.sh b/scripts/fix-db-user.sh
new file mode 100644
index 0000000..602e2b3
--- /dev/null
+++ b/scripts/fix-db-user.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+if [[ ! -f "${PROJECT_DIR}/.env" ]]; then
+ echo ".env wurde in ${PROJECT_DIR} nicht gefunden." >&2
+ exit 1
+fi
+
+read_env_value() {
+ local key="$1"
+ local line
+
+ line="$(grep -E "^${key}=" "${PROJECT_DIR}/.env" | tail -n 1 || true)"
+ if [[ -z "${line}" ]]; then
+ echo "Wert ${key} wurde in ${PROJECT_DIR}/.env nicht gefunden." >&2
+ exit 1
+ fi
+
+ printf '%s' "${line#*=}"
+}
+
+DB_NAME="$(read_env_value DB_NAME)"
+DB_USER="$(read_env_value DB_USER)"
+DB_PASSWORD="$(read_env_value DB_PASSWORD)"
+MYSQL_ROOT_PASSWORD="$(read_env_value MYSQL_ROOT_PASSWORD)"
+
+docker compose -f "${PROJECT_DIR}/docker-compose.yml" exec -T db \
+ mariadb -uroot "-p${MYSQL_ROOT_PASSWORD}" <