diff --git a/.github/workflows/jam-server-deploy.yml b/.github/workflows/jam-server-deploy.yml
index 8ffe31b0..fe183a98 100644
--- a/.github/workflows/jam-server-deploy.yml
+++ b/.github/workflows/jam-server-deploy.yml
@@ -6,6 +6,11 @@ on:
paths: ["jam-server/**"]
workflow_dispatch:
+# The deploy only reads the repo; the Cloudflare token comes from secrets, which
+# workflows triggered by pull requests from forks never receive.
+permissions:
+ contents: read
+
concurrency:
group: jam-server-deploy
cancel-in-progress: false
diff --git a/AGENTS.md b/AGENTS.md
index 9d48dcfa..b2caeff0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,6 +30,7 @@
- Room server: `jam-server/` (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in `jam-server/src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes).
- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off).
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See `jam-server/README.md`.
+- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in `jam-server/README.md`.
- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`.
- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`).
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (start `npm run dev` in `jam-server/`).
diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt
new file mode 100644
index 00000000..45d18513
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import android.content.res.Resources
+import android.os.Build
+import java.util.Locale
+import java.util.TimeZone
+
+internal actual fun jamDeviceTraits(): List {
+ val display = Resources.getSystem().displayMetrics
+ return listOf(
+ "android",
+ Build.MANUFACTURER,
+ Build.MODEL,
+ Build.DEVICE,
+ Build.VERSION.RELEASE,
+ Build.VERSION.SDK_INT.toString(),
+ "${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}",
+ Locale.getDefault().toLanguageTag(),
+ TimeZone.getDefault().id)
+}
diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
index 7910c67d..848261ee 100644
--- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
@@ -344,6 +344,28 @@
تعذّر الانضمام إلى جلسة الجام
أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.
انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s
+ خادم الغرف
+ رابط الخادم
+ أدخل رابطًا يبدأ بـ https://
+ تحقّق من خادم الغرف قبل بدء غرفة.
+ يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر jam-server من مستودع Spotube وأدخل رابطه.
+ أنت
+ الخادم: %1$s
+ حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها.
+ إعدادات الخادم
+ %1$s (أنت)
+ اسمك
+ يراك الآخرون في الجام باسم %1$s
+ يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها.
+ استخدم %1$d حرفًا كحد أقصى.
+ لم يتم إعداد خادم للجام
+ تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي.
+ يعمل خادم الجام بإصدار غير متوافق.
+ لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا.
+ هذه الغرفة ممتلئة.
+ حظرك المضيف من هذه الغرفة.
+ أزالك المضيف من الغرفة.
+ فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال.
الانتقال إلى الألبوم
مؤقت النوم
diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
index d5742676..08dc21ff 100644
--- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
@@ -328,6 +328,28 @@
জ্যাম সেশনে যোগ দেওয়া যায়নি
অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।
এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s
+ রুম সার্ভার
+ সার্ভার URL
+ https:// দিয়ে শুরু হওয়া একটি URL দিন
+ রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন।
+ জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে Spotube রিপোজিটরি থেকে jam-server ডিপ্লয় করে তার URL দিন।
+ আপনি
+ সার্ভার: %1$s
+ রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন।
+ সার্ভার সেটিংস
+ %1$s (আপনি)
+ আপনার নাম
+ জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে
+ জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে।
+ সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন।
+ কোনো জ্যাম সার্ভার সেট করা নেই
+ জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন।
+ জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়।
+ এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন।
+ এই রুমটি পূর্ণ।
+ হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন।
+ হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন।
+ এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে।
অ্যালবামে যান
স্লিপ টাইমার
diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml
index 0837991c..34d0a674 100644
--- a/composeApp/src/commonMain/composeResources/values-de/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml
@@ -328,6 +328,28 @@
Beitritt zur Jam-Session fehlgeschlagen
Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein.
Tritt meiner Spotube-Jam bei, Code: %1$s
+ Raumserver
+ Server-URL
+ Gib eine URL ein, die mit https:// beginnt.
+ Prüfe den Raumserver, bevor du einen Raum startest.
+ Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle jam-server aus dem Spotube-Repository bereit und gib seine URL ein.
+ Du
+ Server: %1$s
+ Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten.
+ Servereinstellungen
+ %1$s (du)
+ Dein Name
+ Andere im Jam sehen dich als %1$s
+ Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst.
+ Verwende höchstens %1$d Zeichen.
+ Kein Jam-Server eingerichtet
+ Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam.
+ Der Jam-Server verwendet eine inkompatible Version.
+ Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut.
+ Dieser Raum ist voll.
+ Der Host hat dich aus diesem Raum gesperrt.
+ Der Host hat dich aus dem Raum entfernt.
+ Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt.
Zum Album
Sleep-Timer
diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml
index 6d486799..4f9e7296 100644
--- a/composeApp/src/commonMain/composeResources/values-es/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml
@@ -332,6 +332,28 @@
No se pudo unir a la sesión de Jam
Introduce un código de sala de seis caracteres con letras y números.
Únete a mi Jam de Spotube con el código: %1$s
+ Servidor de salas
+ URL del servidor
+ Introduce una URL que empiece por https://
+ Comprueba el servidor de salas antes de crear una sala.
+ Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega jam-server desde el repositorio de Spotube e introduce su URL.
+ Tú
+ Servidor: %1$s
+ Configura un servidor de salas en Ajustes para crear una sala o unirte a una.
+ Ajustes del servidor
+ %1$s (tú)
+ Tu nombre
+ Los demás en la jam te ven como %1$s
+ Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una.
+ Usa como máximo %1$d caracteres.
+ No hay ningún servidor de jam configurado
+ No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo.
+ El servidor de jam usa una versión incompatible.
+ No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo.
+ Esta sala está llena.
+ El anfitrión te ha bloqueado en esta sala.
+ El anfitrión te ha quitado de la sala.
+ Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión.
Ir al álbum
Temporizador
diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
index 3c0dbc1a..ad55e61e 100644
--- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
@@ -328,6 +328,28 @@
پیوستن به جلسهٔ جم ناموفق بود
یک کد اتاق ششنویسهای شامل حروف و اعداد وارد کنید.
با این کد به جم Spotube من بپیوندید: %1$s
+ سرور اتاقها
+ نشانی سرور
+ نشانیای وارد کنید که با https:// شروع شود
+ پیش از شروع اتاق، سرور اتاقها را بررسی کنید.
+ جم از سرور رایگان اتاقهای Spotube استفاده میکند. برای میزبانی شخصی، jam-server را از مخزن Spotube راهاندازی کنید و نشانی آن را وارد کنید.
+ شما
+ سرور: %1$s
+ برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید.
+ تنظیمات سرور
+ %1$s (شما)
+ نام شما
+ دیگران در جم شما را با نام %1$s میبینند
+ به همه در جم نشان داده میشود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی میسازید یا به آن میپیوندید اعمال میشود.
+ حداکثر %1$d نویسه وارد کنید.
+ هیچ سرور جمی تنظیم نشده است
+ دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید.
+ سرور جم نسخهٔ ناسازگاری اجرا میکند.
+ اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید.
+ این اتاق پر است.
+ میزبان شما را از این اتاق مسدود کرد.
+ میزبان شما را از اتاق بیرون کرد.
+ این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد.
رفتن به آلبوم
زمانسنج خواب
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 4372e030..08d0398c 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -332,6 +332,28 @@
Impossible de rejoindre la session Jam
Saisissez un code de salle de six caractères (lettres et chiffres).
Rejoignez ma Jam Spotube avec le code : %1$s
+ Serveur de salles
+ URL du serveur
+ Saisissez une URL commençant par https://
+ Vérifiez le serveur de salles avant de lancer une salle.
+ Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez jam-server depuis le dépôt Spotube et saisissez son URL.
+ Vous
+ Serveur : %1$s
+ Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle.
+ Paramètres du serveur
+ %1$s (vous)
+ Votre nom
+ Les autres participants vous voient comme %1$s
+ Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle.
+ Utilisez au maximum %1$d caractères.
+ Aucun serveur de jam configuré
+ Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe.
+ Le serveur de jam utilise une version incompatible.
+ Aucune salle avec ce code. Vérifiez-le et réessayez.
+ Cette salle est pleine.
+ L’hôte vous a banni de cette salle.
+ L’hôte vous a retiré de la salle.
+ Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée.
Accéder à l\'album
Minuteur de veille
diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
index 50c52c2d..82cb35aa 100644
--- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
@@ -327,6 +327,28 @@
जैम सेशन से नहीं जुड़ सके
अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।
मेरे Spotube जैम से जुड़ें, कोड: %1$s
+ रूम सर्वर
+ सर्वर URL
+ https:// से शुरू होने वाला URL डालें
+ रूम शुरू करने से पहले रूम सर्वर जांच लें।
+ जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए Spotube रिपॉज़िटरी से jam-server डिप्लॉय करें और उसका URL डालें।
+ आप
+ सर्वर: %1$s
+ रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें।
+ सर्वर सेटिंग
+ %1$s (आप)
+ आपका नाम
+ जैम में बाकी लोग आपको %1$s के रूप में देखते हैं
+ जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा।
+ ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें।
+ कोई जैम सर्वर सेट नहीं है
+ जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें।
+ जैम सर्वर का वर्शन संगत नहीं है।
+ इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें।
+ यह रूम भर गया है।
+ होस्ट ने आपको इस रूम से बैन कर दिया है।
+ होस्ट ने आपको रूम से हटा दिया है।
+ यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया।
एल्बम पर जाएं
स्लीप टाइमर
diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml
index 1b8ee7e5..1f162c8d 100644
--- a/composeApp/src/commonMain/composeResources/values-id/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml
@@ -324,6 +324,28 @@
Gagal bergabung ke sesi Jam
Masukkan kode ruang enam karakter berisi huruf dan angka.
Gabung ke Jam Spotube-ku dengan kode: %1$s
+ Server ruang
+ URL server
+ Masukkan URL yang diawali https://
+ Periksa server ruang sebelum memulai ruang.
+ Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy jam-server dari repositori Spotube lalu masukkan URL-nya.
+ Kamu
+ Server: %1$s
+ Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang.
+ Pengaturan server
+ %1$s (kamu)
+ Namamu
+ Orang lain di jam melihatmu sebagai %1$s
+ Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang.
+ Gunakan paling banyak %1$d karakter.
+ Belum ada server jam yang diatur
+ Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup.
+ Server jam menjalankan versi yang tidak kompatibel.
+ Tidak ada ruang dengan kode itu. Periksa lalu coba lagi.
+ Ruang ini sudah penuh.
+ Host memblokirmu dari ruang ini.
+ Host mengeluarkanmu dari ruang.
+ Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup.
Buka album
Timer tidur
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index 2a71283c..eb429ba5 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -332,6 +332,28 @@
Impossibile partecipare alla sessione Jam
Inserisci un codice stanza di sei caratteri composto da lettere e cifre.
Unisciti alla mia Jam su Spotube con il codice: %1$s
+ Server delle stanze
+ URL del server
+ Inserisci un URL che inizi con https://
+ Controlla il server delle stanze prima di avviare una stanza.
+ Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci jam-server dal repository di Spotube e inseriscine l’URL.
+ Tu
+ Server: %1$s
+ Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci.
+ Impostazioni del server
+ %1$s (tu)
+ Il tuo nome
+ Gli altri nella jam ti vedono come %1$s
+ Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri.
+ Usa al massimo %1$d caratteri.
+ Nessun server jam configurato
+ Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo.
+ Il server jam usa una versione non compatibile.
+ Nessuna stanza con questo codice. Controllalo e riprova.
+ Questa stanza è piena.
+ L’host ti ha bannato da questa stanza.
+ L’host ti ha rimosso dalla stanza.
+ Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa.
Vai all\'album
Timer di spegnimento
diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
index 473e213d..ce8e54a2 100644
--- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
@@ -324,6 +324,28 @@
ジャムセッションに参加できませんでした
英数字6文字のルームコードを入力してください。
コード %1$s で私のSpotubeジャムに参加しよう
+ ルームサーバー
+ サーバーURL
+ https:// で始まるURLを入力してください。
+ ルームを始める前にルームサーバーを確認します。
+ ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、Spotubeリポジトリのjam-serverをデプロイし、そのURLを入力してください。
+ あなた
+ サーバー: %1$s
+ ルームをホストまたは参加するには、設定でルームサーバーを指定してください。
+ サーバー設定
+ %1$s(あなた)
+ あなたの名前
+ ジャムの他の参加者には %1$s と表示されます
+ ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。
+ %1$d 文字以内で入力してください。
+ ジャムサーバーが設定されていません
+ ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。
+ ジャムサーバーのバージョンに互換性がありません。
+ そのコードのルームはありません。コードを確認してもう一度お試しください。
+ このルームは満員です。
+ ホストによってこのルームからBANされました。
+ ホストによってルームから削除されました。
+ このデバイスでこのルームが再度開かれたため、この接続は閉じられました。
アルバムへ移動
スリープタイマー
diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
index 69dac887..b7fb9f12 100644
--- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
@@ -324,6 +324,28 @@
잼 세션에 참여하지 못했어요
영문자와 숫자로 된 6자리 방 코드를 입력하세요.
내 Spotube 잼에 참여하세요. 코드: %1$s
+ 방 서버
+ 서버 URL
+ https://로 시작하는 URL을 입력하세요.
+ 방을 시작하기 전에 방 서버를 확인하세요.
+ 잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 Spotube 저장소의 jam-server를 배포하고 URL을 입력하세요.
+ 나
+ 서버: %1$s
+ 방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요.
+ 서버 설정
+ %1$s (나)
+ 내 이름
+ 잼의 다른 사람들에게 %1$s(으)로 표시됩니다
+ 잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다.
+ 최대 %1$d자까지 입력하세요.
+ 설정된 잼 서버가 없습니다
+ 잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요.
+ 잼 서버의 버전이 호환되지 않습니다.
+ 해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요.
+ 이 방은 가득 찼습니다.
+ 호스트가 이 방에서 나를 차단했습니다.
+ 호스트가 나를 방에서 내보냈습니다.
+ 이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다.
앨범으로 이동
취침 타이머
diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
index c1b6c506..76fb4235 100644
--- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
@@ -328,6 +328,28 @@
Deelnemen aan Jam-sessie mislukt
Voer een sessiecode van zes tekens in met letters en cijfers.
Doe mee met mijn Spotube Jam met code: %1$s
+ Sessieserver
+ Server-URL
+ Voer een URL in die begint met https://
+ Controleer de sessieserver voordat je een sessie start.
+ Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan jam-server uit de Spotube-repository en voer de URL in.
+ Jij
+ Server: %1$s
+ Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen.
+ Serverinstellingen
+ %1$s (jij)
+ Je naam
+ Anderen in de jam zien je als %1$s
+ Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt.
+ Gebruik maximaal %1$d tekens.
+ Geen jamserver ingesteld
+ Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam.
+ De jamserver draait een incompatibele versie.
+ Geen sessie met die code. Controleer de code en probeer het opnieuw.
+ Deze sessie is vol.
+ De host heeft je uit deze sessie verbannen.
+ De host heeft je uit de sessie verwijderd.
+ Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten.
Naar album
Slaaptimer
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index b749cd80..a196725e 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -336,6 +336,28 @@
Nie udało się dołączyć do sesji Jam
Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr.
Dołącz do mojego Jamu w Spotube, kod: %1$s
+ Serwer pokojów
+ Adres URL serwera
+ Wpisz adres URL zaczynający się od https://
+ Sprawdź serwer pokojów przed utworzeniem pokoju.
+ Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż jam-server z repozytorium Spotube i wpisz jego adres URL.
+ Ty
+ Serwer: %1$s
+ Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.
+ Ustawienia serwera
+ %1$s (ty)
+ Twoja nazwa
+ Inni w jamie widzą cię jako %1$s
+ Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego.
+ Użyj maksymalnie %1$d znaków.
+ Nie skonfigurowano serwera jamu
+ Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam.
+ Serwer jamu używa niezgodnej wersji.
+ Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie.
+ Ten pokój jest pełny.
+ Gospodarz zablokował cię w tym pokoju.
+ Gospodarz usunął cię z pokoju.
+ Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte.
Przejdź do albumu
Wyłącznik czasowy
diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
index c1fdef3b..76942052 100644
--- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
@@ -332,6 +332,28 @@
Falha ao entrar na Jam
Digite um código de sala com seis caracteres, usando letras e números.
Entre na minha Jam do Spotube com o código: %1$s
+ Servidor de salas
+ URL do servidor
+ Digite uma URL que comece com https://
+ Verifique o servidor de salas antes de criar uma sala.
+ O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o jam-server do repositório do Spotube e informe a URL dele.
+ Você
+ Servidor: %1$s
+ Defina um servidor de salas nas Configurações para criar ou entrar em uma sala.
+ Configurações do servidor
+ %1$s (você)
+ Seu nome
+ Os outros no jam veem você como %1$s
+ Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala.
+ Use no máximo %1$d caracteres.
+ Nenhum servidor de jam configurado
+ Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo.
+ O servidor de jam usa uma versão incompatível.
+ Nenhuma sala com esse código. Confira e tente de novo.
+ Esta sala está cheia.
+ O anfitrião baniu você desta sala.
+ O anfitrião removeu você da sala.
+ Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada.
Ir para o álbum
Timer
diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
index eaa76c30..3f89ffd8 100644
--- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
@@ -336,6 +336,28 @@
Не удалось присоединиться к джему
Введите код комнаты из шести букв и цифр.
Присоединяйся к моему джему в Spotube по коду: %1$s
+ Сервер комнат
+ URL сервера
+ Введите URL, начинающийся с https://
+ Проверьте сервер комнат перед созданием комнаты.
+ Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите jam-server из репозитория Spotube и укажите его URL.
+ Вы
+ Сервер: %1$s
+ Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней.
+ Настройки сервера
+ %1$s (вы)
+ Ваше имя
+ Другие участники джема видят вас как %1$s
+ Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней.
+ Не больше %1$d символов.
+ Сервер джема не настроен
+ Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем.
+ Сервер джема использует несовместимую версию.
+ Комнаты с таким кодом нет. Проверьте код и попробуйте снова.
+ Эта комната заполнена.
+ Организатор заблокировал вас в этой комнате.
+ Организатор удалил вас из комнаты.
+ Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто.
Перейти к альбому
Таймер сна
diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml
index 56a198a2..db86245d 100644
--- a/composeApp/src/commonMain/composeResources/values-th/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml
@@ -324,6 +324,28 @@
เข้าร่วมเซสชันแจมไม่สำเร็จ
ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข
มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s
+ เซิร์ฟเวอร์ห้อง
+ URL ของเซิร์ฟเวอร์
+ ใส่ URL ที่ขึ้นต้นด้วย https://
+ ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง
+ แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้ง jam-server จากรีโพสิทอรีของ Spotube แล้วใส่ URL ของเซิร์ฟเวอร์นั้น
+ คุณ
+ เซิร์ฟเวอร์: %1$s
+ ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง
+ การตั้งค่าเซิร์ฟเวอร์
+ %1$s (คุณ)
+ ชื่อของคุณ
+ คนอื่นในแจมจะเห็นคุณเป็น %1$s
+ ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป
+ ใช้ได้ไม่เกิน %1$d ตัวอักษร
+ ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม
+ เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม
+ เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้
+ ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง
+ ห้องนี้เต็มแล้ว
+ โฮสต์แบนคุณออกจากห้องนี้
+ โฮสต์นำคุณออกจากห้อง
+ ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้
ไปที่อัลบั้ม
ตั้งเวลาปิด
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index 2f8c21e9..c151fd39 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -328,6 +328,28 @@
Jam oturumuna katılınamadı
Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir.
Spotube Jam\'ime şu kodla katıl: %1$s
+ Oda sunucusu
+ Sunucu URL’si
+ https:// ile başlayan bir URL gir.
+ Oda başlatmadan önce oda sunucusunu kontrol et.
+ Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için Spotube deposundaki jam-server’ı kur ve URL’sini gir.
+ Sen
+ Sunucu: %1$s
+ Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle.
+ Sunucu ayarları
+ %1$s (sen)
+ Adın
+ Jam’deki diğer kişiler seni %1$s olarak görür
+ Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur.
+ En fazla %1$d karakter kullan.
+ Jam sunucusu ayarlanmamış
+ Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et.
+ Jam sunucusu uyumsuz bir sürüm çalıştırıyor.
+ Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene.
+ Bu oda dolu.
+ Oda sahibi seni bu odadan engelledi.
+ Oda sahibi seni odadan çıkardı.
+ Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı.
Albüme git
Uyku zamanlayıcısı
diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
index 9f87d27f..a2621789 100644
--- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
@@ -336,6 +336,28 @@
Не вдалося приєднатися до джему
Введіть код кімнати з шести літер і цифр.
Приєднуйся до мого джему в Spotube за кодом: %1$s
+ Сервер кімнат
+ URL сервера
+ Введіть URL, що починається з https://
+ Перевірте сервер кімнат перед створенням кімнати.
+ Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть jam-server із репозиторію Spotube і вкажіть його URL.
+ Ви
+ Сервер: %1$s
+ Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї.
+ Налаштування сервера
+ %1$s (ви)
+ Ваше ім’я
+ Інші учасники джему бачать вас як %1$s
+ Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї.
+ Не більше %1$d символів.
+ Сервер джему не налаштовано
+ Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем.
+ Сервер джему використовує несумісну версію.
+ Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз.
+ Ця кімната заповнена.
+ Організатор заблокував вас у цій кімнаті.
+ Організатор видалив вас із кімнати.
+ Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито.
Перейти до альбому
Таймер сну
diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
index 80a93679..f38ef955 100644
--- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
@@ -324,6 +324,28 @@
Không thể tham gia phiên Jam
Nhập mã phòng gồm sáu ký tự chữ và số.
Tham gia Jam Spotube của mình với mã: %1$s
+ Máy chủ phòng
+ URL máy chủ
+ Nhập URL bắt đầu bằng https://
+ Kiểm tra máy chủ phòng trước khi tạo phòng.
+ Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai jam-server từ kho mã Spotube rồi nhập URL của nó.
+ Bạn
+ Máy chủ: %1$s
+ Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng.
+ Cài đặt máy chủ
+ %1$s (bạn)
+ Tên của bạn
+ Mọi người trong jam thấy bạn là %1$s
+ Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng.
+ Dùng tối đa %1$d ký tự.
+ Chưa cấu hình máy chủ jam
+ Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm.
+ Máy chủ jam đang chạy phiên bản không tương thích.
+ Không có phòng nào với mã này. Hãy kiểm tra và thử lại.
+ Phòng này đã đầy.
+ Chủ phòng đã cấm bạn khỏi phòng này.
+ Chủ phòng đã xóa bạn khỏi phòng.
+ Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng.
Đi tới album
Hẹn giờ tắt
diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
index f3710bb5..98f81efd 100644
--- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -324,6 +324,28 @@
無法加入 Jam
請輸入由英文字母和數字組成的六位房間代碼。
用代碼 %1$s 加入我的 Spotube Jam
+ 房間伺服器
+ 伺服器網址
+ 請輸入以 https:// 開頭的網址
+ 建立房間前先檢查房間伺服器。
+ Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 Spotube 儲存庫中的 jam-server 並輸入其網址。
+ 你
+ 伺服器:%1$s
+ 請在設定中指定房間伺服器,以建立或加入房間。
+ 伺服器設定
+ %1$s(你)
+ 你的名稱
+ Jam 裡的其他人會看到你是 %1$s
+ Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。
+ 最多 %1$d 個字元。
+ 尚未設定 Jam 伺服器
+ 無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。
+ Jam 伺服器的版本不相容。
+ 沒有使用此代碼的房間。請檢查後再試一次。
+ 此房間已滿。
+ 主持人已將你從此房間封鎖。
+ 主持人已將你移出房間。
+ 此房間已在這部裝置上再次開啟,因此這個連線已關閉。
前往專輯
睡眠定時器
diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
index 722ff10c..c4670129 100644
--- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
@@ -324,6 +324,28 @@
加入一起听会话失败
请输入由字母和数字组成的六位房间码。
用房间码加入我的 Spotube 一起听:%1$s
+ 房间服务器
+ 服务器网址
+ 请输入以 https:// 开头的网址
+ 创建房间前先检查房间服务器。
+ 一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 Spotube 仓库中的 jam-server 并填写其网址。
+ 你
+ 服务器:%1$s
+ 请在设置中指定房间服务器,以创建或加入房间。
+ 服务器设置
+ %1$s(你)
+ 你的名字
+ 房间里的其他人看到你的名字是 %1$s
+ 房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。
+ 最多 %1$d 个字符。
+ 未配置一起听服务器
+ 无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。
+ 一起听服务器的版本不兼容。
+ 没有使用该代码的房间。请检查后重试。
+ 该房间已满。
+ 房主已将你从此房间封禁。
+ 房主已将你移出房间。
+ 此房间已在本设备上再次打开,因此此连接已关闭。
转到专辑
睡眠定时器
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 764bff82..e6c3b823 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -321,6 +321,7 @@
Failed: %1$s
Connection
Diagnostics
+ You
Group Jam
@@ -343,6 +344,7 @@
Participants (%1$d)
Host
Listener
+ %1$s (you)
Waiting for listeners…
Remove
Ban
@@ -354,6 +356,145 @@
Failed to join jam session
Enter a six-character room code using letters and digits.
Join my Spotube Jam with code: %1$s
+ Your name
+ Others in the jam see you as %1$s
+ Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room.
+ Use at most %1$d characters.
+
+ %1$s %2$s
+
+
+ - Brave
+ - Bright
+ - Calm
+ - Cheerful
+ - Chill
+ - Clever
+ - Cosmic
+ - Cozy
+ - Curious
+ - Dapper
+ - Dreamy
+ - Eager
+ - Electric
+ - Fancy
+ - Fearless
+ - Fizzy
+ - Fluffy
+ - Funky
+ - Gentle
+ - Giddy
+ - Glowing
+ - Golden
+ - Groovy
+ - Happy
+ - Humble
+ - Jazzy
+ - Jolly
+ - Jumpy
+ - Kind
+ - Lively
+ - Lucky
+ - Lunar
+ - Mellow
+ - Merry
+ - Mighty
+ - Misty
+ - Nimble
+ - Peppy
+ - Plucky
+ - Polite
+ - Quick
+ - Quiet
+ - Rapid
+ - Rhythmic
+ - Rosy
+ - Silly
+ - Sleepy
+ - Smooth
+ - Snappy
+ - Soulful
+ - Sparkly
+ - Speedy
+ - Spicy
+ - Sunny
+ - Swift
+ - Tidy
+ - Tiny
+ - Vivid
+ - Wavy
+ - Whistling
+ - Witty
+ - Zany
+ - Zesty
+ - Zippy
+
+
+ - Alpaca
+ - Badger
+ - Beaver
+ - Bison
+ - Bunny
+ - Capybara
+ - Cheetah
+ - Chipmunk
+ - Cobra
+ - Corgi
+ - Coyote
+ - Crane
+ - Dingo
+ - Dolphin
+ - Duckling
+ - Eagle
+ - Falcon
+ - Ferret
+ - Finch
+ - Flamingo
+ - Fox
+ - Gazelle
+ - Gecko
+ - Giraffe
+ - Hedgehog
+ - Heron
+ - Hippo
+ - Ibis
+ - Jaguar
+ - Koala
+ - Lemur
+ - Llama
+ - Lynx
+ - Magpie
+ - Manatee
+ - Marmot
+ - Meerkat
+ - Mongoose
+ - Moose
+ - Narwhal
+ - Ocelot
+ - Octopus
+ - Orca
+ - Otter
+ - Owl
+ - Panda
+ - Pangolin
+ - Parrot
+ - Pelican
+ - Penguin
+ - Puffin
+ - Quokka
+ - Raccoon
+ - Raven
+ - Robin
+ - Seal
+ - Sloth
+ - Sparrow
+ - Squirrel
+ - Tapir
+ - Tiger
+ - Toucan
+ - Walrus
+ - Wombat
+
Go to album
Sleep timer
@@ -547,7 +688,7 @@
This room is full.
The host banned you from this room.
The host removed you from the room.
- You joined this room from another device.
+ This room was opened again on this device, so this connection was closed.
Connection failed
Connection lost
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt
new file mode 100644
index 00000000..ebef9b60
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import okio.ByteString.Companion.encodeUtf8
+
+/**
+ * Stable traits of this device (model, OS version, locale, …) for jam bans. They never
+ * leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the
+ * server hashes that again with the network, so a room only ever holds an opaque value.
+ */
+internal expect fun jamDeviceTraits(): List
+
+/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */
+internal fun jamDeviceDigest(roomCode: String): String? = runCatching {
+ val traits = jamDeviceTraits().filter { it.isNotBlank() }
+ if (traits.isEmpty()) return null
+ "spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex()
+}.getOrNull()
+
+/**
+ * This install's participant secret for room [roomCode], derived from the install's
+ * [deviceKey] (random, kept in settings). The same install gets the same identity in a
+ * room every time, so a ban sticks across rejoins; different rooms get unrelated keys.
+ */
+internal fun jamParticipantKey(deviceKey: String, roomCode: String): String =
+ "spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex()
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt
new file mode 100644
index 00000000..023bd85c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import okio.ByteString.Companion.encodeUtf8
+import org.jetbrains.compose.resources.getString
+import org.jetbrains.compose.resources.getStringArray
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.jam_name_adjectives
+import spotube.composeapp.generated.resources.jam_name_animals
+import spotube.composeapp.generated.resources.jam_name_format
+
+/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */
+object JamNames {
+ /** The server's limit (`MAX_NAME_LENGTH` in `jam-server/src/protocol.ts`). */
+ const val MAX_LENGTH = 40
+
+ /**
+ * The generated name for [seed]. The same seed always gets the same name, so with this
+ * install's key a device keeps its name across sessions until the user picks their own.
+ */
+ suspend fun generate(seed: String): String {
+ val adjectives = getStringArray(Res.array.jam_name_adjectives)
+ val animals = getStringArray(Res.array.jam_name_animals)
+ val hash = "spotube-jam-name:$seed".encodeUtf8().sha256()
+ val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)]
+ val animal = animals[pick(hash[2], hash[3], animals.size)]
+ return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH)
+ }
+
+ /** Cleans a name the user typed; blank means "use the generated one". */
+ fun normalize(input: String): String =
+ input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim()
+
+ private fun pick(high: Byte, low: Byte, size: Int): Int =
+ (((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size
+}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
index b01fe8e0..7744c5f7 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
@@ -99,10 +99,35 @@ data class JamMediaItem(
val coverUrl: String,
val protocol: String,
val addedBy: String = "") {
- /** Identifies the track across devices: the metadata id, or the file for local tracks. */
- val key: String get() = trackId.ifBlank { "local:$url" }
+ /** Identifies the track across devices: the metadata id, or an opaque id for local files. */
+ val key: String get() = trackId.ifBlank { url }
+
+ /**
+ * This item as received from another participant, made safe to load: streaming tracks
+ * only (a peer's local file can't play here, and a URL would make this device fetch
+ * whatever a peer chose), https covers only, and bounded text. Null if unusable.
+ */
+ fun sanitized(): JamMediaItem? {
+ if (trackId.isBlank() || trackId.length > MAX_ID_LENGTH) return null
+ return copy(
+ url = "",
+ title = title.take(MAX_TEXT_LENGTH),
+ artist = artist.take(MAX_TEXT_LENGTH),
+ album = album.take(MAX_TEXT_LENGTH),
+ durationMs = durationMs.coerceAtLeast(0),
+ coverUrl = coverUrl.takeIf { it.startsWith("https://") && it.length <= MAX_URL_LENGTH }.orEmpty(),
+ protocol = protocol.take(MAX_ID_LENGTH),
+ addedBy = addedBy.take(MAX_ID_LENGTH))
+ }
companion object {
+ private const val MAX_ID_LENGTH = 200
+ private const val MAX_TEXT_LENGTH = 300
+ private const val MAX_URL_LENGTH = 2048
+
+ /** The cross-device key of a local file at [path], which doesn't reveal the path. */
+ fun localTrackKey(path: String): String = "local:" + path.hashCode().toUInt().toString(16)
+
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem(
url = "",
@@ -117,8 +142,9 @@ data class JamMediaItem(
protocol = entry.protocol.name,
addedBy = entry.addedBy)
+ // Only an opaque id: the path would show the host's folders (and user name) to everyone.
is QueueEntry.LocalTrack -> JamMediaItem(
- url = entry.url,
+ url = localTrackKey(entry.url),
trackId = "",
title = entry.name,
artist = entry.artists.joinToString(", "),
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
index c204ede2..fd529abf 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
@@ -117,6 +117,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
private var lastFrameAtMs = 0L
/** The room's host as last reported by the server. */
private var hostId: String? = null
+
+ /**
+ * Our public participant id in the current room, as the server derived it from our
+ * secret key. Null until the first welcome.
+ */
+ var selfId: String? = null
+ private set
private val pongs = MutableStateFlow(0)
private val _isConnected = MutableStateFlow(false)
@@ -144,16 +151,22 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
* Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException]
* when the server refuses (code taken, room not found, banned, …). Once connected,
* drops are retried in the background until [disconnect] or a refusal.
+ *
+ * [clientKey] is this participant's secret ([jamParticipantKey]): reconnecting with
+ * it makes us the same participant, so it never leaves this device except to the
+ * server. [deviceDigest] ([jamDeviceDigest]) lets a ban cover this device too.
*/
suspend fun connect(
serverUrl: String,
code: String,
- clientId: String,
+ clientKey: String,
displayName: String,
- create: Boolean): Result {
+ create: Boolean,
+ deviceDigest: String? = null): Result {
disconnect()
clock.reset()
- val welcome = runCatching { open(serverUrl, code, clientId, displayName, create) }
+ val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest)
+ val welcome = runCatching { open(opener, create) }
.onFailure { e ->
log.w(e) { "Failed to open jam room $code" }
_connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed)
@@ -176,11 +189,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
while (isActive) {
delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)])
attempt++
- var reopened = runCatching { open(serverUrl, code, clientId, displayName, create = false) }
+ var reopened = runCatching { open(opener, create = false) }
// Everyone was gone long enough for the room to expire: its host brings it back.
- if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && hostId == clientId) {
+ if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" &&
+ hostId != null && hostId == selfId
+ ) {
log.i { "Jam room $code expired while offline; recreating it" }
- reopened = runCatching { open(serverUrl, code, clientId, displayName, create = true) }
+ reopened = runCatching { open(opener, create = true) }
}
val error = reopened.exceptionOrNull()
if (error is JamRoomException) {
@@ -210,6 +225,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
connectionJob?.cancel()
connectionJob = null
hostId = null
+ selfId = null
reader?.cancel()
reader = null
val current = session
@@ -225,13 +241,17 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
* Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome
* and the socket's reader, which completes with the server's refusal (if any) on close.
*/
- private suspend fun open(
- serverUrl: String,
- code: String,
- clientId: String,
- displayName: String,
- create: Boolean): Pair> {
- val socket = runCatching { http.webSocketSession(roomUrl(serverUrl, code, clientId, displayName, create)) }
+ /** Everything needed to (re)open one room connection. */
+ private data class Opener(
+ val serverUrl: String,
+ val code: String,
+ val clientKey: String,
+ val displayName: String,
+ val deviceDigest: String?)
+
+ private suspend fun open(opener: Opener, create: Boolean): Pair> {
+ val code = opener.code
+ val socket = runCatching { http.webSocketSession(roomUrl(opener, create)) }
.getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) }
try {
val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) }
@@ -241,6 +261,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
}
session = socket
+ selfId = welcome.you
hostId = welcome.host
lastFrameAtMs = now()
_isConnected.value = true
@@ -249,7 +270,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
val socketReader = scope.async { pump(socket) }
reader = socketReader
syncClock(socket)
- log.i { "Joined jam room $code as $clientId (host=${welcome.host}, rtt=${clock.rttMs}ms)" }
+ log.i { "Joined jam room $code as ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" }
return welcome.toEvent() to socketReader
} catch (e: Throwable) {
runCatching { socket.close() }
@@ -351,7 +372,8 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
- is ServerFrame.Error -> return decoded.code
+ // Sent too much too fast (a burst of skips, say): not a refusal, just reconnect.
+ is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" }
null -> Unit
}
return null
@@ -395,13 +417,14 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
else -> getString(Res.string.jam_error_connection_failed)
}
- private fun roomUrl(serverUrl: String, code: String, clientId: String, displayName: String, create: Boolean) =
- URLBuilder(serverUrl.trim()).apply {
+ private fun roomUrl(opener: Opener, create: Boolean) =
+ URLBuilder(opener.serverUrl.trim()).apply {
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
- appendPathSegments("v1", "rooms", code)
- parameters.append("id", clientId)
- parameters.append("name", displayName)
+ appendPathSegments("v1", "rooms", opener.code)
+ parameters.append("key", opener.clientKey)
+ parameters.append("name", opener.displayName)
parameters.append("mode", if (create) "create" else "join")
+ opener.deviceDigest?.let { parameters.append("dev", it) }
}.buildString()
private fun healthUrl(serverUrl: String) =
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt
index e6ec412b..3846e813 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt
@@ -17,7 +17,8 @@
package org.spotube.spotube.core.jam
-import kotlin.random.Random
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
/**
* Six-character room codes shared verbally / by text. Codes are opaque keys used
@@ -30,12 +31,23 @@ object JamRoomCode {
const val LENGTH = 6
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
- fun generate(): String = buildString(LENGTH) {
- repeat(LENGTH) {
- append(ALPHABET[Random.nextInt(ALPHABET.length)])
+ /** From a cryptographically secure source: a predictable code would let others in. */
+ @OptIn(ExperimentalUuidApi::class)
+ fun generate(): String {
+ val bytes = Uuid.random().toByteArray()
+ // 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform.
+ return buildString(LENGTH) {
+ repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) }
}
}
+ /**
+ * A participant's secret for one session, from a cryptographically secure source.
+ * The server derives the public participant id from it; only this device knows it.
+ */
+ @OptIn(ExperimentalUuidApi::class)
+ fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString()
+
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
fun normalize(input: String): String = input
.uppercase()
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt
index abeb2ff4..252db77f 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt
@@ -35,7 +35,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
@@ -55,7 +57,6 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.abs
-import kotlin.random.Random
import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_invalid_code
@@ -129,7 +130,6 @@ class JamRoomService(
private val _shuffleEnabled = MutableStateFlow(false)
val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow()
- private var localClientId: String = ""
private var localDisplayName: String = ""
/** Display name of the local participant. */
@@ -138,7 +138,7 @@ class JamRoomService(
/** Stable id of the local participant (stamped on items this device adds). */
val participantClientId: String
- get() = localClientId
+ get() = jamClient.selfId.orEmpty()
private var roleJob: Job? = null
private var leaving = false
@@ -175,6 +175,8 @@ class JamRoomService(
private var ownSeekUntilMs = 0L
private var entryChangedAtMs = 0L
+ private val anchorRequests = Channel(Channel.CONFLATED)
+
// The host's last anchor, to notice its own player drifting away from it.
private var sentAnchor: JamAnchor? = null
private var sentAnchorAtMs = 0L
@@ -222,8 +224,8 @@ class JamRoomService(
suspend fun createRoom(): Result {
val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server)))
- localClientId = newClientId()
- localDisplayName = participantName("Host")
+ val deviceKey = deviceKey()
+ localDisplayName = participantName(deviceKey)
enter()
// A fresh code is almost never taken; retry the rare collision with another one.
@@ -232,7 +234,9 @@ class JamRoomService(
var attempts = 0
do {
code = JamRoomCode.generate()
- result = jamClient.connect(serverUrl, code, localClientId, localDisplayName, create = true)
+ result = jamClient.connect(
+ serverUrl, code, jamParticipantKey(deviceKey, code), localDisplayName, create = true,
+ deviceDigest = jamDeviceDigest(code))
val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken"
} while (taken && ++attempts < CREATE_ATTEMPTS)
@@ -250,11 +254,13 @@ class JamRoomService(
if (!JamRoomCode.isValid(normalized)) {
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code)))
}
- localClientId = newClientId()
- localDisplayName = participantName("Guest")
+ val deviceKey = deviceKey()
+ localDisplayName = participantName(deviceKey)
enter()
- val welcome = jamClient.connect(serverUrl, normalized, localClientId, localDisplayName, create = false)
+ val welcome = jamClient.connect(
+ serverUrl, normalized, jamParticipantKey(deviceKey, normalized), localDisplayName, create = false,
+ deviceDigest = jamDeviceDigest(normalized))
.getOrElse {
inRoom = false
return Result.failure(it)
@@ -322,12 +328,12 @@ class JamRoomService(
}
suspend fun suggestTrack(track: MetadataTrack) {
- jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = localClientId))
+ jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = participantClientId))
}
suspend fun suggestPlaylist(tracks: List) {
if (tracks.isEmpty()) return
- jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = localClientId))
+ jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = participantClientId))
}
suspend fun kickParticipant(participantId: String) {
@@ -343,7 +349,7 @@ class JamRoomService(
/** The host applies its own controls; a guest's go to the host. */
private fun control(command: JamCommand) {
scope.launch {
- if (_role.value == JamRole.Host) applyCommand(command) else jamClient.sendCommand(command)
+ if (_role.value == JamRole.Host) applyCommand(command, from = participantClientId) else jamClient.sendCommand(command)
}
}
@@ -367,7 +373,8 @@ class JamRoomService(
anchor = event.anchor
if (_role.value == JamRole.Guest) onAnchor(event.anchor)
}
- is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command)
+ // [JamRoomEvent.Command.from] is the server's word for who sent it; the body is the sender's.
+ is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command, event.from)
is JamRoomEvent.Closed -> {
log.i { "Left jam room: ${event.code}" }
reset()
@@ -385,7 +392,7 @@ class JamRoomService(
private fun onMembership(host: String?, participants: List, force: Boolean = false) {
_participants.value = participants
- val role = if (host == localClientId) JamRole.Host else JamRole.Guest
+ val role = if (host != null && host == jamClient.selfId) JamRole.Host else JamRole.Guest
// A (re)welcome restarts the role so a guest re-applies the room's state.
if (role == _role.value && !force) return
log.i { "Jam role: ${_role.value} -> $role" }
@@ -406,16 +413,28 @@ class JamRoomService(
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
}
.distinctUntilChanged()
+ // At most one upload a second (the server's limit), always the latest queue.
+ .conflate()
.onEach { queue ->
_shuffleEnabled.value = queue.shuffleEnabled
// After a reconnect the server already has it: skip the re-upload.
if (queue != roomQueue) {
roomQueue = queue
jamClient.sendQueue(queue)
+ delay(QUEUE_MIN_GAP_MS)
}
}
.launchIn(this)
+ // Anchors are built when sent, so coalescing a burst of requests (rapid skips)
+ // loses nothing; it keeps the host under the server's frame budget.
+ launch {
+ for (request in anchorRequests) {
+ sendAnchor()
+ delay(ANCHOR_MIN_GAP_MS)
+ }
+ }
+
combine(
audioPlayerQueue.currentQueueEntryFlow,
playIntent,
@@ -464,7 +483,12 @@ class JamRoomService(
}
}
- private suspend fun publishAnchor() {
+ /** Asks the host loop to send a fresh anchor (coalesced, see [runHost]). */
+ private fun publishAnchor() {
+ anchorRequests.trySend(Unit)
+ }
+
+ private suspend fun sendAnchor() {
if (_role.value != JamRole.Host) return
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
val playing = playIntent.value
@@ -491,7 +515,7 @@ class JamRoomService(
?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
}
- private suspend fun applyCommand(command: JamCommand) {
+ private suspend fun applyCommand(command: JamCommand, from: String) {
runCatching {
when (command) {
// Applied locally; the anchor carries the change to every guest.
@@ -503,15 +527,21 @@ class JamRoomService(
seek(command.positionMs)
publishAnchor()
}
- is JamCommand.Suggest -> acceptSuggestion(command.items.map { it.copy(addedBy = command.addedBy) })
+ is JamCommand.Suggest -> acceptSuggestion(command.items, from)
}
}.onFailure { log.w(it) { "Failed to apply jam command $command" } }
}
- private suspend fun acceptSuggestion(items: List) {
- if (items.isEmpty()) return
- log.i { "Accepting ${items.size} suggested item(s) into the jam queue" }
- audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() })
+ /** Suggestions come from other devices: only clean streaming items, credited to the actual sender. */
+ private suspend fun acceptSuggestion(items: List, from: String) {
+ val accepted = items.asSequence()
+ .mapNotNull { it.sanitized() }
+ .take(MAX_SUGGESTED_ITEMS)
+ .map { it.copy(addedBy = from) }
+ .toList()
+ if (accepted.isEmpty()) return
+ log.i { "Accepting ${accepted.size} suggested item(s) into the jam queue" }
+ audioPlayerQueue.addAllToQueue(accepted.map { it.toQueueEntry() })
}
// ---------- Guest ----------
@@ -575,7 +605,8 @@ class JamRoomService(
private suspend fun applyQueue(queue: JamQueue) {
roomVersion.value++
_shuffleEnabled.value = queue.shuffleEnabled
- val items = queue.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
+ // The host's local files can't play here, and anything else is cleaned first.
+ val items = queue.items.mapNotNull { it.sanitized() }
if (items == lastAppliedItems) return
val previous = lastAppliedItems
lastAppliedItems = items
@@ -747,7 +778,7 @@ class JamRoomService(
// The user seeked: it applies to the whole room.
when (_role.value) {
- JamRole.Host -> scope.launch { publishAnchor() }
+ JamRole.Host -> publishAnchor()
JamRole.Guest -> {
holdSync(SEEK_HOLD_MS * 2)
jamClient.sendCommand(JamCommand.SeekTo(positionMs))
@@ -761,15 +792,29 @@ class JamRoomService(
private fun serverUrl(): String? =
settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null }
- private fun participantName(fallbackPrefix: String): String {
- val configured = settingsRepository.userSettings.value.jamParticipantName
- return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" }
+ private suspend fun participantName(deviceKey: String): String =
+ JamNames.normalize(settingsRepository.userSettings.value.jamParticipantName)
+ .take(JamNames.MAX_LENGTH)
+ .ifBlank { JamNames.generate(deviceKey) }
+
+ /** The name this device gets when the user hasn't picked one; the same every time. */
+ suspend fun defaultParticipantName(): String = JamNames.generate(deviceKey())
+
+ /** Saves the user's own name; blank goes back to [defaultParticipantName]. */
+ suspend fun setParticipantName(name: String) {
+ val settings = settingsRepository.userSettings.value
+ settingsRepository.updateSettings(
+ settings.copy(jamParticipantName = JamNames.normalize(name).take(JamNames.MAX_LENGTH)))
}
- private fun newClientId(): String = buildString {
- append("sp-")
- val chars = "0123456789abcdef"
- repeat(12) { append(chars[Random.nextInt(chars.length)]) }
+ /** This install's jam secret, created on first use. */
+ private suspend fun deviceKey(): String {
+ val settings = settingsRepository.userSettings.value
+ if (settings.jamDeviceKey.isNotBlank()) return settings.jamDeviceKey
+ val generated = JamRoomCode.newClientKey()
+ runCatching { settingsRepository.updateSettings(settings.copy(jamDeviceKey = generated)) }
+ .onFailure { log.w(it) { "Failed to save the jam device key" } }
+ return generated
}
private fun persistLastCode(code: String) {
@@ -785,7 +830,7 @@ class JamRoomService(
private fun QueueEntry.jamKey(): String = when (this) {
is QueueEntry.StreamingTrack -> track.id
- is QueueEntry.LocalTrack -> "local:$url"
+ is QueueEntry.LocalTrack -> JamMediaItem.localTrackKey(url)
}
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
@@ -827,6 +872,10 @@ class JamRoomService(
companion object {
private const val CREATE_ATTEMPTS = 3
+ /** Keeps the host under the server's budgets (see `jam-server/src/protocol.ts`). */
+ private const val QUEUE_MIN_GAP_MS = 1_000L
+ private const val ANCHOR_MIN_GAP_MS = 250L
+ private const val MAX_SUGGESTED_ITEMS = 500
private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L
private const val HOST_DRIFT_WINDOW = 5
/** Below the guests' dead band, so the host's own error never adds up to a correction. */
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt
index d9964f27..9c827d29 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt
@@ -62,6 +62,7 @@ import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.core.ui.theming.AppTheme
+import org.spotube.spotube.modules.settings.sections.JamNameSettingCard
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxSetting2
@@ -138,6 +139,15 @@ fun JamScreen(navigationCommands: NavigationCommands) {
viewModel::createSession,
viewModel::joinSession
)
+ // Pick a name before starting or joining, without a detour through Settings.
+ JamPanel {
+ JamNameSettingCard(
+ customName = state.customName,
+ defaultName = state.defaultName,
+ onNameSaved = viewModel::setParticipantName,
+ enabled = !state.isBusy
+ )
+ }
}
ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
}
@@ -412,7 +422,11 @@ internal fun SessionPanel(
}
Column(Modifier.weight(1f)) {
Text(
- participant.displayName,
+ if (participant.isYou) {
+ stringResource(Res.string.jam_participants_you, participant.displayName)
+ } else {
+ participant.displayName
+ },
style = AppTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt
index 42fc524a..97799ccb 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt
@@ -48,6 +48,7 @@ data class JamParticipantUi(
val initial: String,
val isHost: Boolean,
val canModerate: Boolean,
+ val isYou: Boolean = false,
val menuExpanded: Boolean = false
)
@@ -68,7 +69,11 @@ data class JamUiState(
val isLeaving: Boolean = false,
val isHost: Boolean = false,
val supportsNativeShare: Boolean = false,
- val codeCopied: Boolean = false
+ val codeCopied: Boolean = false,
+ /** The name the user picked (blank when they haven't). */
+ val customName: String = "",
+ /** This device's generated name, used when [customName] is blank. */
+ val defaultName: String = ""
)
class JamViewModel(
@@ -85,6 +90,7 @@ class JamViewModel(
private val codeInput = TextFieldState()
private var copyFeedbackJob: Job? = null
private val participantMenu = MutableStateFlow(null)
+ private val defaultName = MutableStateFlow("")
val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
@@ -102,7 +108,8 @@ class JamViewModel(
codeCopied,
snapshotFlow { codeInput.text.toString() },
participantMenu,
- isLeaving
+ isLeaving,
+ defaultName
) { values ->
@Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole?
@@ -128,6 +135,7 @@ class JamViewModel(
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
isHost = participant.isHost,
canModerate = role == JamRole.Host && !participant.isHost,
+ isYou = participant.id == jamRoomService.participantClientId,
menuExpanded = values[11] == participant.id
)
},
@@ -145,7 +153,9 @@ class JamViewModel(
canJoin = serverConfigured &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host,
- supportsNativeShare = supportsNativeShare
+ supportsNativeShare = supportsNativeShare,
+ customName = settings?.jamParticipantName.orEmpty(),
+ defaultName = values[13] as String
)
}.stateIn(
viewModelScope,
@@ -153,6 +163,14 @@ class JamViewModel(
JamUiState(codeInput = codeInput)
)
+ init {
+ viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() }
+ }
+
+ fun setParticipantName(name: String) {
+ viewModelScope.launch { jamRoomService.setParticipantName(name) }
+ }
+
// ---------- Session lifecycle ----------
fun createSession() {
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt
index 5ac5953d..295b7536 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
+import org.spotube.spotube.modules.settings.sections.JamNameSettingCard
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
@@ -33,6 +34,7 @@ import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_connection_failed
import spotube.composeapp.generated.resources.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
+import spotube.composeapp.generated.resources.settings_jam_section_you
import spotube.composeapp.generated.resources.settings_jam_server_diagnostics_subtitle
import spotube.composeapp.generated.resources.settings_jam_server_note
import spotube.composeapp.generated.resources.settings_jam_server_test
@@ -77,6 +79,15 @@ internal fun JamSettingsContent(
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter),
contentPadding = contentPadding) {
+ settingsSectionHeader(Res.string.settings_jam_section_you)
+ settingsSectionCard(
+ items = listOf(
+ {
+ JamNameSettingCard(
+ customName = uiState.settings.jamParticipantName,
+ defaultName = uiState.defaultName,
+ onNameSaved = viewModel::setParticipantName)
+ }))
settingsSectionHeader(Res.string.settings_jam_section_connection)
settingsSectionCard(
items = listOf(
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt
index 2cfc3ad7..ea58bd38 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt
@@ -3,6 +3,7 @@ package org.spotube.spotube.modules.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.jam.JamRoomClient
+import org.spotube.spotube.core.jam.JamRoomService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,15 +13,22 @@ import kotlinx.coroutines.launch
data class JamSettingsUiState(
val settings: UserSettings = UserSettings(),
val isTesting: Boolean = false,
- val testResult: Result? = null)
+ val testResult: Result? = null,
+ /** This device's generated jam name, used while the user hasn't picked one. */
+ val defaultName: String = "")
class JamSettingsViewModel(
private val settingsRepository: SettingsRepository,
- private val jamRoomClient: JamRoomClient) : ViewModel() {
+ private val jamRoomClient: JamRoomClient,
+ private val jamRoomService: JamRoomService) : ViewModel() {
private val _uiState = MutableStateFlow(JamSettingsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
init {
+ viewModelScope.launch {
+ val name = jamRoomService.defaultParticipantName()
+ _uiState.update { it.copy(defaultName = name) }
+ }
viewModelScope.launch {
settingsRepository.userSettings.collect { settings ->
_uiState.update { it.copy(settings = settings) }
@@ -28,6 +36,10 @@ class JamSettingsViewModel(
}
}
+ fun setParticipantName(name: String) {
+ viewModelScope.launch { jamRoomService.setParticipantName(name) }
+ }
+
/** A blank URL goes back to the public server. */
fun setServerUrl(url: String) {
viewModelScope.launch {
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt
index 8b7aca13..5b93e6e7 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt
@@ -64,6 +64,8 @@ data class UserSettings(
/** Room server (`jam-server/`); self-hosters point this at their own deployment. */
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
val lastJamCode: String = "",
+ /** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */
+ val jamDeviceKey: String = "",
// Downloads
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt
new file mode 100644
index 00000000..843c3402
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.modules.settings.sections
+
+import androidx.compose.runtime.Composable
+import org.jetbrains.compose.resources.stringResource
+import org.spotube.spotube.core.jam.JamNames
+import org.spotube.spotube.modules.settings.components.TextInputSettingCard
+import org.spotube.spotube.resources.iconsax.Iconsax
+import org.spotube.spotube.resources.iconsax.IconsaxEdit
+import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.jam_name_description
+import spotube.composeapp.generated.resources.jam_name_subtitle
+import spotube.composeapp.generated.resources.jam_name_title
+import spotube.composeapp.generated.resources.jam_name_too_long
+
+/**
+ * "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay
+ * identical, like the remote control device name card.
+ *
+ * [customName] is what the user saved (blank = none), [defaultName] the generated one.
+ */
+@Composable
+internal fun JamNameSettingCard(
+ customName: String,
+ defaultName: String,
+ onNameSaved: (String) -> Unit,
+ enabled: Boolean = true
+) {
+ val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH)
+ TextInputSettingCard(
+ title = stringResource(Res.string.jam_name_title),
+ subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }),
+ icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) },
+ value = customName,
+ dialogDescription = stringResource(Res.string.jam_name_description, defaultName),
+ placeholder = defaultName,
+ normalize = JamNames::normalize,
+ validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null },
+ onValueSaved = onNameSaved,
+ enabled = enabled
+ )
+}
diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt
new file mode 100644
index 00000000..ab3b9691
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class JamNamesTest {
+ @Test
+ fun sameSeedSameName() = runTest {
+ assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a"))
+ }
+
+ @Test
+ fun namesAreTwoFriendlyWordsAndVaried() = runTest {
+ val names = List(60) { JamNames.generate("device-$it") }
+ assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString())
+ assertTrue(names.all { it.length <= JamNames.MAX_LENGTH })
+ assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names")
+ }
+
+ @Test
+ fun typedNamesAreCleaned() {
+ assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n"))
+ assertEquals("", JamNames.normalize(" "))
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt
new file mode 100644
index 00000000..bc8cfcbb
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class JamSecurityTest {
+ private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") =
+ JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B",
+ durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE")
+
+ @Test
+ fun peerItemsWithoutATrackIdAreDropped() {
+ assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized())
+ assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized())
+ }
+
+ @Test
+ fun peerItemsLoseUrlsAndNonHttpsCovers() {
+ val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!!
+ assertEquals("", clean.url)
+ assertEquals("", clean.coverUrl)
+ assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl)
+ }
+
+ @Test
+ fun peerTextIsBounded() {
+ val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!!
+ assertTrue(clean.title.length <= 300)
+ assertEquals(0, clean.durationMs)
+ }
+
+ @Test
+ fun localTrackKeysDontRevealPaths() {
+ val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")
+ assertFalse("alice" in key || "Music" in key)
+ assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3"))
+ assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3"))
+ }
+
+ @Test
+ fun clientKeysAreLongRandomAndAcceptedByTheServer() {
+ val keys = List(100) { JamRoomCode.newClientKey() }
+ assertEquals(keys.size, keys.toSet().size)
+ // Mirrors CLIENT_KEY in jam-server/src/protocol.ts.
+ val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$")
+ assertTrue(keys.all { serverPattern.matches(it) })
+ }
+
+ @Test
+ fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() {
+ val device = JamRoomCode.newClientKey()
+ assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF"))
+ assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG"))
+ assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF"))
+ assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF")))
+ }
+
+ @Test
+ fun deviceDigestsRevealNothingAndDifferPerRoom() {
+ val digest = jamDeviceDigest("ABCDEF")!!
+ assertEquals(digest, jamDeviceDigest("ABCDEF"))
+ assertNotEquals(digest, jamDeviceDigest("ABCDEG"))
+ assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest })
+ // Mirrors DEVICE_DIGEST in jam-server/src/protocol.ts.
+ assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest))
+ }
+
+ @Test
+ fun roomCodesAreValid() {
+ repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) }
+ }
+}
diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt
new file mode 100644
index 00000000..ff45cbf5
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import platform.Foundation.NSLocale
+import platform.Foundation.NSTimeZone
+import platform.Foundation.currentLocale
+import platform.Foundation.localTimeZone
+import platform.Foundation.localeIdentifier
+import platform.UIKit.UIDevice
+
+internal actual fun jamDeviceTraits(): List {
+ val device = UIDevice.currentDevice
+ return listOf(
+ "ios",
+ device.model,
+ device.systemName(),
+ device.systemVersion,
+ NSLocale.currentLocale.localeIdentifier,
+ NSTimeZone.localTimeZone.name)
+}
diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt
new file mode 100644
index 00000000..c37e8a47
--- /dev/null
+++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package org.spotube.spotube.core.jam
+
+import java.util.Locale
+import java.util.TimeZone
+
+internal actual fun jamDeviceTraits(): List = listOf(
+ "desktop",
+ System.getProperty("os.name").orEmpty(),
+ System.getProperty("os.version").orEmpty(),
+ System.getProperty("os.arch").orEmpty(),
+ Runtime.getRuntime().availableProcessors().toString(),
+ Locale.getDefault().toLanguageTag(),
+ TimeZone.getDefault().id)
diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt
index dcc7ed86..90bdce47 100644
--- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt
+++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt
@@ -46,8 +46,9 @@ class JamRoomClientIntegrationTest {
try {
host.testConnection(url).getOrThrow()
- val hostWelcome = host.connect(url, code, "it-host-1", "Host", create = true).getOrThrow()
- assertEquals("it-host-1", hostWelcome.host)
+ val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow()
+ val hostId = hostWelcome.you
+ assertEquals(hostId, hostWelcome.host)
// Same machine: the clock offset is just measurement noise.
assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}")
@@ -59,22 +60,24 @@ class JamRoomClientIntegrationTest {
Thread.sleep(300)
// Late joiners get the retained queue and anchor in the welcome.
- val guestWelcome = guest.connect(url, code, "it-guest-1", "Guest", create = false).getOrThrow()
+ val guestKey = JamRoomCode.newClientKey()
+ val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow()
+ val guestId = guestWelcome.you
assertEquals(queue, guestWelcome.queue)
assertEquals(anchor, guestWelcome.anchor)
- assertEquals(listOf("it-host-1", "it-guest-1"), guestWelcome.participants.map { it.id })
+ assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id })
val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance().first() }
guest.sendCommand(JamCommand.SeekTo(42_000))
val command = withTimeout(5_000) { received.await() }
- assertEquals("it-guest-1", command.from)
+ assertEquals(guestId, command.from)
assertEquals(JamCommand.SeekTo(42_000), command.command)
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance().first() }
- host.kick("it-guest-1", ban = false)
+ host.kick(guestId, ban = false)
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
- val wrongRoom = guest.connect(url, JamRoomCode.generate(), "it-guest-1", "Guest", create = false)
+ val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false)
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
} finally {
host.disconnect()
diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt
index 60c1a888..88c33db8 100644
--- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt
+++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt
@@ -130,10 +130,17 @@ class JamSessionSimulationTest {
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value)
assertInSync(host, guest, "after the skip")
+ // Mashing skip stays within the server's flood limits (the host isn't cut off).
+ repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() }
+ delay(6_000)
+ assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip")
+ assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips")
+ assertInSync(host, guest, "after mashing skip")
+
// Steady playback costs (almost) nothing: count the anchors a listener receives.
val listener = JamRoomClient(http)
try {
- listener.connect(url, code, "it-listener", "Listener", create = false).getOrThrow()
+ listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow()
delay(1_000) // The host answers the join with an anchor.
var anchors = 0
val counting = launch {
diff --git a/jam-server/README.md b/jam-server/README.md
index c6f7b5e0..cf7c020d 100644
--- a/jam-server/README.md
+++ b/jam-server/README.md
@@ -35,6 +35,51 @@ A four-person room sends roughly 200 messages an hour (about 10 billed requests)
The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is
`composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`.
+## Security
+
+Anyone who knows a room code can join it, as with any party-code app. Everything else
+is enforced here, not in the app:
+
+- **Identity**: each install keeps a random secret, and its key for a room is derived
+ from that secret and the room code. Everyone else only sees an id derived from the key
+ (`participantId`, different in every room). Knowing someone's id, the host's included,
+ doesn't let you connect as them, so nobody can take over hosting, kick people or replace
+ someone's connection.
+- **Host powers**: only the current host can set the queue and anchor or kick and ban.
+ Guests can only send commands, and those go to the host, which decides what to apply.
+- **Taking over rooms**: "create" only works on a fresh code, or for the room's own host
+ bringing back a room that expired while everyone was offline.
+- **Abuse and cost**: connects are rate-limited per IP (60 a minute; also slows down code
+ guessing). Each socket has a message budget and a queue budget (`FRAME_BUDGET`,
+ `QUEUE_BUDGET`), and a client that floods is disconnected with `rate_limited`.
+ Frames over 512 KB are refused, and names are cleaned of control characters.
+- **What the app does with peer data**: queue items and suggestions from other devices
+ are cleaned before use (`JamMediaItem.sanitized`): streaming tracks only, no URLs, only
+ https covers. The host credits suggestions to the sender the server reports, not to
+ whatever name the sender claims. The host's local files go out as an opaque id, never
+ as a path.
+- **Privacy**: participants never see each other's IP addresses, since everything goes
+ through the room. The room keeps no history: the queue and bans are deleted 10 minutes
+ after the last person leaves.
+
+Known limits:
+
+- **Bans**: a ban covers the identity (the same install gets the same identity in a
+ room every time) and the device. The app sends a digest of stable device traits
+ (model, OS version, locale, …) hashed with the room code. The server hashes that again
+ with the network (IPv4 address or IPv6 /64), so the room only stores an opaque value
+ that means nothing outside it. Someone who reinstalls and rejoins from the same device
+ and network is refused. People who were already in the room aren't affected, so a
+ friend on the same Wi-Fi with the same phone is never caught by someone else's ban.
+ Ways around it: a different device or network, or a modified client. A new arrival
+ with exactly the same phone, OS version and settings on the banned person's network
+ would also be refused.
+- **Room codes**: six characters, about a billion codes. That's fine against guessing
+ at the rate limit, but a code someone sees on your screen lets them in.
+- **Spending**: on the paid plan, a determined attacker with many IPs can still run up
+ usage. Set a usage alert in the Cloudflare dashboard. The free plan just stops at its
+ daily limit (00:00 UTC reset) instead of billing.
+
## Develop
```sh
diff --git a/jam-server/src/index.ts b/jam-server/src/index.ts
index 5a9e27fa..c5af9e95 100644
--- a/jam-server/src/index.ts
+++ b/jam-server/src/index.ts
@@ -1,4 +1,4 @@
-import { CLIENT_ID, MAX_NAME_LENGTH, PROTOCOL_VERSION, ROOM_CODE } from "./protocol";
+import { CLIENT_KEY, DEVICE_DIGEST, PROTOCOL_VERSION, ROOM_CODE, cleanName, deviceMark, participantId } from "./protocol";
export { JamRoom } from "./room";
@@ -7,7 +7,10 @@ const JSON_HEADERS = { "content-type": "application/json" };
/**
* Routes:
* - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection")
- * - `GET /v1/rooms/{CODE}?id=&name=&mode=create|join` (WebSocket) → the room's Durable Object
+ * - `GET /v1/rooms/{CODE}?key=&name=&mode=create|join[&dev=]` (WebSocket) → the room's Durable Object
+ *
+ * Durable Objects aren't reachable from the internet, only through this Worker, so the
+ * room trusts the `id` and `name` this Worker hands it.
*/
export default {
async fetch(request, env): Promise {
@@ -25,10 +28,17 @@ export default {
}
const code = match[1].toUpperCase();
- const id = url.searchParams.get("id") ?? "";
- const name = (url.searchParams.get("name") ?? "").trim().slice(0, MAX_NAME_LENGTH);
+ const key = url.searchParams.get("key") ?? "";
+ const name = cleanName(url.searchParams.get("name") ?? "");
const mode = url.searchParams.get("mode");
- if (!ROOM_CODE.test(code) || !CLIENT_ID.test(id) || !name || (mode !== "create" && mode !== "join")) {
+ const dev = url.searchParams.get("dev");
+ if (
+ !ROOM_CODE.test(code) ||
+ !CLIENT_KEY.test(key) ||
+ !name ||
+ (mode !== "create" && mode !== "join") ||
+ (dev !== null && !DEVICE_DIGEST.test(dev))
+ ) {
return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS });
}
@@ -37,7 +47,12 @@ export default {
if (!success) return new Response("Too many connections", { status: 429 });
const forwarded = new URL(request.url);
- forwarded.search = new URLSearchParams({ id, name, mode }).toString();
+ forwarded.search = new URLSearchParams({
+ id: await participantId(code, key),
+ name,
+ mode,
+ mark: (await deviceMark(code, dev, ip)) ?? "",
+ }).toString();
const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code));
return room.fetch(new Request(forwarded, request));
},
diff --git a/jam-server/src/protocol.ts b/jam-server/src/protocol.ts
index 4aaefb87..b59ce813 100644
--- a/jam-server/src/protocol.ts
+++ b/jam-server/src/protocol.ts
@@ -10,11 +10,30 @@ export const PROTOCOL_VERSION = 1;
/** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */
export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
-export const CLIENT_ID = /^[A-Za-z0-9_-]{4,64}$/;
+/**
+ * A participant's secret, chosen by the client (at least 128 random bits). The server
+ * never shares it: everyone else only sees the id derived from it ([participantId]),
+ * so knowing someone's id doesn't let you reconnect as them.
+ */
+export const CLIENT_KEY = /^[A-Za-z0-9_-]{22,64}$/;
export const MAX_NAME_LENGTH = 40;
export const MAX_PARTICIPANTS = 32;
/** A long queue with cover URLs is the largest frame, a few hundred KB at most. */
export const MAX_FRAME_BYTES = 512 * 1024;
+export const MAX_BANS = 256;
+/** Everyone who has been in the room, to tell a returning member from a new arrival. */
+export const MAX_MEMBERS = 512;
+
+/** Optional per-room device digest from the client (it never sends device details). */
+export const DEVICE_DIGEST = /^[A-Za-z0-9_-]{16,64}$/;
+
+/**
+ * Per-socket token buckets. Every incoming message is billed, so a client that floods
+ * is disconnected. Real clients stay far below: a few frames a minute, bursts of a
+ * dozen when skipping quickly, and the host sends at most one queue a second.
+ */
+export const FRAME_BUDGET = { burst: 40, perSecond: 6 };
+export const QUEUE_BUDGET = { burst: 5, perSecond: 1 };
/**
* A host that drops keeps the room for this long before someone else takes over (long
@@ -70,7 +89,8 @@ export type ErrorCode =
| "banned"
| "kicked"
| "replaced"
- | "frame_too_large";
+ | "frame_too_large"
+ | "rate_limited";
/** WebSocket close codes the app maps back to an [ErrorCode]. */
export const CLOSE_CODES: Record = {
@@ -82,4 +102,42 @@ export const CLOSE_CODES: Record = {
kicked: 4008,
replaced: 4009,
frame_too_large: 4013,
+ rate_limited: 4029,
};
+
+/** First 96 bits of SHA-256, base64url. */
+async function shortHash(text: string): Promise {
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
+ return btoa(String.fromCharCode(...new Uint8Array(digest).slice(0, 12)))
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+}
+
+/** The public id for [key] in room [code]: a different id per room, so ids can't link rooms. */
+export function participantId(code: string, key: string): Promise {
+ return shortHash(`${code}:${key}`);
+}
+
+/**
+ * What a ban remembers about a device besides its participant id: the client's device
+ * digest combined with its network, hashed again here so the room only ever stores an
+ * opaque value. Null without a digest.
+ */
+export function deviceMark(code: string, digest: string | null, ip: string): Promise | null {
+ return digest ? shortHash(`${code}:${digest}:${networkOf(ip)}`) : null;
+}
+
+/** An IPv4 address, or an IPv6 /64 (one home or phone gets a whole /64). */
+export function networkOf(ip: string): string {
+ if (!ip.includes(":")) return ip;
+ const [head, tail] = ip.split("::");
+ const left = head ? head.split(":") : [];
+ const right = tail ? tail.split(":") : [];
+ const groups = tail === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
+ return groups.slice(0, 4).map((g) => parseInt(g || "0", 16).toString(16)).join(":") + "::/64";
+}
+
+/** Names are shown to everyone: no control or formatting characters, no padding. */
+export function cleanName(raw: string): string {
+ return raw.replace(/[\p{C}]/gu, "").replace(/\s+/gu, " ").trim().slice(0, MAX_NAME_LENGTH);
+}
diff --git a/jam-server/src/room.ts b/jam-server/src/room.ts
index 72496412..207fad2e 100644
--- a/jam-server/src/room.ts
+++ b/jam-server/src/room.ts
@@ -4,10 +4,14 @@ import {
KEEPALIVE_PING,
KEEPALIVE_PONG,
EMPTY_ROOM_TTL_MS,
+ FRAME_BUDGET,
HOST_GRACE_MS,
+ MAX_BANS,
+ MAX_MEMBERS,
MAX_FRAME_BYTES,
MAX_PARTICIPANTS,
PROTOCOL_VERSION,
+ QUEUE_BUDGET,
type ClientFrame,
type ErrorCode,
type JoinMode,
@@ -15,17 +19,35 @@ import {
type ServerFrame,
} from "./protocol";
+interface Bucket {
+ tokens: number;
+ at: number;
+}
+
+/** Takes one token; false when [bucket] is empty. */
+function spend(bucket: Bucket, budget: { burst: number; perSecond: number }, now: number): boolean {
+ bucket.tokens = Math.min(budget.burst, bucket.tokens + ((now - bucket.at) / 1000) * budget.perSecond);
+ bucket.at = now;
+ if (bucket.tokens < 1) return false;
+ bucket.tokens -= 1;
+ return true;
+}
+
/** Kept on each socket so it survives hibernation. */
interface Attachment {
id: string;
name: string;
joined: number;
+ /** See [deviceMark]; empty when the client sent no device digest. */
+ mark: string;
}
const enum Key {
Host = "host",
Queue = "queue",
Banned = "banned",
+ BannedMarks = "bannedMarks",
+ Members = "members",
HostGoneAt = "hostGoneAt",
EmptySince = "emptySince",
}
@@ -46,6 +68,10 @@ const enum Key {
*/
export class JamRoom extends DurableObject {
private anchor: unknown = null;
+ // In memory: a room that hibernated starts everyone with a full budget again, which
+ // only matters to a client that floods, and that one wakes the room right away.
+ private frameBudgets = new WeakMap();
+ private queueBudgets = new WeakMap();
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
@@ -57,16 +83,17 @@ export class JamRoom extends DurableObject {
const id = url.searchParams.get("id")!;
const name = url.searchParams.get("name")!;
const mode = url.searchParams.get("mode") as JoinMode;
+ const mark = url.searchParams.get("mark") ?? "";
const { 0: client, 1: server } = new WebSocketPair();
const previous = this.ctx.getWebSockets(id);
this.ctx.acceptWebSocket(server, [id]);
- server.serializeAttachment({ id, name, joined: Date.now() } satisfies Attachment);
+ server.serializeAttachment({ id, name, joined: Date.now(), mark } satisfies Attachment);
// A reconnect with the same id (network change, app restart) takes over the old socket.
for (const ws of previous) this.reject(ws, "replaced");
- const refusal = await this.admit(id, mode);
+ const refusal = await this.admit(id, mode, mark);
if (refusal) {
this.reject(server, refusal);
} else {
@@ -94,6 +121,7 @@ export class JamRoom extends DurableObject {
const size = typeof message === "string" ? message.length : message.byteLength;
if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large");
if (typeof message !== "string") return;
+ if (!this.withinBudget(this.frameBudgets, ws, FRAME_BUDGET)) return this.reject(ws, "rate_limited");
let frame: ClientFrame;
try {
@@ -110,6 +138,7 @@ export class JamRoom extends DurableObject {
case "queue":
if (!(await this.isHost(sender.id))) return;
+ if (!this.withinBudget(this.queueBudgets, ws, QUEUE_BUDGET)) return this.reject(ws, "rate_limited");
await this.ctx.storage.put(Key.Queue, frame.body);
this.broadcast({ t: "queue", body: frame.body }, ws);
return;
@@ -133,12 +162,7 @@ export class JamRoom extends DurableObject {
case "kick": {
if (!(await this.isHost(sender.id)) || frame.target === sender.id) return;
- if (frame.ban) {
- const banned = (await this.ctx.storage.get(Key.Banned)) ?? [];
- if (!banned.includes(frame.target)) {
- await this.ctx.storage.put(Key.Banned, [...banned, frame.target]);
- }
- }
+ if (frame.ban) await this.ban(frame.target);
for (const target of this.ctx.getWebSockets(frame.target)) {
this.reject(target, frame.ban ? "banned" : "kicked");
}
@@ -183,16 +207,32 @@ export class JamRoom extends DurableObject {
// ---------- Membership ----------
- private async admit(id: string, mode: JoinMode): Promise {
- const others = this.live().filter((ws) => this.attachment(ws).id !== id);
+ /**
+ * Bans [id], and the device it's on: someone who comes back with a fresh identity from
+ * the same device and network is refused too, unless they were already a member.
+ */
+ private async ban(id: string): Promise {
+ const marks = this.ctx
+ .getWebSockets(id)
+ .map((ws) => this.attachment(ws).mark)
+ .filter((mark) => mark);
+ const [banned, bannedMarks] = await Promise.all([
+ this.ctx.storage.get(Key.Banned),
+ this.ctx.storage.get(Key.BannedMarks),
+ ]);
+ await this.ctx.storage.put({
+ [Key.Banned]: [...new Set([...(banned ?? []), id])].slice(-MAX_BANS),
+ [Key.BannedMarks]: [...new Set([...(bannedMarks ?? []), ...marks])].slice(-MAX_BANS),
+ });
+ }
- if (mode === "create") {
- if (others.length > 0) return "room_taken";
- // Leftovers of an expired-but-not-yet-cleaned room must not leak into a new one.
+ private async admit(id: string, mode: JoinMode, mark: string): Promise {
+ const others = this.live().filter((ws) => this.attachment(ws).id !== id);
+ // An empty room past its TTL whose cleanup alarm hasn't run yet is gone.
+ const emptySince = await this.ctx.storage.get(Key.EmptySince);
+ if (others.length === 0 && emptySince !== undefined && Date.now() - emptySince >= EMPTY_ROOM_TTL_MS) {
await this.ctx.storage.deleteAll();
this.anchor = null;
- await this.ctx.storage.put(Key.Host, id);
- return null;
}
const [host, banned, queue] = await Promise.all([
@@ -200,9 +240,27 @@ export class JamRoom extends DurableObject {
this.ctx.storage.get(Key.Banned),
this.ctx.storage.get(Key.Queue),
]);
- if (host === undefined && queue === undefined && others.length === 0) return "room_not_found";
+ const exists = host !== undefined || queue !== undefined || others.length > 0;
+
+ if (mode === "create") {
+ // Only a fresh code, or the room's own host bringing it back. Anyone else
+ // "creating" an existing room (even an empty one) would take it over.
+ if (others.length > 0 || (exists && host !== id)) return "room_taken";
+ if (!exists) await this.ctx.storage.put({ [Key.Host]: id, [Key.Members]: [id] });
+ return null;
+ }
+
+ if (!exists) return "room_not_found";
if (banned?.includes(id)) return "banned";
+ const members = (await this.ctx.storage.get(Key.Members)) ?? [];
+ const isMember = members.includes(id);
+ // A member who was in the room before a ban (a friend on the same Wi-Fi with the
+ // same phone, say) is never caught by another person's device ban.
+ if (mark && !isMember && (await this.ctx.storage.get(Key.BannedMarks))?.includes(mark)) {
+ return "banned";
+ }
if (others.length >= MAX_PARTICIPANTS) return "room_full";
+ if (!isMember) await this.ctx.storage.put(Key.Members, [...members, id].slice(-MAX_MEMBERS));
// Joining a room whose host is gone (and whose grace period is over) promotes someone.
const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host);
@@ -276,6 +334,20 @@ export class JamRoom extends DurableObject {
.map(({ id, name }) => ({ id, name }));
}
+ private withinBudget(
+ buckets: WeakMap,
+ ws: WebSocket,
+ budget: { burst: number; perSecond: number },
+ ): boolean {
+ const now = Date.now();
+ let bucket = buckets.get(ws);
+ if (!bucket) {
+ bucket = { tokens: budget.burst, at: now };
+ buckets.set(ws, bucket);
+ }
+ return spend(bucket, budget, now);
+ }
+
private attachment(ws: WebSocket): Attachment {
return ws.deserializeAttachment() as Attachment;
}
diff --git a/jam-server/test/smoke.mjs b/jam-server/test/smoke.mjs
index a2d080dd..d6736dc5 100644
--- a/jam-server/test/smoke.mjs
+++ b/jam-server/test/smoke.mjs
@@ -6,9 +6,12 @@ const BASE = process.env.JAM_URL ?? "http://127.0.0.1:8787";
const WS_BASE = BASE.replace(/^http/, "ws");
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
+const newKey = () => crypto.randomUUID().replaceAll("-", "");
-function connect(room, id, mode, name = id) {
- const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?id=${id}&name=${encodeURIComponent(name)}&mode=${mode}`);
+/** [id] is only a label for errors; the server assigns the real id (welcome.you). */
+function connect(room, id, mode, name = id, key = newKey(), dev = null) {
+ const devParam = dev ? `&dev=${dev}` : "";
+ const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?key=${key}&name=${encodeURIComponent(name)}&mode=${mode}${devParam}`);
const inbox = [];
const waiters = [];
ws.addEventListener("message", (e) => {
@@ -20,6 +23,7 @@ function connect(room, id, mode, name = id) {
const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code)));
return {
ws,
+ key,
closed,
send: (frame) => ws.send(JSON.stringify(frame)),
next(t, pred = () => true, timeoutMs = 5_000) {
@@ -46,12 +50,14 @@ await step("health", async () => {
});
const room = code();
-const host = connect(room, "host-0001", "create", "Host");
-let guest;
+const host = connect(room, "host", "create", "Host");
+let guest, hostId, guestId;
await step("host creates the room", async () => {
const welcome = await host.next("welcome");
- assert.equal(welcome.host, "host-0001");
+ hostId = welcome.you;
+ assert.match(hostId, /^[A-Za-z0-9_-]{16}$/);
+ assert.equal(welcome.host, hostId);
host.send({ t: "queue", body: { items: [1, 2, 3] } });
});
@@ -59,11 +65,13 @@ await step("guest joins and gets the retained queue", async () => {
// The host's queue frame is processed before the guest's join is.
host.send({ t: "ping", id: 1, c: 0 });
await host.next("pong");
- guest = connect(room, "guest-0001", "join", "Guest");
+ guest = connect(room, "guest", "join", "Guest\u202e\u0000 1");
const welcome = await guest.next("welcome");
- assert.equal(welcome.host, "host-0001");
+ guestId = welcome.you;
+ assert.equal(welcome.host, hostId);
assert.deepEqual(welcome.queue, { items: [1, 2, 3] });
- assert.ok(welcome.participants.some((p) => p.id === "guest-0001"));
+ const me = welcome.participants.find((p) => p.id === guestId);
+ assert.equal(me.name, "Guest 1", "control and formatting characters are stripped");
await host.next("presence", (f) => f.participants.length === 2);
});
@@ -95,37 +103,99 @@ await step("guest anchors are ignored", async () => {
await step("guest commands reach the host", async () => {
guest.send({ t: "cmd", body: { type: "skipNext" } });
const cmd = await host.next("cmd");
- assert.equal(cmd.from, "guest-0001");
+ assert.equal(cmd.from, guestId);
+});
+
+await step("nobody can take over the host's identity", async () => {
+ // The host's id is public; using it as a key yields a different id.
+ const impostor = connect(room, "impostor", "join", "Host", hostId.padEnd(22, "x"));
+ const welcome = await impostor.next("welcome");
+ assert.notEqual(welcome.you, hostId);
+ assert.equal(welcome.host, hostId);
+ impostor.send({ t: "kick", target: guestId, ban: true });
+ impostor.send({ t: "anchor", body: { positionMs: 666 } });
+ impostor.send({ t: "ping", id: 9, c: 0 });
+ await impostor.next("pong");
+ await assert.rejects(guest.next("anchor", () => true, 500), "an impostor's anchor isn't relayed");
+ impostor.ws.close();
+ await host.next("presence", (f) => f.participants.length === 2);
});
await step("a taken code can't be created again", async () => {
- const intruder = connect(room, "intruder-1", "create");
+ const intruder = connect(room, "intruder", "create");
const error = await intruder.next("error");
assert.equal(error.code, "room_taken");
assert.equal(await intruder.closed, 4001);
});
await step("unknown rooms are not found", async () => {
- const lost = connect(code(), "lost-0001", "join");
+ const lost = connect(code(), "lost", "join");
assert.equal((await lost.next("error")).code, "room_not_found");
});
await step("ban closes the socket and blocks rejoining", async () => {
- host.send({ t: "kick", target: "guest-0001", ban: true });
+ host.send({ t: "kick", target: guestId, ban: true });
assert.equal((await guest.next("error")).code, "banned");
assert.equal(await guest.closed, 4003);
- const again = connect(room, "guest-0001", "join");
+ const again = connect(room, "guest", "join", "Guest", guest.key);
assert.equal((await again.next("error")).code, "banned");
});
+await step("a ban covers the device, not just the identity", async () => {
+ const party = code();
+ const phone = newKey(); // The same device digest: same model, OS, locale…
+ const owner = connect(party, "owner", "create", "Owner", newKey(), newKey());
+ await owner.next("welcome");
+ // A friend with an identical phone, already in the room before the ban.
+ const friend = connect(party, "friend", "join", "Friend", newKey(), phone);
+ const friendId = (await friend.next("welcome")).you;
+ const troll = connect(party, "troll", "join", "Troll", newKey(), phone);
+ const trollId = (await troll.next("welcome")).you;
+
+ owner.send({ t: "kick", target: trollId, ban: true });
+ assert.equal((await troll.next("error")).code, "banned");
+
+ // Back with a fresh identity (reinstall) from the same device and network: refused.
+ const again = connect(party, "troll-again", "join", "Troll", newKey(), phone);
+ assert.equal((await again.next("error")).code, "banned");
+ // The friend can still reconnect, and someone on another device can join.
+ friend.ws.close();
+ const friendBack = connect(party, "friend", "join", "Friend", friend.key, phone);
+ assert.equal((await friendBack.next("welcome")).you, friendId);
+ const stranger = connect(party, "stranger", "join", "Stranger", newKey(), newKey());
+ await stranger.next("welcome");
+ for (const c of [owner, friendBack, stranger]) c.ws.close();
+});
+
+await step("a client that floods is cut off", async () => {
+ const flooder = connect(code(), "flooder", "create");
+ await flooder.next("welcome");
+ for (let i = 0; i < 100; i++) flooder.send({ t: "ping", id: i, c: 0 });
+ assert.equal(await flooder.closed, 4029);
+});
+
await step("host hands over after the grace period", async () => {
- const heir = connect(room, "heir-0001", "join", "Heir");
- await heir.next("welcome");
+ const heir = connect(room, "heir", "join", "Heir");
+ const heirId = (await heir.next("welcome")).you;
host.ws.close();
- const presence = await heir.next("presence", (f) => f.host === "heir-0001", 45_000);
- assert.deepEqual(presence.participants.map((p) => p.id), ["heir-0001"]);
+ const presence = await heir.next("presence", (f) => f.host === heirId, 45_000);
+ assert.deepEqual(presence.participants.map((p) => p.id), [heirId]);
heir.ws.close();
});
+await step("an empty room can't be taken over, but its host can bring it back", async () => {
+ const other = code();
+ const owner = connect(other, "owner", "create");
+ await owner.next("welcome");
+ owner.ws.close();
+ await owner.closed;
+ const squatter = connect(other, "squatter", "create");
+ assert.equal((await squatter.next("error")).code, "room_taken");
+ const back = connect(other, "owner", "create", "Owner", owner.key);
+ const welcome = await back.next("welcome");
+ assert.equal(welcome.host, welcome.you);
+ back.ws.close();
+});
+
console.log("all smoke tests passed");
process.exit(0);
diff --git a/jam-server/wrangler.jsonc b/jam-server/wrangler.jsonc
index 4ec4a1bb..edabb8ec 100644
--- a/jam-server/wrangler.jsonc
+++ b/jam-server/wrangler.jsonc
@@ -8,7 +8,9 @@
"bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }],
- // Room connects per client IP; stops code guessing from waking (and billing) rooms.
+ // Room connects per client IP: slows down code guessing and connection floods, which
+ // wake (and bill) rooms. Generous because carrier-grade NAT puts many phones behind
+ // one IP. Enforced per Cloudflare location, so approximate.
"ratelimits": [
{ "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
]