diff --git a/README.md b/README.md index 8e5c826..a87987f 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ Quizify is a music quiz that uses your Spotify playlists. Guess the artist, titl - Login with Spotify - Select your own playlists - Multiple game modes: Guess artist, title, or year +- **Singleplayer and Local Multiplayer** (up to 4 players, each with their own score) - Adjustable play duration and random start position for each song - Spotify Web Playback (play songs directly in the browser) - No song repeats until all have been played - Smart search and answer evaluation (ignores bracket additions, apostrophes, etc.) - Multilingual: All texts are loaded from language files, language is set via `.env` +- Invite/Referral link system for easy sharing - Dockerfile included – ready for deployment with [Coolify](https://coolify.io/) or any Docker-compatible platform ## Requirements @@ -73,6 +75,8 @@ See `.env.example` for the required variables. - The Spotify Redirect URI must exactly match `SPOTIPY_REDIRECT_URI` in the Spotify Developer Console. - All texts are loaded from language files in the `locales` folder. Set the language via the `LANG` variable in your `.env` (e.g., `LANG=en-EN` or `LANG=de-DE`). - You can set the play duration and start position for each song in the quiz interface. +- **Local Multiplayer:** After selecting "Local Multiplayer", you can enter up to 4 player names. Each player takes turns and has their own score. +- **Referral/Invite Link:** Generate a time-limited invite link to let friends join your quiz session (each with their own Spotify account). ## License diff --git a/app.py b/app.py index 0dd4cbc..cb75c93 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,9 @@ import random from difflib import SequenceMatcher import re import json +import unicodedata +import secrets +from datetime import datetime, timedelta app = Flask(__name__) app.secret_key = os.getenv("SECRET_KEY") @@ -34,15 +37,13 @@ def get_translations(): def get_spotify_client(): token_info = session.get("token_info", None) if not token_info: - # Kein Token, redirect handled elsewhere return None - # Prüfen, ob Token abgelaufen ist sp_oauth = SpotifyOAuth( client_id=os.getenv("SPOTIPY_CLIENT_ID"), client_secret=os.getenv("SPOTIPY_CLIENT_SECRET"), redirect_uri=os.getenv("SPOTIPY_REDIRECT_URI"), scope=SCOPE, - cache_path=".cache" + cache_path=None # <--- wichtig! ) if sp_oauth.is_token_expired(token_info): token_info = sp_oauth.refresh_access_token(token_info['refresh_token']) @@ -53,13 +54,20 @@ def similarity(a, b): return SequenceMatcher(None, a.lower(), b.lower()).ratio() def clean_title(title): + # Unicode-Normalisierung (z.B. é -> e) + title = unicodedata.normalize('NFKD', title) + title = "".join([c for c in title if not unicodedata.combining(c)]) # Entfernt alles in () oder [] title = re.sub(r"(\s*[\(\[][^)\]]*[\)\]])", "", title) # Vereinheitliche Apostrophen und Anführungszeichen title = title.replace("’", "'").replace("‘", "'").replace("`", "'") title = title.replace('"', '').replace("„", '').replace("“", '').replace("”", '') - title = title.replace("'", "") # Optional: alle Apostrophen entfernen - return title.strip() + title = title.replace("'", "") + # Entferne alle nicht-alphanumerischen Zeichen (außer Leerzeichen) + title = re.sub(r"[^a-zA-Z0-9äöüÄÖÜß ]", "", title) + # Mehrfache Leerzeichen zu einem + title = re.sub(r"\s+", " ", title) + return title.strip().lower() def get_all_playlist_tracks(sp, playlist_id): tracks = [] @@ -109,17 +117,28 @@ def callback(): user = sp.current_user() session["user"] = user - return redirect("/playlists") + # Setze ein 30-Tage-Cookie mit Userdaten (ohne Token!) + resp = redirect("/playlists") + user_cookie = json.dumps({ + "id": user.get("id"), + "display_name": user.get("display_name"), + "email": user.get("email"), + "images": user.get("images"), + }) + resp.set_cookie("quizify_user", user_cookie, max_age=60*60*24*30, httponly=True, samesite="Lax") + return resp @app.route("/playlists") def playlists(): sp = get_spotify_client() playlists = sp.current_user_playlists()["items"] - return render_template("playlists.html", playlists=playlists, translations=get_translations()) + user = get_user_from_cookie() + return render_template("playlists.html", playlists=playlists, translations=get_translations(), user=user) @app.route("/quiz/") def quiz(playlist_id): game_mode = request.args.get('mode', 'artist') + is_multiplayer = request.args.get('local_multiplayer') == '1' sp = get_spotify_client() tracks = get_all_playlist_tracks(sp, playlist_id) @@ -161,8 +180,10 @@ def quiz(playlist_id): } all_tracks.append(track_info) + user = get_user_from_cookie() + template = "quiz_multiplayer.html" if is_multiplayer else "quiz_single.html" return render_template( - "quiz.html", + template, track=track, access_token=access_token, playlist_id=playlist_id, @@ -172,7 +193,8 @@ def quiz(playlist_id): total_questions=len(tracks), score=score, answered=answered, - translations=get_translations() + translations=get_translations(), + user=user ) @app.route("/search_track", methods=["POST"]) @@ -215,7 +237,8 @@ def check_answer(): game_mode = data.get('game_mode', 'artist') playlist_id = data.get('playlist_id') - if game_mode == 'title': + # Immer clean_title für title und artist + if game_mode in ['title', 'artist']: guess = clean_title(guess) correct_answer = clean_title(correct_answer) @@ -279,12 +302,9 @@ def toggle_playback(): @app.route('/logout') def logout(): session.clear() - return redirect(url_for('home')) - -@app.route('/') -def index(): - user = session.get('user') # Benutzerinfos aus der Session holen, falls vorhanden - return render_template('index.html', user=user, translations=get_translations()) + resp = redirect(url_for('home')) + resp.set_cookie("quizify_user", "", expires=0) + return resp @app.route("/reset_quiz/") def reset_quiz(playlist_id): @@ -295,5 +315,50 @@ def reset_quiz(playlist_id): return redirect(url_for('quiz', playlist_id=playlist_id, mode=next_mode)) return redirect(url_for('playlists')) +@app.route("/gamemodes/") +def gamemodes(playlist_id): + return render_template("gamemodes.html", playlist_id=playlist_id, translations=get_translations()) + +invites = {} # {token: expiry_datetime} + +@app.route("/invite") +def invite(): + duration = int(request.args.get("duration", 60)) # Minuten + token = secrets.token_urlsafe(16) + expires = datetime.utcnow() + timedelta(minutes=duration) + invites[token] = expires + invite_link = url_for('guest_join', token=token, _external=True) + # Gib nur den Link als Klartext zurück! + return invite_link + +@app.route("/invite/") +def guest_join(token): + expires = invites.get(token) + if not expires or expires < datetime.utcnow(): + return "Invite link expired or invalid.", 403 + # Setze ein Cookie, damit der Gast als eingeladener User erkannt wird (optional) + resp = redirect(url_for('login')) + resp.set_cookie("guest_token", token, max_age=60*60) # 1 Stunde gültig + return resp + +def get_user_from_cookie(): + user_cookie = request.cookies.get("quizify_user") + if user_cookie: + try: + return json.loads(user_cookie) + except Exception: + return None + return None + +@app.route("/playerselect/") +def playerselect(playlist_id): + game_mode = request.args.get('mode', 'artist') + return render_template( + "playerselect.html", + playlist_id=playlist_id, + game_mode=game_mode, + translations=get_translations() + ) + if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=True) diff --git a/locales/de-DE.json b/locales/de-DE.json index 70e82d1..d25bf71 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -37,5 +37,23 @@ "album": "Album", "year": "Jahr", "open_on_spotify": "Auf Spotify öffnen", - "logout": "Abmelden" + "logout": "Abmelden", + "custom": "Anpassbar", + "quiz_mode": "Quiz-Modus", + "quiz_mode_desc": "Errate Künstler, Titel oder Jahr. Klassisches Musik-Quiz.", + "invite_guest": "Gast einladen", + "invite_duration": "Link gültig für (Minuten):", + "generate_link": "Link generieren", + "invite_link": "Einladungslink:", + "copy_link": "Link kopieren", + "copied": "Kopiert!", + "referral_link": "Einladungslink", + "referral_duration": "Link gültig für (Minuten):", + "generate_referral": "Einladungslink generieren", + "referral_link_label": "Dein Einladungslink:", + "copy_referral_link": "Link kopieren", + "singleplayer": "Singleplayer", + "singleplayer_desc": "Spiele alleine und teste dein Wissen.", + "local_multiplayer": "Lokaler Multiplayer", + "online_multiplayer": "Online Multiplayer" } \ No newline at end of file diff --git a/locales/en-EN.json b/locales/en-EN.json index 3bbc3bd..9bebab6 100644 --- a/locales/en-EN.json +++ b/locales/en-EN.json @@ -37,5 +37,23 @@ "album": "Album", "year": "Year", "open_on_spotify": "Open on Spotify", - "logout": "Logout" + "logout": "Logout", + "custom": "custom", + "quiz_mode": "Quiz Mode", + "quiz_mode_desc": "Guess artist, title or year. Classic music quiz.", + "invite_guest": "Invite Guest", + "invite_duration": "Link valid for (minutes):", + "generate_link": "Generate Link", + "invite_link": "Invite Link:", + "copy_link": "Copy Link", + "referral_link": "Referral Link", + "referral_duration": "Link valid for (minutes):", + "generate_referral": "Generate Referral Link", + "referral_link_label": "Your referral link:", + "copy_referral_link": "Copy Referral Link", + "copied": "Copied!", + "singleplayer": "Singleplayer", + "singleplayer_desc": "Play alone and test your knowledge.", + "local_multiplayer": "Local Multiplayer", + "online_multiplayer": "Online Multiplayer" } \ No newline at end of file diff --git a/templates/gamemodes.html b/templates/gamemodes.html new file mode 100644 index 0000000..12d5ca9 --- /dev/null +++ b/templates/gamemodes.html @@ -0,0 +1,77 @@ + + + + {{ translations['quiz_title'] }} – Game Modes + + + +
+

{{ translations['quiz_title'] }} – Game Modes

+
+ + +
{{ translations['quiz_mode_desc'] if translations['quiz_mode_desc'] else 'Classic music quiz.' }}
+
+ +
Coming soon!
+ +
Coming soon!
+ +
+ + \ No newline at end of file diff --git a/templates/playerselect.html b/templates/playerselect.html new file mode 100644 index 0000000..2a3bf5f --- /dev/null +++ b/templates/playerselect.html @@ -0,0 +1,81 @@ + + + + {{ translations['quiz_title'] }} – Player Selection + + + +
+

{{ translations['quiz_title'] }} – Player Selection

+
+ + +
{{ translations['singleplayer_desc'] if translations['singleplayer_desc'] else 'Play alone and test your knowledge.' }}
+
+
+ + + +
Spiele mit bis zu 4 Personen an einem Gerät.
+
+ +
Coming soon!
+ +
+ + \ No newline at end of file diff --git a/templates/playlists.html b/templates/playlists.html index 5609b73..987cc24 100644 --- a/templates/playlists.html +++ b/templates/playlists.html @@ -51,19 +51,171 @@ background-color: #1ed760; transform: scale(1.04); } + /* Invite Button and Popup Styles */ + .btn { + background-color: #1DB954; + color: #fff; + border: none; + border-radius: 30px; + padding: 10px 20px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + transition: background 0.2s, transform 0.2s; + } + .btn:hover { + background-color: #1ed760; + transform: scale(1.04); + } + #invitePopup { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; + z-index: 1000; + } + #invitePopup > div { + background: #191414; + color: #fff; + padding: 30px 40px; + border-radius: 18px; + box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); + min-width: 320px; + text-align: center; + position: relative; + } + #invitePopup span { + position: absolute; + top: 10px; + right: 18px; + cursor: pointer; + font-size: 1.5em; + } + #invitePopup label { + display: block; + margin: 15px 0 5px; + } + #invitePopup input { + width: 60px; + margin: 10px auto; + padding: 5px; + border: none; + border-radius: 5px; + text-align: center; + } + #invitePopup #inviteLinkResult { + margin-top: 18px; + word-break: break-all; + } + .top-bar { + width: 100%; + padding: 10px 0; + background: rgba(25, 20, 20, 0.85); + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 100; + } + .top-bar-section { + display: flex; + align-items: center; + } + .top-bar-section.left { + margin-left: 30px; + } + .top-bar-section.center { + flex-grow: 1; + justify-content: center; + } + .top-bar-section.right { + margin-right: 30px; + } - - {{ translations['logout'] if translations['logout'] else 'Logout' }} - -
+
+
+ +
+
+ {% if user %} + + {{ user.display_name }} + {% endif %} +
+ +
+ +

{{ translations['choose_playlist'] }}

+ + +
+
+ × +

{{ translations['referral_link'] if translations['referral_link'] else 'Referral Link' }}

+ + +
+ +
+ + +
+
+
+ + diff --git a/templates/quiz.html b/templates/quiz.html index 5579f86..89102db 100644 --- a/templates/quiz.html +++ b/templates/quiz.html @@ -6,10 +6,15 @@ + {% block extra_head %}{% endblock %} + + +
+ {% block quiz_content %}{% endblock %} +
+ {% block extra_body %}{% endblock %} + + \ No newline at end of file diff --git a/templates/quiz_multiplayer.html b/templates/quiz_multiplayer.html new file mode 100644 index 0000000..8e9452a --- /dev/null +++ b/templates/quiz_multiplayer.html @@ -0,0 +1,441 @@ + + + + {{ translations['quiz_title'] }} + + + + + +
+ +
+ +
+ +

{{ translations['question_artist'] }}

+ +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ {% if game_mode == 'artist' %} +

{{ translations['tip_artist'] }}

+ {% elif game_mode == 'title' %} +

{{ translations['tip_title'] }}

+ {% elif game_mode == 'year' %} +

{{ translations['tip_year'] }}

+ {% endif %} +
+
+ + +
+
+

Lokaler Multiplayer

+

Gib bis zu 4 Spielernamen ein:

+
+
+
+
+
+ +
+
+
+ + + diff --git a/templates/quiz_single.html b/templates/quiz_single.html new file mode 100644 index 0000000..c4eee79 --- /dev/null +++ b/templates/quiz_single.html @@ -0,0 +1,252 @@ +{% extends "quiz_base.html" %} +{% block quiz_content %} +
+ {{ translations['songs_in_playlist'] }} {{ total_questions }} + + {{ translations['score'] }}: {{ score }} / {{ answered if answered > 0 else 1 }} + ({{ ((score / (answered if answered > 0 else 1)) * 100) | round(0) if answered > 0 else 0 }}{{ translations['percent'] }}) + +
+ +

{{ translations['question_artist'] }}

+ +
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ {% if game_mode == 'artist' %} +

{{ translations['tip_artist'] }}

+ {% elif game_mode == 'title' %} +

{{ translations['tip_title'] }}

+ {% elif game_mode == 'year' %} +

{{ translations['tip_year'] }}

+ {% endif %} +
+{% endblock %} +{% block extra_body %} + + + +{% endblock %} \ No newline at end of file