diff --git a/.gitignore b/.gitignore index 8319981..22979bd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ tiles/build/ *.mbtiles tiles/build.log tiles/.DS_Store + +# Album-Build: Zwischendateien (ZIPs + Thumbnail werden committet) +tools/album-build/dist/ diff --git a/MARKETING.md b/MARKETING.md index 4692ffe..c52125d 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -2,7 +2,7 @@ **Single Source of Truth fürs Marketing.** Vor jeder Aktion hier prüfen, danach updaten — so wird nichts doppelt gemacht, vergessen oder übersehen. Pflege: René + Claude. -_Stand: 2026-06-09_ +_Stand: 2026-06-15_ > Diese Datei = Planung & Checkliste. Für **Live-Daten** (User-Meilenstein, Kanal-Tracking) lohnt zusätzlich ein Marketing-Tab im **Admin-Bereich** — siehe „Ausbau" unten. @@ -16,6 +16,7 @@ _Stand: 2026-06-09_ | Empfehlung / Referral | 🟡 Infra da (`referral_code`) | Empfehlungs-QR + Tracking sichtbar machen | | Partner-Programm | 🟢 Infra komplett (v1265, 07.06.) | Partner einladen! Showcase `#partner`, Pro gratis, Partner-Dashboard, QR-Kontingente (Druck-PDF) mit Einzel-Code-Tracking, Dank-Mails mit Statistik, Pause-Notbremse für geleakte Codes. Onboarding: Admin → Code anlegen → Partner-Badge → Besitzer zuordnen | | Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot | +| Social / Short-Video (Reels/Shorts/TikTok) | 💡 Plan steht (15.06.) | Pilot-Clip „Platsch!" (Sommer-Timing) produzieren — Details unten unter „Song-Reel" | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | | SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) | @@ -37,10 +38,10 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · - [ ] **Verzeichnisse** — Product Hunt, progressivewebappstore.com, pwafire.org/directory, Google Business (Ebersberg). - [ ] **Landing-Page-Redesign** nach Briefing (3 Zielgruppen-Einstiege Hundebesitzer/Züchter/Welpenkäufer, Outcomes statt Features, Züchter-SaaS prominent, Datenschutz als Argument, Gründer-Story + Foto). - [ ] **Messung einbauen** — „Wie hast du von uns gehört?" im Onboarding + QR-refs pro Kanal. -- [ ] **Song-Reel** — die Album-Songs + App-Screenshots/Hundefotos → **Reel/YouTube-Video** für Social. Album ist erweiterbar (neuer Suno-Pro-Song = MP3 + Array-Zeile). +- [ ] **Song-Reel / Short-Video-Serie** — Plan ausgearbeitet, siehe „Details je Kanal → Song-Reel / Short-Video" unten. **Nächster Schritt:** Pilot-Clip „Platsch!" (Sommer-Timing jetzt ideal). ## ✅ Erledigt -- [x] **Ban-Yaro-Album** — 3 eigene Songs (Ban Yaro Blues, Ban Yaro Mobil, Amy) als Album-Modal in der WELT-Welt — 14.06., Prod v1297. Alle **Suno Pro** = kommerziell lizenziert. (Lektion: Lizenz hängt am Generierungszeitpunkt; neu generieren ≠ neu downloaden, per MD5 geprüft.) +- [x] **Ban-Yaro-Album** — **7 eigene Songs** (Ban Yaro Blues, Ban Yaro Mobil, Amy, Beim Friseur, Leckerli-Paradies, Platsch!, Bester Freund) als Album-Modal in der WELT-Welt — Prod v1300. Alle **Suno Pro** = kommerziell lizenziert (granted commercial rights, Suno nimmt 0 % Royalties). (Lektion: Lizenz hängt am Generierungszeitpunkt; neu generieren ≠ neu downloaden, per MD5 geprüft.) - [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 - [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios` - [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278 @@ -71,6 +72,42 @@ Tag recherchiert: **HID Laundry Tag 16 mm** (shopnfc, SKU RE-ICO2-16, ~1 €/Stk ### Flyer Print: A5 zweiseitig, Quelle `promotion/flyer_a5_allgemein.html` + `flyer_a5_rueckseite.html`, QR → banyaro.app. Vorderseite = alle Hundebesitzer, Rückseite stark Züchter-fokussiert. +### Song-Reel / Short-Video + +**Ziel:** Das Album ist kein Einnahme-Produkt (Streaming zahlt ~3–4 €/1000 Streams), sondern ein **Top-of-Funnel-Marketing-Motor**. Ein ohrwurmiger Hundesong ist von Natur aus teilbar → Awareness → App-Installs. **Erfolgskennzahl = Installs pro Clip, nicht Tantiemen.** + +**Lizenz/Compliance:** Social-Reels (IG/TikTok/Shorts) brauchen **keine** DDEX-KI-Flag — die gilt nur bei Streaming-Distribution (Spotify/Apple). Optional sympathischer Hinweis „selbst gemacht 🎸 (mit KI)" in der Caption. Suno-Pro-Songs sind kommerziell freigegeben. + +**Song → Clip-Konzept** (visuelle/lustige Songs zuerst short-form, Balladen long-form): + +| Song | Sub | Clip-Idee | Format | +|---|---|---|---| +| **Platsch!** | Ab ins kühle Nass | Hund springt ins Wasser, Zeitlupe beim Sprung → Drop auf den Beat. **Sommer-Timing = JETZT.** | Short (Pilot) | +| **Leckerli-Paradies** | Voller Napf, volles Glück | Hund vor vollem Napf / im Tierladen, große Augen, Tierladen-Boogie. Sehr relatable. | Short | +| **Beim Friseur** | Halbes Fell, Energie pur | Grooming Vorher/Nachher, lustiger Schnitt. | Short | +| **Ban Yaro Mobil** | Erste Fahrt im Anhänger | Hund im Anhänger/Auto, Ohren im Fahrtwind. | Short | +| **Bester Freund** | Du und ich | Emotionale Foto-Montage Mensch-Hund-Bindung. | YouTube-Lyric + Story-Reel | +| **Amy** | Eine Liebesromanze | Zwei-Hunde-Story. | YouTube-Lyric | +| **Ban Yaro Blues** | Die Hymne | Marken-Hymne = Brand-/Pinned-Video. | YouTube + Pinned | + +**Clip-Bauplan (Template, 15–30 s, 9:16):** +1. **0–1,5 s Hook:** stärkster visueller Moment + stärkste Textzeile als Caption-Pop. +2. **1,5–~20 s:** 3–5 schnelle Hunde-Clips, **auf den Beat** geschnitten. +3. **letzte ~3 s CTA:** App-Logo + „Gratis-App für Hundemenschen → banyaro.app" + Pfoten-Badge. +4. **durchgehend:** animierte **Lyric-Untertitel** (Sound-off-Tauglichkeit — die meisten scrollen stumm!). + +**Footage:** Bans & Yaros eigenes Material (Renés Hunde) + App-Screenshots wo passend. Schnitt z. B. in **CapCut** (kostenlos, Beat-Marker + Auto-Captions). + +**Distribution & Tracking:** +- Kanäle: **Instagram Reels · TikTok · YouTube Shorts** (gleicher Clip) + Facebook (lokale EBE-Gruppen). +- **Per-Kanal-ref-Link** (on-screen + in Bio/Pinned): `banyaro.app/?ref=reel-ig` · `?ref=reel-tt` · `?ref=reel-yt` → zählbar (siehe „Messung"). +- Hashtags: #hundeliebe #dogsofinstagram #hundsetiktok + lokal #ebersberg. +- Balladen (Bester Freund, Amy, Blues) zusätzlich als **volles Lyric-Video auf YouTube** = evergreen + SEO. + +**Cadence:** Lead-Clips (Platsch!, Leckerli, Friseur, Mobil) in **einer Batch-Session** produzieren, dann **2/Woche** posten über ~2–3 Wochen. Erst der Pilot, dann Batch. + +**Erster Schritt:** **1 Pilot-Clip „Platsch!"** (Saison) als Test posten → Reaktion messen, bevor die Batch läuft. + ## 🚀 Ausbau: Live-Tool im Admin-Bereich (optional) Diese Datei deckt Planung/Checkliste ab (Claude pflegt sie). Der **Admin-Bereich** lohnt sich für die Teile mit echten Daten: - **User-Meilenstein-Anzeige** (aktive User) → blendet automatisch den „Outreach Runde 3"-Hinweis ein, sobald ~50 erreicht. diff --git a/Makefile b/Makefile index 7fc461a..1906455 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ TAR_EXCLUDE := --exclude='.git' \ --exclude='./.DS_Store' .PHONY: help deploy deploy-clean staging release sync push restart build stop status \ - logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy + logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy album # ---------------------------------------------------------- # SSH-Prüfung — Abhängigkeit aller DS-Befehle @@ -343,6 +343,13 @@ bump: sed -i.bak -E "s/\?v=[0-9]+/?v=$$NEW/g" backend/static/landing.html && rm -f backend/static/landing.html.bak; \ echo " ✓ APP_VER $$CUR → $$NEW (VERSION, sw.js, app.js, index.html, landing.html aktualisiert)" +# ---------------------------------------------------------- +# ALBUM — die zwei Download-ZIPs (DE+EN) neu bauen (Cover, ID3-Tags, Liner Notes) +# Nur nötig, wenn sich Songs/Cover/Beschreibung ändern. Braucht ImageMagick + ffmpeg. +# ---------------------------------------------------------- +album: + @bash tools/album-build/build.sh + # ---------------------------------------------------------- # TEST — Smoke-Tests gegen isolierte Test-DB (kein Docker, kein DS) # ---------------------------------------------------------- diff --git a/VERSION b/VERSION index 16b7561..61a4199 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1300 \ No newline at end of file +1303 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index a26bd07..8fad182 100644 --- a/backend/database.py +++ b/backend/database.py @@ -522,6 +522,7 @@ def _migrate(conn_factory): # Forum Sprint 11: erweiterte Thread-Felder ("forum_threads", "foto_urls", "TEXT"), ("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"), + ("forum_threads", "pin_scope", "TEXT NOT NULL DEFAULT 'global'"), ("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), diff --git a/backend/main.py b/backend/main.py index 21a5069..13da5cf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -375,6 +375,8 @@ app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") app.mount("/sounds", StaticFiles(directory=f"{STATIC_DIR}/sounds"), name="sounds") # Yaro-Navi-Sounds +os.makedirs(f"{STATIC_DIR}/downloads", exist_ok=True) # Album-ZIPs (vom Build-Skript erzeugt) +app.mount("/downloads", StaticFiles(directory=f"{STATIC_DIR}/downloads"), name="downloads") # Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image. # WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 58d03b4..f8943ac 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -42,6 +42,7 @@ class PostCreate(BaseModel): class ThreadPatch(BaseModel): is_pinned: Optional[int] = None is_locked: Optional[int] = None + pin_scope: Optional[str] = None # 'global' (überall oben) | 'kategorie' (nur im Thema oben) class ThreadUpdate(BaseModel): titel: Optional[str] = Field(None, max_length=200) @@ -71,6 +72,15 @@ class ResolveReport(BaseModel): resolved: int = 1 +def _can_moderate(user) -> bool: + """Admin ODER Moderator dürfen moderieren (pin/lock/löschen). + Wichtig: Admins haben nicht zwingend das is_moderator-Flag gesetzt — + daher zusätzlich die Rolle prüfen (analog auth.require_moderator).""" + if not user: + return False + return user.get('rolle') in ('admin', 'moderator') or bool(user.get('is_moderator')) + + # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ @@ -126,12 +136,13 @@ async def list_threads( user=Depends(get_current_user_optional), ): uid = user['id'] if user else None + has_cat = bool(kategorie and kategorie != 'alle') with db() as conn: q = """ SELECT t.id, t.kategorie, t.titel, SUBSTR(t.text, 1, 120) AS text_preview, t.antworten, t.likes, t.views, - t.is_pinned, t.is_locked, t.foto_urls, + t.is_pinned, t.pin_scope, t.is_locked, t.foto_urls, t.created_at, t.user_id, u.name AS autor_name, u.founder_number AS autor_founder_number FROM forum_threads t @@ -139,13 +150,18 @@ async def list_threads( WHERE t.is_deleted = 0 """ params = [] - if kategorie and kategorie != 'alle': + if has_cat: q += " AND t.kategorie = ?" params.append(kategorie) if search: q += " AND (t.titel LIKE ? OR t.text LIKE ?)" params.extend([f'%{search}%', f'%{search}%']) - q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?" + # Kategorie-Ansicht: globale UND Themen-Pins steigen nach oben. + # "Alle"-Ansicht: nur globale Pins oben — Themen-Pins bleiben in ihrem Thema. + if has_cat: + q += " ORDER BY t.is_pinned DESC, t.created_at DESC LIMIT ? OFFSET ?" + else: + q += " ORDER BY (t.is_pinned = 1 AND t.pin_scope = 'global') DESC, t.created_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) rows = conn.execute(q, params).fetchall() @@ -323,7 +339,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)): ).fetchone() if not thread: raise HTTPException(404, "Thread nicht gefunden.") - if thread['user_id'] != user['id'] and not user.get('is_moderator'): + if thread['user_id'] != user['id'] and not _can_moderate(user): raise HTTPException(403, "Keine Berechtigung.") conn.execute( "UPDATE forum_threads SET is_deleted = 1 WHERE id = ?", (thread_id,) @@ -335,7 +351,7 @@ async def delete_thread(thread_id: int, user=Depends(get_current_user)): # ------------------------------------------------------------------ @router.patch("/threads/{thread_id}") async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_current_user)): - if not user.get('is_moderator'): + if not _can_moderate(user): raise HTTPException(403, "Nur Moderatoren können Threads bearbeiten.") with db() as conn: thread = conn.execute( @@ -345,6 +361,8 @@ async def patch_thread(thread_id: int, data: ThreadPatch, user=Depends(get_curre raise HTTPException(404, "Thread nicht gefunden.") updates = data.model_dump(exclude_none=True) + if 'pin_scope' in updates and updates['pin_scope'] not in ('global', 'kategorie'): + raise HTTPException(400, "Ungültiger pin_scope (erlaubt: 'global', 'kategorie').") if updates: cols = ', '.join(f"{k} = ?" for k in updates) conn.execute( @@ -476,7 +494,7 @@ async def delete_post(post_id: int, user=Depends(get_current_user)): ).fetchone() if not post: raise HTTPException(404, "Beitrag nicht gefunden.") - if post['user_id'] != user['id'] and not user.get('is_moderator'): + if post['user_id'] != user['id'] and not _can_moderate(user): raise HTTPException(403, "Keine Berechtigung.") conn.execute( "UPDATE forum_posts SET is_deleted = 1 WHERE id = ?", (post_id,) @@ -504,7 +522,7 @@ async def upload_thread_foto( ).fetchone() if not thread: raise HTTPException(404, "Thread nicht gefunden.") - if thread['user_id'] != user['id'] and not user.get('is_moderator'): + if thread['user_id'] != user['id'] and not _can_moderate(user): raise HTTPException(403, "Keine Berechtigung.") existing = _parse_foto_urls(thread['foto_urls']) @@ -537,7 +555,7 @@ async def upload_post_foto( ).fetchone() if not post: raise HTTPException(404, "Beitrag nicht gefunden.") - if post['user_id'] != user['id'] and not user.get('is_moderator'): + if post['user_id'] != user['id'] and not _can_moderate(user): raise HTTPException(403, "Keine Berechtigung.") existing = _parse_foto_urls(post['foto_urls']) @@ -642,7 +660,7 @@ async def report_content(data: ReportBody, user=Depends(get_current_user)): # ------------------------------------------------------------------ @router.get("/reports") async def list_reports(user=Depends(get_current_user)): - if not user.get('is_moderator'): + if not _can_moderate(user): raise HTTPException(403, "Nur Moderatoren.") with db() as conn: rows = conn.execute( @@ -660,7 +678,7 @@ async def list_reports(user=Depends(get_current_user)): # ------------------------------------------------------------------ @router.patch("/reports/{report_id}") async def resolve_report(report_id: int, data: ResolveReport, user=Depends(get_current_user)): - if not user.get('is_moderator'): + if not _can_moderate(user): raise HTTPException(403, "Nur Moderatoren.") with db() as conn: conn.execute( diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 91dfd2d..f3e34e7 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8307,6 +8307,10 @@ svg.empty-state-icon { .album-title { font-size: var(--text-base); font-weight: 700; color: var(--c-text); } .album-subtitle { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } .album-close { background: none; border: none; font-size: 1.5rem; line-height: 1; color: var(--c-text-muted); cursor: pointer; padding: 0 4px; } +.album-head-actions { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; } +.album-lang { display: inline-flex; border: 1px solid var(--c-border); border-radius: var(--radius-full); overflow: hidden; background: var(--c-surface-2); } +.album-lang-btn { background: none; border: none; cursor: pointer; padding: 4px 10px; font-size: var(--text-xs); font-weight: 700; color: var(--c-text-muted); line-height: 1.4; transition: background .15s, color .15s; } +.album-lang-btn.is-active { background: var(--c-primary); color: #fff; } .album-list { display: flex; flex-direction: column; gap: var(--space-2); } .album-song { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--c-surface-2); cursor: pointer; transition: background .15s; } .album-song:active { background: var(--c-border); } diff --git a/backend/static/downloads/ban-yaro-album-de.zip b/backend/static/downloads/ban-yaro-album-de.zip new file mode 100644 index 0000000..6cc9e85 Binary files /dev/null and b/backend/static/downloads/ban-yaro-album-de.zip differ diff --git a/backend/static/downloads/ban-yaro-album-en.zip b/backend/static/downloads/ban-yaro-album-en.zip new file mode 100644 index 0000000..d7e52e4 Binary files /dev/null and b/backend/static/downloads/ban-yaro-album-en.zip differ diff --git a/backend/static/img/banyaro/album-thumb.jpg b/backend/static/img/banyaro/album-thumb.jpg new file mode 100644 index 0000000..bbf38d8 Binary files /dev/null and b/backend/static/img/banyaro/album-thumb.jpg differ diff --git a/backend/static/index.html b/backend/static/index.html index eaec6fc..5a9d95f 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -624,12 +624,12 @@ - - - - - - + + + + + + @@ -639,7 +639,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1a76c84..4cbbfb9 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1300'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1303'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/forum.js b/backend/static/js/pages/forum.js index 003160d..c9664b6 100644 --- a/backend/static/js/pages/forum.js +++ b/backend/static/js/pages/forum.js @@ -82,7 +82,8 @@ function _fmtDate(iso) { // RENDER — Grundstruktur // ---------------------------------------------------------- function _render() { - const isMod = !!_appState.user?.is_moderator; + const _u = _appState.user; + const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator)); _container.innerHTML = `
@@ -438,7 +439,7 @@ function _fmtDate(iso) { const preview = t.text_preview ? UI.escape(t.text_preview.slice(0, 120)) + (t.text_preview.length >= 120 ? '…' : '') : ''; - const pinBadge = t.is_pinned ? `${UI.icon('push-pin')}` : ''; + const pinBadge = t.is_pinned ? `${UI.icon('push-pin')}` : ''; const lockBadge = t.is_locked ? `${UI.icon('lock')}` : ''; const fotoHtml = t.foto_preview ? /\.(mp4|mov|webm|m4v|avi)$/i.test(t.foto_preview) @@ -515,14 +516,25 @@ function _fmtDate(iso) { } const uid = _appState.user?.id; - const isMod = !!_appState.user?.is_moderator; + const _u = _appState.user; + const isMod = !!(_u && (_u.rolle === 'admin' || _u.rolle === 'moderator' || _u.is_moderator)); const isOwn = uid && uid === thread.user_id; + const pinControls = thread.is_pinned + ? ` + ${UI.icon('push-pin')} Angepinnt${thread.pin_scope === 'kategorie' ? ` (Thema „${UI.escape(thread.kategorie)}")` : ' (global)'} + + ` + : ` + `; + const modToolbar = (isMod) ? `
- + ${pinControls} @@ -677,14 +689,20 @@ function _fmtDate(iso) { }); // Moderator: pin/lock/delete - document.querySelector('.forum-mod-pin')?.addEventListener('click', async () => { + const _applyPin = async (payload) => { try { - await API.forum.patchThread(thread.id, { is_pinned: thread.is_pinned ? 0 : 1 }); + await API.forum.patchThread(thread.id, payload); UI.toast.success('Gespeichert.'); UI.modal.close(); _loadThreads(true); } catch (err) { UI.toast.error(err.message); } - }); + }; + document.querySelector('.forum-mod-pin-global')?.addEventListener('click', + () => _applyPin({ is_pinned: 1, pin_scope: 'global' })); + document.querySelector('.forum-mod-pin-cat')?.addEventListener('click', + () => _applyPin({ is_pinned: 1, pin_scope: 'kategorie' })); + document.querySelector('.forum-mod-unpin')?.addEventListener('click', + () => _applyPin({ is_pinned: 0 })); document.querySelector('.forum-mod-lock')?.addEventListener('click', async () => { try { diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 4e21eae..d31203b 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -877,6 +877,35 @@ window.Page_settings = (() => {
+ +
+
Ban Yaro — das Album
+
+ Ban Yaro — Album-Cover +
+
7 Songs zum Behalten 🎸
+
+ Das ganze Album als Download — auf Deutsch oder Englisch. + Behalten & teilen ausdrücklich erwünscht. +
+
+
+
+ + ${UI.icon('download-simple')} Deutsch · 33 MB + + + ${UI.icon('download-simple')} English · 36 MB + +
+
+
diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index c3bf861..17946de 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1941,7 +1941,11 @@ window.Worlds = (() => { // zentral in index.html → übersteht Re-Renders & Welt-Wechsel. const _anthem = (() => { const KEY = 'by_anthem_heard'; - const SONGS = [ + const LANG_KEY = 'by_album_lang'; + // EN-Album erst sichtbar, wenn die 7 *-en.mp3 in static/sounds/ liegen. + // Aktivieren: Dateien ablegen → EN_READY = true → make bump → deploy. + const EN_READY = true; // 2026-06-16: 7 *-en.mp3 liegen in static/sounds/ + const SONGS_DE = [ { title: 'Ban Yaro Blues', sub: 'Die Hymne', file: '/sounds/ban-yaro-blues.mp3?v=2' }, { title: 'Ban Yaro Mobil', sub: 'Erste Fahrt im Anhänger', file: '/sounds/ban-yaro-mobil.mp3' }, { title: 'Amy', sub: 'Eine Liebesromanze', file: '/sounds/amy.mp3' }, @@ -1950,6 +1954,19 @@ window.Worlds = (() => { { title: 'Platsch!', sub: 'Ab ins kühle Nass', file: '/sounds/platsch.mp3' }, { title: 'Bester Freund', sub: 'Du und ich', file: '/sounds/bester-freund.mp3' }, ]; + const SONGS_EN = [ + { title: 'Ban Yaro Blues', sub: 'The anthem', file: '/sounds/ban-yaro-blues-en.mp3' }, + { title: 'Ban Yaro Mobile', sub: 'First ride in the trailer', file: '/sounds/ban-yaro-mobil-en.mp3' }, + { title: 'Amy', sub: 'A love duet', file: '/sounds/amy-en.mp3' }, + { title: "At the Groomer's", sub: 'Half the fur, all the energy', file: '/sounds/at-the-groomers-en.mp3' }, + { title: 'Treat Paradise', sub: 'Full bowl, full heart', file: '/sounds/treat-paradise-en.mp3' }, + { title: 'Splash!', sub: 'Into the cool water', file: '/sounds/splash-en.mp3' }, + { title: 'Best Friend', sub: 'You and me', file: '/sounds/best-friend-en.mp3' }, + ]; + let _lang = (() => { + try { return (EN_READY && localStorage.getItem(LANG_KEY) === 'en') ? 'en' : 'de'; } catch (_) { return 'de'; } + })(); + const _songs = () => (_lang === 'en' && EN_READY) ? SONGS_EN : SONGS_DE; let _bound = false, _curIdx = -1; const _audio = () => document.getElementById('anthem-audio'); // Entdeckt? Server-Flag (geräteübergreifend, deploy-fest) ODER lokal (sofort/offline). @@ -1988,17 +2005,18 @@ window.Worlds = (() => { a.addEventListener('play', _sync); a.addEventListener('pause', _sync); a.addEventListener('ended', () => { // automatisch zum nächsten Song - if (_curIdx >= 0 && _curIdx < SONGS.length - 1) _play(_curIdx + 1); + if (_curIdx >= 0 && _curIdx < _songs().length - 1) _play(_curIdx + 1); else { _curIdx = -1; _sync(); } }); } function _play(i) { const a = _audio(); - if (!a || !SONGS[i]) return; + const songs = _songs(); + if (!a || !songs[i]) return; if (i === _curIdx && !a.paused) { a.pause(); return; } // aktiven Song pausieren _curIdx = i; - a.src = SONGS[i].file; + a.src = songs[i].file; a.play().catch(() => {}); _markHeard(); _sync(); @@ -2006,39 +2024,67 @@ window.Worlds = (() => { function _closeAlbum() { document.getElementById('album-modal')?.remove(); } + // Sprache wechseln: aktuelle Wiedergabe stoppen (andere Datei) und Liste neu zeichnen. + function _setLang(l) { + if (l === _lang || !EN_READY) return; + _lang = l; + try { localStorage.setItem(LANG_KEY, l); } catch (_) {} + const a = _audio(); if (a) a.pause(); + _curIdx = -1; + _fillAlbum(); + _sync(); + } + + // Inhalt des Sheets (neu) rendern + innere Controls binden — auch bei Sprachwechsel. + function _fillAlbum() { + const sheet = document.querySelector('#album-modal .album-sheet'); + if (!sheet) return; + const songs = _songs(); + const en = _lang === 'en'; + sheet.innerHTML = ` +
+
+
${en ? 'Ban Yaro — The Album' : 'Ban Yaro — das Album'}
+
${songs.length} ${en ? 'songs · homemade' : 'Songs · selbst gemacht'} 🎸
+
+
+ ${EN_READY ? ` +
+ + +
` : ''} + +
+
+
+ ${songs.map((s, i) => ` +
+ + + ${_esc(s.title)} + ${_esc(s.sub)} + +
`).join('')} +
`; + sheet.querySelector('.album-close').addEventListener('click', _closeAlbum); + sheet.querySelectorAll('.album-lang-btn').forEach(b => + b.addEventListener('click', () => _setLang(b.dataset.lang))); + sheet.querySelectorAll('.album-song').forEach(row => { + const i = parseInt(row.dataset.i, 10); + row.addEventListener('click', () => _play(i)); + row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _play(i); } }); + }); + } + function openAlbum() { _markHeard(); if (document.getElementById('album-modal')) return; const ov = document.createElement('div'); ov.id = 'album-modal'; - ov.innerHTML = ` -
-
-
-
Ban Yaro — das Album
-
${SONGS.length} Songs · selbst gemacht 🎸
-
- -
-
- ${SONGS.map((s, i) => ` -
- - - ${_esc(s.title)} - ${_esc(s.sub)} - -
`).join('')} -
-
`; + ov.innerHTML = `
`; document.body.appendChild(ov); ov.addEventListener('click', e => { if (e.target === ov) _closeAlbum(); }); - ov.querySelector('.album-close').addEventListener('click', _closeAlbum); - ov.querySelectorAll('.album-song').forEach(row => { - const i = parseInt(row.dataset.i, 10); - row.addEventListener('click', () => _play(i)); - row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _play(i); } }); - }); + _fillAlbum(); _sync(); } @@ -2061,7 +2107,7 @@ window.Worlds = (() => { updateButton(); } - return { heard, toggle: openAlbum, updateButton, initWelt, count: SONGS.length }; + return { heard, toggle: openAlbum, updateButton, initWelt, count: SONGS_DE.length }; })(); function _renderWelt() { diff --git a/backend/static/landing.html b/backend/static/landing.html index e92f60f..f190627 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sounds/amy-en.mp3 b/backend/static/sounds/amy-en.mp3 new file mode 100644 index 0000000..2d42126 Binary files /dev/null and b/backend/static/sounds/amy-en.mp3 differ diff --git a/backend/static/sounds/at-the-groomers-en.mp3 b/backend/static/sounds/at-the-groomers-en.mp3 new file mode 100644 index 0000000..0d1fe2f Binary files /dev/null and b/backend/static/sounds/at-the-groomers-en.mp3 differ diff --git a/backend/static/sounds/ban-yaro-blues-en.mp3 b/backend/static/sounds/ban-yaro-blues-en.mp3 new file mode 100644 index 0000000..28811ca Binary files /dev/null and b/backend/static/sounds/ban-yaro-blues-en.mp3 differ diff --git a/backend/static/sounds/ban-yaro-mobil-en.mp3 b/backend/static/sounds/ban-yaro-mobil-en.mp3 new file mode 100644 index 0000000..3f4fc3f Binary files /dev/null and b/backend/static/sounds/ban-yaro-mobil-en.mp3 differ diff --git a/backend/static/sounds/best-friend-en.mp3 b/backend/static/sounds/best-friend-en.mp3 new file mode 100644 index 0000000..314e45a Binary files /dev/null and b/backend/static/sounds/best-friend-en.mp3 differ diff --git a/backend/static/sounds/splash-en.mp3 b/backend/static/sounds/splash-en.mp3 new file mode 100644 index 0000000..e732615 Binary files /dev/null and b/backend/static/sounds/splash-en.mp3 differ diff --git a/backend/static/sounds/treat-paradise-en.mp3 b/backend/static/sounds/treat-paradise-en.mp3 new file mode 100644 index 0000000..c08deaa Binary files /dev/null and b/backend/static/sounds/treat-paradise-en.mp3 differ diff --git a/backend/static/sw.js b/backend/static/sw.js index 3eca1b4..30f9606 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1300'; +const VER = '1303'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/promotion/ban-yaro-blues/platsch-reel-shotlist.md b/promotion/ban-yaro-blues/platsch-reel-shotlist.md new file mode 100644 index 0000000..2b5f50f --- /dev/null +++ b/promotion/ban-yaro-blues/platsch-reel-shotlist.md @@ -0,0 +1,80 @@ +# Platsch! — Reel-Shotlist (Pilot-Clip) + +**Zweck:** Erster Social-Pilot der Song-Reel-Serie (siehe `MARKETING.md` → „Song-Reel / Short-Video"). +**Ziel-Kennzahl:** App-Installs pro Clip (nicht Tantiemen) → ref-Link unten. +**Song:** `Platsch Nassrein.mp3` / live als `/sounds/platsch.mp3`. Funk-Blues, ~96 BPM, A-Moll, bass-getrieben, sommerlich. +**Format:** vertikal 9:16, ~16–20 s, Ton **kommerziell frei** (Suno Pro). +**Saison:** Sommer = JETZT ideal — zeitnah raus. + +--- + +## Audio-Segment + +Nicht den ganzen Song — nur den **Refrain** als Clip-Audio (sofortiger Hook): + +> Platsch! — und ich bin drin, +> Platsch! — kein Halten mehr. +> Schütteln, lachen, nochmal rein — +> Wasser, ich komm rein! + +Danach 1–2 s Bass-Groove als Auslauf für die Endcard. +**Beat-Raster:** 96 BPM → 1 Beat = 0,625 s, 1 Takt = 2,5 s. In CapCut **Beat-Marker** setzen; die beiden **„Platsch!"** sind die wichtigsten Sync-Punkte (Wasser-Aufprall genau aufs Wort). + +--- + +## Teil A — Was filmen (Roh-Shots mit Ban & Yaro) + +In **Slow-Mo (120/240 fps)** filmen, damit Spritzer episch werden. Möglichst direkt **vertikal** oder mit Rand. + +| # | Shot | Hinweis | +|---|---|---| +| A1 | **Sprung ins Wasser**, Seitenprofil | DER Schlüssel-Shot, Slow-Mo, großer Spritzer | +| A2 | Auftauchen / Paddeln, Nahaufnahme Gesicht | glückliche Augen | +| A3 | Rennen durchs flache Wasser **auf die Kamera zu** | niedrige Perspektive, Spray | +| A4 | **Schüttel-Shot**, Slow-Mo, Gegenlicht | Tropfen fliegen radial — sehr fotogen | +| A5 | Pfote schlägt aufs Wasser / Sprung in Pfütze | Detail, anderer Winkel | +| A6 | Zufrieden, tropfnass, **Blick in die Kamera** | ruhiger Schluss-Shot | +| B | Schnittbilder | Wasserspiegelung, Ufer, Sommerstimmung | + +--- + +## Teil B — Schnitt-Timeline (auf den Beat) + +| Clip-Zeit | Audio-Cue | Shot | On-Screen-Text | Schnitt | +|---|---|---|---|---| +| 0,0–1,3 s | **„Platsch!"** (1) | A1 Sprung, Aufprall **genau aufs Wort** | **„PLATSCH!"** groß, zentriert | 0,2 s Freeze auf Impact-Frame | +| 1,3–2,6 s | „und ich bin drin," | A2 Auftauchen/Paddeln | Lyric-Untertitel unten | Cut auf Beat | +| 2,6–3,9 s | **„Platsch!"** (2) | A5 Pfote/Pfütze, anderer Winkel | **„PLATSCH!"** Pop | Cut aufs Wort | +| 3,9–5,2 s | „kein Halten mehr." | A3 Rennen auf Kamera zu | Untertitel | Beat-Cut | +| 5,2–7,0 s | „Schütteln, lachen, nochmal rein —" | A4 Schüttel-Slow-Mo, Gegenlicht | „Schütteln, lachen…" | Slow-Mo rein auf „Schütteln" | +| 7,0–9,0 s | „Wasser, ich komm rein!" | A1 Sprung **nochmal** (Thema „nochmal rein") | Untertitel | Beat-Cut | +| 9,0–11 s | Bass-Groove | Mikro-Montage: 3× ~0,5 s (Plansch/Grinsen/Schütteln) | — | schnelle Beat-Cuts | +| 11–13 s | „Platsch…" / Bass-Fade | A6 zufrieden, Blick in Kamera | — | ruhiger Ausklang | +| 13–16 s | Bass-Fade | **Endcard** (Logo-BG oder über letztem Frame) | App-Logo + „Ban Yaro — Gratis-App für Hundemenschen" + **banyaro.app** + 🐾 | sanfter Fade-in | + +--- + +## Untertitel & Sound-off + +Die meisten scrollen **stumm** → animierte Lyric-Untertitel sind Pflicht (CapCut Auto-Captions, dann je Zeile timen). „Platsch!" größer/farbig als Akzent. + +## CTA & Tracking + +- Endcard-Link & Bio/Pinned: **`banyaro.app/?ref=reel-ig`** (bzw. `-tt`, `-yt` je Kanal) — zählbar via „Messung" in `MARKETING.md`. +- Kanäle: Instagram Reels · TikTok · YouTube Shorts (gleicher Clip) + lokale FB-Gruppen (EBE). + +## Caption-Vorschlag + +> Wenn's heiß ist, gibt's für Ban Yaro nur eine Lösung 💦 **Platsch!** 🐾 +> Song aus unserem selbstgemachten Album 🎸 +> Mehr Hundeliebe (Gratis-App) → banyaro.app +> #hundeliebe #dogsofinstagram #hundevideo #sommer #ebersberg + +## Tools / Workflow + +1. Roh-Shots A1–A6 sammeln/filmen (Slow-Mo!). +2. CapCut: Audio rein → Beat-Marker → Shots auf die Marker ziehen. +3. Auto-Captions → Lyrics anpassen, „Platsch!" betonen. +4. Endcard + ref-Link. +5. Export 9:16, 1080p → IG/TikTok/Shorts + FB. +6. Reaktion messen, **dann** Batch (Leckerli-Paradies, Beim Friseur, Ban Yaro Mobil). diff --git a/tests/test_forum_pinning.py b/tests/test_forum_pinning.py new file mode 100644 index 0000000..d5c45f5 --- /dev/null +++ b/tests/test_forum_pinning.py @@ -0,0 +1,102 @@ +"""Forum-Anpinnen: Berechtigung (Admin/Moderator) + Scope-Sortierung. + +Deckt zwei neue Verhalten ab: +1. Admins OHNE is_moderator-Flag dürfen anpinnen (Rolle zählt, nicht nur Flag). +2. pin_scope='global' hält oben in JEDER Ansicht, pin_scope='kategorie' nur in + der gefilterten Kategorie (nicht in der "Alle"-Liste). + +Hinweise zu den Fixtures/Route-Constraints: +- `admin`-Fixture setzt rolle='admin' direkt in der DB, OHNE is_moderator. +- forum.create_thread hat 30s-Cooldown + 5-Threads/Stunde — daher gestaffelte + client_time. Texte sind eindeutig (token_hex) gegen den Duplikat-Check. +- Kategorie 'tauschboerse' isoliert die Sortier-Tests von anderen Suiten; + Assertions vergleichen nur die RELATIVE Reihenfolge der eigenen Thread-IDs. +""" + +from __future__ import annotations + +import secrets + + +def _mk_thread(client, headers, kategorie, titel, client_time): + r = client.post( + "/api/forum/threads", + headers=headers, + json={ + "kategorie": kategorie, + "titel": titel, + "text": f"{titel}: Hallo zusammen, ein Testbeitrag {secrets.token_hex(8)}.", + "client_time": client_time, + }, + ) + assert r.status_code == 201, f"create_thread failed: {r.status_code} {r.text}" + return r.json() + + +class TestForumPinPermission: + def test_admin_without_moderator_flag_can_pin(self, client, admin): + """rolle='admin' ohne is_moderator darf anpinnen (Kernursache-Fix).""" + t = _mk_thread(client, admin["headers"], "tauschboerse", "Admin Pin", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=admin["headers"], + json={"is_pinned": 1, "pin_scope": "global"}, + ) + assert r.status_code == 200, f"{r.status_code} {r.text}" + body = r.json() + assert body["is_pinned"] == 1 + assert body["pin_scope"] == "global" + + def test_normal_user_cannot_pin(self, client, user): + """Normaler User → 403, Anpinnen bleibt Admin/Mod vorbehalten.""" + t = _mk_thread(client, user["headers"], "tauschboerse", "User Pin", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=user["headers"], + json={"is_pinned": 1, "pin_scope": "global"}, + ) + assert r.status_code == 403 + + def test_invalid_pin_scope_rejected(self, client, admin): + """Ungültiger pin_scope → 400.""" + t = _mk_thread(client, admin["headers"], "tauschboerse", "Bad Scope", "2026-06-18T08:00:00") + r = client.patch( + f"/api/forum/threads/{t['id']}", + headers=admin["headers"], + json={"is_pinned": 1, "pin_scope": "bogus"}, + ) + assert r.status_code == 400 + + +class TestForumPinScopeSorting: + def test_scope_controls_where_thread_floats(self, client, admin): + h = admin["headers"] + t1 = _mk_thread(client, h, "tauschboerse", "Erster", "2026-06-18T09:00:00") + t2 = _mk_thread(client, h, "tauschboerse", "Zweiter", "2026-06-18T09:20:00") + t3 = _mk_thread(client, h, "tauschboerse", "Dritter", "2026-06-18T09:40:00") + ids = {t1["id"], t2["id"], t3["id"]} + + def order(kategorie=None): + url = "/api/forum/threads?limit=200" + if kategorie: + url += f"&kategorie={kategorie}" + r = client.get(url, headers=h) + assert r.status_code == 200 + return [x["id"] for x in r.json() if x["id"] in ids] + + # Ohne Pin: neueste zuerst. + assert order() == [t3["id"], t2["id"], t1["id"]] + + # Ältesten Thread GLOBAL anpinnen → oben in "Alle" UND in der Kategorie. + r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h, + json={"is_pinned": 1, "pin_scope": "global"}) + assert r.status_code == 200 + assert order()[0] == t1["id"] + assert order("tauschboerse")[0] == t1["id"] + + # Auf Themen-Pin umstellen → NUR in der Kategorie oben, in "Alle" wieder nach Datum. + r = client.patch(f"/api/forum/threads/{t1['id']}", headers=h, + json={"pin_scope": "kategorie"}) + assert r.status_code == 200 + assert order() == [t3["id"], t2["id"], t1["id"]] # "Alle": Themen-Pin zählt nicht + assert order("tauschboerse")[0] == t1["id"] # Kategorie: oben diff --git a/tools/album-build/build.sh b/tools/album-build/build.sh new file mode 100755 index 0000000..ae94723 --- /dev/null +++ b/tools/album-build/build.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# Baut die zwei Album-Downloads (DE + EN) reproduzierbar: +# Cover (Foto quadr. + Wortmarke) → ID3-Tags + eingebettetes Cover → ZIP mit Liner Notes. +# Quelle: die committeten MP3s in backend/static/sounds/ + ein Hi-Res-Foto. +# Ausgabe: backend/static/downloads/ban-yaro-album-{de,en}.zip + img/banyaro/album-thumb.jpg +# Benötigt: ImageMagick (magick), ffmpeg, zip. Aufruf: make album (oder bash tools/album-build/build.sh) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SND="$ROOT/backend/static/sounds" +PHOTO="$ROOT/backend/static/img/banyaro/hires/banyaro_fruehling_playdate_hires.jpg" +FONT="/System/Library/Fonts/Supplemental/Arial Bold.ttf" +BUILD="$ROOT/tools/album-build" +DIST="$BUILD/dist" +DL="$ROOT/backend/static/downloads" +IMG="$ROOT/backend/static/img/banyaro" + +rm -rf "$DIST"; mkdir -p "$DIST" "$DL" "$IMG" + +# --- 1) Cover (quadratischer Crop auf die Hunde + Verlauf unten + Wortmarke) --- +make_cover() { # $1=subtitle $2=outfile + magick "$PHOTO" -auto-orient -crop 2648x2648+551+0 +repage -resize 2000x2000 \ + \( -size 2000x950 gradient:none-'rgba(0,0,0,0.80)' \) -gravity south -compose over -composite \ + -gravity south -font "$FONT" -kerning 10 \ + -fill 'rgba(0,0,0,0.55)' -pointsize 168 -annotate +4+254 "BAN YARO" \ + -fill white -pointsize 168 -annotate +0+250 "BAN YARO" \ + -kerning 7 -fill 'rgba(255,255,255,0.92)' -pointsize 60 -annotate +0+160 "$1" \ + -quality 90 "$2" +} +make_cover "DAS ALBUM · 7 SONGS" "$DIST/cover-de.jpg" +make_cover "THE ALBUM · 7 SONGS" "$DIST/cover-en.jpg" +# Eingebettetes Albumart kleiner halten (sonst blähen sich die MP3s auf) +magick "$DIST/cover-de.jpg" -resize 800x800 -quality 85 "$DIST/art-de.jpg" +magick "$DIST/cover-en.jpg" -resize 800x800 -quality 85 "$DIST/art-en.jpg" +# Neutrales Thumbnail (ohne Text) für die Profil-Karte — der Titel steht dort im HTML +magick "$PHOTO" -auto-orient -crop 2648x2648+551+0 +repage -resize 600x600 -quality 85 "$IMG/album-thumb.jpg" + +# --- 2)+3) pro Sprache: taggen + zippen --- +# Zeilenformat: "quelldatei(ohne .mp3)|Titel|Untertitel" +DE_TRACKS=( + "ban-yaro-blues|Ban Yaro Blues|Die Hymne" + "ban-yaro-mobil|Ban Yaro Mobil|Erste Fahrt im Anhänger" + "amy|Amy|Eine Liebesromanze" + "beim-friseur|Beim Friseur|Halbes Fell, Energie pur" + "leckerli-paradies|Leckerli-Paradies|Voller Napf, volles Glück" + "platsch|Platsch!|Ab ins kühle Nass" + "bester-freund|Bester Freund|Du und ich" +) +EN_TRACKS=( + "ban-yaro-blues-en|Ban Yaro Blues|The anthem" + "ban-yaro-mobil-en|Ban Yaro Mobile|First ride in the trailer" + "amy-en|Amy|A love duet" + "at-the-groomers-en|At the Groomer's|Half the fur, all the energy" + "treat-paradise-en|Treat Paradise|Full bowl, full heart" + "splash-en|Splash!|Into the cool water" + "best-friend-en|Best Friend|You and me" +) + +zip_album() { # $1=lang $2=AlbumName(ID3, Em-Dash ok) $3=Ordner(ASCII) $4=art $5=liner shift 5; rest=tracks + local lang="$1" album="$2" fname="$3" art="$4" liner="$5"; shift 5 + local folder="$DIST/$fname"; rm -rf "$folder"; mkdir -p "$folder" + if [ "$lang" = "de" ]; then cp "$liner" "$folder/LIESMICH.txt"; else cp "$liner" "$folder/README.txt"; fi + cp "$art" "$folder/cover.jpg" + local copyr + if [ "$lang" = "de" ]; then + copyr="© 2026 Ban Yaro — Alle Rechte vorbehalten. Kommerzielle Nutzung nur nach Absprache (banyaro.app)." + else + copyr="© 2026 Ban Yaro — All rights reserved. Commercial use only by prior arrangement (banyaro.app)." + fi + local n=0 line src title sub nn + for line in "$@"; do + n=$((n+1)); nn=$(printf "%02d" "$n") + IFS='|' read -r src title sub <<<"$line" + ffmpeg -y -loglevel error -i "$SND/$src.mp3" -i "$art" \ + -map 0:a -map 1:v -c copy -id3v2_version 3 \ + -metadata title="$title" -metadata artist="Ban Yaro" \ + -metadata album="$album" -metadata album_artist="Ban Yaro" \ + -metadata track="$n/7" -metadata date="2026" -metadata genre="Blues" \ + -metadata copyright="$copyr" -metadata comment="$sub" \ + -disposition:v:0 attached_pic \ + "$folder/$nn $title.mp3" + done + ( cd "$DIST" && rm -f "$DL/ban-yaro-album-$lang.zip" && zip -r -X -q "$DL/ban-yaro-album-$lang.zip" "$fname" ) + echo " ✓ $fname → ban-yaro-album-$lang.zip ($(du -h "$DL/ban-yaro-album-$lang.zip" | cut -f1))" +} + +# LIESMICH.txt für DE, README.txt für EN +cp "$BUILD/liner-de.txt" "$DIST/_liner-de.txt" +cp "$BUILD/liner-en.txt" "$DIST/_liner-en.txt" +zip_album "de" "Ban Yaro — Das Album" "Ban Yaro - Das Album" "$DIST/art-de.jpg" "$DIST/_liner-de.txt" "${DE_TRACKS[@]}" +zip_album "en" "Ban Yaro — The Album" "Ban Yaro - The Album" "$DIST/art-en.jpg" "$DIST/_liner-en.txt" "${EN_TRACKS[@]}" + +echo "Fertig. Downloads in backend/static/downloads/, Thumbnail in img/banyaro/album-thumb.jpg" diff --git a/tools/album-build/liner-de.txt b/tools/album-build/liner-de.txt new file mode 100644 index 0000000..f4bbeba --- /dev/null +++ b/tools/album-build/liner-de.txt @@ -0,0 +1,32 @@ +================================================== + BAN YARO — DAS ALBUM + 7 Songs +================================================== + +Sieben Lieder über einen Hund namens Ban Yaro: +übers Gassigehen bei jedem Wetter, die erste Fahrt +im Anhänger, die große Liebe, den Friseur, den +vollen Napf, das kühle Wasser — und den besten +Freund, den ein Mensch haben kann. + +Selbst gemacht, mit viel Herz. Aus der Ban-Yaro-App. + +-------------------------------------------------- +TRACKLIST +-------------------------------------------------- + 1. Ban Yaro Blues Die Hymne + 2. Ban Yaro Mobil Erste Fahrt im Anhänger + 3. Amy Eine Liebesromanze + 4. Beim Friseur Halbes Fell, Energie pur + 5. Leckerli-Paradies Voller Napf, volles Glück + 6. Platsch! Ab ins kühle Nass + 7. Bester Freund Du und ich + +-------------------------------------------------- +© 2026 Ban Yaro — Musik und Texte. Alle Rechte vorbehalten. + +Privat behalten und teilen ist ausdrücklich erwünscht. +Kommerzielle Nutzung nur nach Absprache: banyaro.app + +Viel Freude beim Hören und einen schönen +Gassi-Gang! Wuff. 🐾 diff --git a/tools/album-build/liner-en.txt b/tools/album-build/liner-en.txt new file mode 100644 index 0000000..d04e18f --- /dev/null +++ b/tools/album-build/liner-en.txt @@ -0,0 +1,32 @@ +================================================== + BAN YARO — THE ALBUM + 7 Songs +================================================== + +Seven songs about a dog named Ban Yaro: +about walks in every kind of weather, the first +ride in the trailer, falling in love, the groomer, +a full bowl, cool water — and the best friend a +human could ever ask for. + +Homemade, with all our heart. From the Ban Yaro app. + +-------------------------------------------------- +TRACKLIST +-------------------------------------------------- + 1. Ban Yaro Blues The anthem + 2. Ban Yaro Mobile First ride in the trailer + 3. Amy A love duet + 4. At the Groomer's Half the fur, all the energy + 5. Treat Paradise Full bowl, full heart + 6. Splash! Into the cool water + 7. Best Friend You and me + +-------------------------------------------------- +© 2026 Ban Yaro — music and lyrics. All rights reserved. + +Keeping and sharing privately is expressly welcome. +Commercial use only by prior arrangement: banyaro.app + +Enjoy the music, and have a wonderful walk! +Woof. 🐾