mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Merge 54e9f61c953466777a7763756a63b27568ab1dbf into ecf606e52320b87d099991f4f3da87913523c303
This commit is contained in:
commit
2510b15c46
@ -196,6 +196,13 @@
|
||||
"updatedAt": "آخر تحديث",
|
||||
"createdAt": "تاريخ الإنشاء",
|
||||
"downloadable": "هل يسمح بالتنزيل؟"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "تحميل",
|
||||
"currentTrack": "المقطع الحالي",
|
||||
"allTracks": "جميع المقاطع"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Актуализирана на",
|
||||
"createdAt": "Създадена на",
|
||||
"downloadable": "Разреши изтегляния?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Изтегляне",
|
||||
"currentTrack": "Текуща песен",
|
||||
"allTracks": "Всички песни"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
|
||||
@ -253,6 +253,13 @@
|
||||
"updatedAt": "Ažurirano",
|
||||
"createdAt": "Kreirano",
|
||||
"downloadable": "Dozvoli preuzimanje?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Preuzmi",
|
||||
"currentTrack": "Trenutna pjesma",
|
||||
"allTracks": "Sve pjesme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Actualitzat",
|
||||
"createdAt": "Creat",
|
||||
"downloadable": "Permet descarregar?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Descarrega",
|
||||
"currentTrack": "Pista actual",
|
||||
"allTracks": "Totes les pistes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
|
||||
@ -196,6 +196,13 @@
|
||||
"updatedAt": "Nahráno",
|
||||
"createdAt": "Vytvořeno",
|
||||
"downloadable": "Povolit stahování?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Stáhnout",
|
||||
"currentTrack": "Aktuální skladba",
|
||||
"allTracks": "Všechny skladby"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Opdateret d.",
|
||||
"createdAt": "Oprettet d.",
|
||||
"downloadable": "Tillad downloads?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Download",
|
||||
"currentTrack": "Nuværende nummer",
|
||||
"allTracks": "Alle numre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Intet afspilles nu",
|
||||
"minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Geändert am",
|
||||
"createdAt": "Erstellt am",
|
||||
"downloadable": "Downloads erlauben?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Herunterladen",
|
||||
"currentTrack": "Aktueller Titel",
|
||||
"allTracks": "Alle Titel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Keine Wiedergabe",
|
||||
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Ενημερώθηκε στις",
|
||||
"createdAt": "Δημιουργήθηκε στις",
|
||||
"downloadable": "Επιτρέπονται οι λήψεις?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Λήψη",
|
||||
"currentTrack": "Τρέχον κομμάτι",
|
||||
"allTracks": "Όλα τα κομμάτια"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Δεν παίζει τίποτα",
|
||||
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Ĝisdatiĝis je",
|
||||
"createdAt": "Fariĝis je",
|
||||
"downloadable": "Ĉu Ebligi Elŝutojn?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Elŝuti",
|
||||
"currentTrack": "Nuna trako",
|
||||
"allTracks": "Ĉiuj trakoj"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Nenio ludas",
|
||||
"minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Actualizado el",
|
||||
"createdAt": "Creado el",
|
||||
"downloadable": "¿Permitir descargas?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Descargar",
|
||||
"currentTrack": "Pista actual",
|
||||
"allTracks": "Todas las pistas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,7 +260,13 @@
|
||||
"createdAt": "Sortze-data:"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Deskargatu",
|
||||
"currentTrack": "Uneko pista",
|
||||
"allTracks": "Pista guztiak"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Aurkitu ez den fitxategia |||| Aurkitu ez diren fitxategiak",
|
||||
@ -601,7 +607,7 @@
|
||||
"shareSuccess": "URLa arbelera kopiatu da: %{url}",
|
||||
"shareFailure": "Errorea %{url} URLa arbelera kopiatzean",
|
||||
"downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})",
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
"downloadOriginalFormat": "Deskargatu jatorrizko formatua"
|
||||
},
|
||||
"menu": {
|
||||
"library": "Liburutegia",
|
||||
|
||||
@ -196,6 +196,13 @@
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"downloadable": ""
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "دانلود",
|
||||
"currentTrack": "آهنگ فعلی",
|
||||
"allTracks": "همه آهنگها"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -457,4 +464,4 @@
|
||||
"current_song": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Päivitetty",
|
||||
"createdAt": "Luotu",
|
||||
"downloadable": "Salli lataus?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Lataa",
|
||||
"currentTrack": "Nykyinen kappale",
|
||||
"allTracks": "Kaikki kappaleet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Ei soita mitään",
|
||||
"minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Mis à jour le",
|
||||
"createdAt": "Créé le",
|
||||
"downloadable": "Autoriser les téléchargements ?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Télécharger",
|
||||
"currentTrack": "Piste en cours",
|
||||
"allTracks": "Toutes les pistes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Aucun titre en cours de lecture",
|
||||
"minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Actualizada o",
|
||||
"createdAt": "Creada o",
|
||||
"downloadable": "Permitir descargas?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Descargar",
|
||||
"currentTrack": "Pista actual",
|
||||
"allTracks": "Todas as pistas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Sen reprodución",
|
||||
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,7 +255,13 @@
|
||||
"createdAt": "बनाया गया"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "डाउनलोड",
|
||||
"currentTrack": "वर्तमान ट्रैक",
|
||||
"allTracks": "सभी ट्रैक"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "गुम फ़ाइल |||| गुम फ़ाइलें",
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Frissítve",
|
||||
"createdAt": "Létrehozva",
|
||||
"downloadable": "Engedélyezed a letöltéseket?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Letöltés",
|
||||
"currentTrack": "Jelenlegi szám",
|
||||
"allTracks": "Összes szám"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -715,4 +722,4 @@
|
||||
"current_song": "Aktuális számhoz ugrás"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Diperbarui pada",
|
||||
"createdAt": "Dibuat pada",
|
||||
"downloadable": "Izinkan Pengunduhan?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Unduh",
|
||||
"currentTrack": "Trek saat ini",
|
||||
"allTracks": "Semua trek"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Tidak ada yang diputar",
|
||||
"minutesAgo": "%{smart_count} menit yang lalu |||| %{smart_count} menit yang lalu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -261,7 +261,13 @@
|
||||
"createdAt": "Data di creazione"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Scarica",
|
||||
"currentTrack": "Traccia corrente",
|
||||
"allTracks": "Tutte le tracce"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "File mancante |||| File mancanti",
|
||||
|
||||
@ -253,6 +253,13 @@
|
||||
"updatedAt": "更新日",
|
||||
"createdAt": "作成日",
|
||||
"downloadable": "ダウンロードを許可しますか?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "ダウンロード",
|
||||
"currentTrack": "現在のトラック",
|
||||
"allTracks": "すべてのトラック"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -631,4 +638,4 @@
|
||||
"empty": "何も再生されていません",
|
||||
"minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"artist": "아티스트",
|
||||
"album": "앨범",
|
||||
"path": "파일 경로",
|
||||
"libraryName": "라이브러리",
|
||||
"libraryName": "라이브러리",
|
||||
"genre": "장르",
|
||||
"compilation": "컴필레이션",
|
||||
"year": "년",
|
||||
@ -183,7 +183,7 @@
|
||||
"scrobbleEnabled": "외부 서비스에 스크로블 보내기"
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"transcoding": {
|
||||
"name": "트랜스코딩 |||| 트랜스코딩",
|
||||
"fields": {
|
||||
"name": "이름",
|
||||
@ -254,7 +254,13 @@
|
||||
"createdAt": "생성됨"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "다운로드",
|
||||
"currentTrack": "현재 트랙",
|
||||
"allTracks": "모든 트랙"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "누락 파일 |||| 누락 파일들",
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Bijgewerkt op",
|
||||
"createdAt": "Aangemaakt op",
|
||||
"downloadable": "Downloads toestaan?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Downloaden",
|
||||
"currentTrack": "Huidig nummer",
|
||||
"allTracks": "Alle nummers"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Er wordt niets afgespeed",
|
||||
"minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -253,6 +253,13 @@
|
||||
"updatedAt": "Oppdatert",
|
||||
"createdAt": "Opprettet",
|
||||
"downloadable": "Tillat Nedlastinger?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Last ned",
|
||||
"currentTrack": "Gjeldende spor",
|
||||
"allTracks": "Alle spor"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -631,4 +638,4 @@
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,6 +255,13 @@
|
||||
"updatedAt": "Zaktualizowano",
|
||||
"createdAt": "Stworzono",
|
||||
"downloadable": "Zezwolić Na Pobieranie?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Pobierz",
|
||||
"currentTrack": "Bieżący utwór",
|
||||
"allTracks": "Wszystkie utwory"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -708,4 +715,4 @@
|
||||
"empty": "Nic nie jest odtwarzane",
|
||||
"minutesAgo": "%{smart_count} minutę temu |||| %{smart_count} minut temu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Últ. Atualização",
|
||||
"createdAt": "Data de Criação",
|
||||
"downloadable": "Permitir Baixar?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Baixar",
|
||||
"currentTrack": "Faixa atual",
|
||||
"allTracks": "Todas as faixas"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Nada tocando",
|
||||
"minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Обновлено",
|
||||
"createdAt": "Создано",
|
||||
"downloadable": "Разрешить загрузку?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Скачать",
|
||||
"currentTrack": "Текущий трек",
|
||||
"allTracks": "Все треки"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Posodobljeno ob",
|
||||
"createdAt": "Ustvarjeno ob",
|
||||
"downloadable": "Dovoli prenose?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Prenesi",
|
||||
"currentTrack": "Trenutna skladba",
|
||||
"allTracks": "Vse skladbe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
|
||||
@ -260,7 +260,13 @@
|
||||
"createdAt": "Креирано"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Преузми",
|
||||
"currentTrack": "Тренутна песма",
|
||||
"allTracks": "Све песме"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Фајл који недостаје|||| Фајлови који недостају",
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Uppdaterad",
|
||||
"createdAt": "Skapad",
|
||||
"downloadable": "Tillåt nedladdning?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Ladda ner",
|
||||
"currentTrack": "Nuvarande spår",
|
||||
"allTracks": "Alla spår"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Inget spelas",
|
||||
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "อัปเดตเมื่อ",
|
||||
"createdAt": "สร้างเมื่อ",
|
||||
"downloadable": "อนุญาตให้ดาวโหลด?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "ดาวน์โหลด",
|
||||
"currentTrack": "แทร็กปัจจุบัน",
|
||||
"allTracks": "แทร็กทั้งหมด"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "ไม่มีเพลงเล่น",
|
||||
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "Güncelleme Tarihi",
|
||||
"createdAt": "Oluşturma Tarihi",
|
||||
"downloadable": "İndirmelere İzin Ver"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "İndir",
|
||||
"currentTrack": "Mevcut parça",
|
||||
"allTracks": "Tüm parçalar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "Çalan şarkı yok",
|
||||
"minutesAgo": "%{smart_count} dakika önce"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,6 +256,13 @@
|
||||
"updatedAt": "Оновлено",
|
||||
"createdAt": "Створено",
|
||||
"downloadable": "Дозволити завантаження?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Завантажити",
|
||||
"currentTrack": "Поточний трек",
|
||||
"allTracks": "Усі треки"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -718,4 +725,4 @@
|
||||
"empty": "Нічого не грає",
|
||||
"minutesAgo": "%{smart_count} хвилин тому |||| %{smart_count} хвилин тому"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -260,7 +260,13 @@
|
||||
"createdAt": "创建于"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "下载",
|
||||
"currentTrack": "当前曲目",
|
||||
"allTracks": "所有曲目"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "丢失文件",
|
||||
|
||||
@ -258,6 +258,13 @@
|
||||
"updatedAt": "更新於",
|
||||
"createdAt": "建立於",
|
||||
"downloadable": "允許下載?"
|
||||
},
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "下載",
|
||||
"currentTrack": "目前曲目",
|
||||
"allTracks": "所有曲目"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
@ -720,4 +727,4 @@
|
||||
"empty": "無播放內容",
|
||||
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,15 @@ package public
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
streampkg "github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -84,6 +88,12 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32))
|
||||
|
||||
if conf.Server.EnableDownloads && p.BoolOr("download", false) {
|
||||
w.Header().Set("Content-Disposition", mime.FormatMediaType(
|
||||
"attachment", map[string]string{"filename": downloadFilename(mf, info.format)},
|
||||
))
|
||||
}
|
||||
|
||||
n, err := stream.Serve(ctx, w, r)
|
||||
if err != nil || n == 0 {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
@ -124,3 +134,15 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) {
|
||||
shareID: c.ShareID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sanitizeName(target string) string {
|
||||
return strings.ReplaceAll(target, "/", "_")
|
||||
}
|
||||
|
||||
func downloadFilename(mf *model.MediaFile, format string) string {
|
||||
ext := mf.Suffix
|
||||
if format != "" && format != "raw" {
|
||||
ext = format
|
||||
}
|
||||
return fmt.Sprintf("%s - %s.%s", sanitizeName(mf.Artist), sanitizeName(mf.Title), ext)
|
||||
}
|
||||
|
||||
@ -246,3 +246,52 @@ var _ = Describe("handleStream", func() {
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("sanitizeName", func() {
|
||||
It("leaves a plain string unchanged", func() {
|
||||
Expect(sanitizeName("Artist Name")).To(Equal("Artist Name"))
|
||||
})
|
||||
|
||||
It("replaces a single slash with an underscore", func() {
|
||||
Expect(sanitizeName("AC/DC")).To(Equal("AC_DC"))
|
||||
})
|
||||
|
||||
It("replaces multiple slashes with underscores", func() {
|
||||
Expect(sanitizeName("a/b/c")).To(Equal("a_b_c"))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("downloadFilename", func() {
|
||||
var mf *model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = &model.MediaFile{
|
||||
Artist: "The Beatles",
|
||||
Title: "Hey Jude",
|
||||
Suffix: "flac",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
})
|
||||
|
||||
It("uses the media file suffix when format is empty", func() {
|
||||
Expect(downloadFilename(mf, "")).To(Equal("The Beatles - Hey Jude.flac"))
|
||||
})
|
||||
|
||||
It("uses the media file suffix when format is 'raw'", func() {
|
||||
Expect(downloadFilename(mf, "raw")).To(Equal("The Beatles - Hey Jude.flac"))
|
||||
})
|
||||
|
||||
It("uses the format as the extension when a real format is given", func() {
|
||||
Expect(downloadFilename(mf, "mp3")).To(Equal("The Beatles - Hey Jude.mp3"))
|
||||
})
|
||||
|
||||
It("sanitizes slashes in the artist name", func() {
|
||||
mf.Artist = "AC/DC"
|
||||
Expect(downloadFilename(mf, "")).To(Equal("AC_DC - Hey Jude.flac"))
|
||||
})
|
||||
|
||||
It("sanitizes slashes in the title", func() {
|
||||
mf.Title = "Love/Hate"
|
||||
Expect(downloadFilename(mf, "")).To(Equal("The Beatles - Love_Hate.flac"))
|
||||
})
|
||||
})
|
||||
|
||||
@ -161,6 +161,9 @@ func addShareData(r *http.Request, data map[string]any, shareInfo *model.Share)
|
||||
Description: shareInfo.Description,
|
||||
Downloadable: shareInfo.Downloadable,
|
||||
}
|
||||
if sd.Description == "" {
|
||||
sd.Description = shareInfo.Contents
|
||||
}
|
||||
sd.Tracks = slice.Map(shareInfo.Tracks, func(mf model.MediaFile) shareTrack {
|
||||
return shareTrack{
|
||||
ID: mf.ID,
|
||||
|
||||
@ -312,6 +312,13 @@ var _ = Describe("addShareData", func() {
|
||||
addShareData(r, data, shareInfo)
|
||||
Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents))
|
||||
})
|
||||
It("should use shareInfo.Contents as ShareInfo.Description", func() {
|
||||
addShareData(r, data, shareInfo)
|
||||
var sd shareData
|
||||
err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &sd)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(sd.Description).To(Equal(shareInfo.Contents))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
<meta property="og:image" content="{{ .ShareImageURL }}">
|
||||
<meta property="og:image:width" content="300">
|
||||
<meta property="og:image:height" content="300">
|
||||
<title>Navidrome</title>
|
||||
<title>{{ if .ShareDescription }}{{ .ShareDescription }} | Navidrome{{ else }}Navidrome{{ end }}</title>
|
||||
<script>
|
||||
// Shim for libraries that check for Node.js process object
|
||||
window.process = { env: {} };
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Resource,
|
||||
useSetLocale,
|
||||
useRefresh,
|
||||
TranslationProvider,
|
||||
} from 'react-admin'
|
||||
import { HotKeys } from 'react-hotkeys'
|
||||
import dataProvider from './dataProvider'
|
||||
@ -192,7 +193,13 @@ const AppWithHotkeys = () => {
|
||||
let language = localStorage.getItem('locale') || 'en'
|
||||
document.documentElement.lang = language
|
||||
if (config.enableSharing && shareInfo) {
|
||||
return <SharePlayer />
|
||||
return (
|
||||
<Provider store={adminStore}>
|
||||
<TranslationProvider i18nProvider={i18nProvider}>
|
||||
<SharePlayer />
|
||||
</TranslationProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<HotKeys keyMap={keyMap}>
|
||||
|
||||
@ -261,7 +261,13 @@
|
||||
"createdAt": "Created at"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
"actions": {
|
||||
"download": {
|
||||
"title": "Download",
|
||||
"currentTrack": "Current Track",
|
||||
"allTracks": "All Tracks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Missing File |||| Missing Files",
|
||||
|
||||
@ -8,21 +8,15 @@ import CardActions from '@material-ui/core/CardActions'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import Link from '@material-ui/core/Link'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
|
||||
import {
|
||||
createMuiTheme,
|
||||
useLogin,
|
||||
useNotify,
|
||||
useTranslate,
|
||||
useVersion,
|
||||
} from 'react-admin'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useLogin, useNotify, useTranslate } from 'react-admin'
|
||||
import Logo from '../icons/android-icon-192x192.png'
|
||||
|
||||
import Notification from './Notification'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import config from '../config'
|
||||
import { clearQueue } from '../actions'
|
||||
import { INSIGHTS_DOC_URL } from '../consts.js'
|
||||
import withTheme from '../utils/withTheme'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -399,18 +393,6 @@ Login.propTypes = {
|
||||
previousRoute: PropTypes.string,
|
||||
}
|
||||
|
||||
// We need to put the ThemeProvider decoration in another component
|
||||
// Because otherwise the useStyles() hook used in Login won't get
|
||||
// the right theme
|
||||
const LoginWithTheme = (props) => {
|
||||
const theme = useCurrentTheme()
|
||||
const version = useVersion()
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<Login key={version} {...props} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
const LoginWithTheme = withTheme(Login)
|
||||
|
||||
export default LoginWithTheme
|
||||
|
||||
@ -1,10 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import ReactJkMusicPlayer from 'navidrome-music-player'
|
||||
|
||||
import config, { shareInfo } from '../config'
|
||||
import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils'
|
||||
import {
|
||||
shareCoverUrl,
|
||||
shareDownloadUrl,
|
||||
shareStreamUrl,
|
||||
toDownloadUrl,
|
||||
} from '../utils'
|
||||
import withTheme from '../utils/withTheme'
|
||||
import { DialogTitle } from '../dialogs/DialogTitle'
|
||||
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { Button, Dialog, DialogContent } from '@material-ui/core'
|
||||
import {
|
||||
QueueMusic as QueueMusicIcon,
|
||||
MusicNote as MusicNoteIcon,
|
||||
} from '@material-ui/icons'
|
||||
|
||||
const useStyle = makeStyles({
|
||||
const useStyle = makeStyles((theme) => ({
|
||||
player: {
|
||||
'& .group .next-audio': {
|
||||
pointerEvents: (props) => props.single && 'none',
|
||||
@ -20,10 +35,38 @@ const useStyle = makeStyles({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
columnLayout: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
}))
|
||||
|
||||
const downloadFile = (src, filename) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = src
|
||||
link.download = filename ?? '' // when blank, will use the Content-Disposition header
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
const SharePlayer = () => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyle({ single: shareInfo?.tracks.length === 1 })
|
||||
const [downloadInfo, setDownloadInfo] = useState(null)
|
||||
|
||||
const handleCustomDownload = (downloadInfo) => {
|
||||
if (shareInfo?.tracks) {
|
||||
if (shareInfo.tracks.length === 1) {
|
||||
downloadFile(toDownloadUrl(downloadInfo.src))
|
||||
} else {
|
||||
setDownloadInfo(downloadInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => setDownloadInfo(null)
|
||||
|
||||
const list = shareInfo?.tracks.map((s) => {
|
||||
return {
|
||||
@ -34,11 +77,7 @@ const SharePlayer = () => {
|
||||
duration: s.duration,
|
||||
}
|
||||
})
|
||||
const onBeforeAudioDownload = () => {
|
||||
return Promise.resolve({
|
||||
src: shareDownloadUrl(shareInfo?.id),
|
||||
})
|
||||
}
|
||||
|
||||
const options = {
|
||||
audioLists: list,
|
||||
mode: 'full',
|
||||
@ -56,12 +95,55 @@ const SharePlayer = () => {
|
||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
||||
}
|
||||
return (
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
className={classes.player}
|
||||
onBeforeAudioDownload={onBeforeAudioDownload}
|
||||
/>
|
||||
<>
|
||||
<ReactJkMusicPlayer
|
||||
{...options}
|
||||
className={classes.player}
|
||||
customDownloader={handleCustomDownload}
|
||||
/>
|
||||
<Dialog
|
||||
id="share-download-menu"
|
||||
open={!!downloadInfo}
|
||||
onClose={handleClose}
|
||||
aria-labelledby="share-download-title"
|
||||
>
|
||||
<DialogTitle id="share-download-title" onClose={handleClose}>
|
||||
{translate('resources.share.actions.download.title')}
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.columnLayout} dividers>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<MusicNoteIcon />}
|
||||
onClick={() => {
|
||||
downloadFile(toDownloadUrl(downloadInfo.src))
|
||||
setDownloadInfo(null)
|
||||
}}
|
||||
>
|
||||
{translate('resources.share.actions.download.currentTrack')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<QueueMusicIcon />}
|
||||
disabled={!shareInfo}
|
||||
onClick={() => {
|
||||
let url = shareDownloadUrl(shareInfo?.id)
|
||||
downloadFile(
|
||||
shareDownloadUrl(shareInfo?.id),
|
||||
shareInfo?.description && shareInfo.description.trim() !== ''
|
||||
? shareInfo.description + '.zip'
|
||||
: shareInfo?.id + '.zip',
|
||||
)
|
||||
setDownloadInfo(null)
|
||||
}}
|
||||
>
|
||||
{translate('resources.share.actions.download.allTracks')}
|
||||
</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharePlayer
|
||||
const SharePlayerWithTheme = withTheme(SharePlayer)
|
||||
|
||||
export default SharePlayerWithTheme
|
||||
|
||||
315
ui/src/share/SharePlayer.test.jsx
Normal file
315
ui/src/share/SharePlayer.test.jsx
Normal file
@ -0,0 +1,315 @@
|
||||
/* eslint-disable no-import-assign */
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
// Mock ReactJkMusicPlayer - capture the customDownloader prop so tests can invoke it
|
||||
let capturedCustomDownloader = null
|
||||
vi.mock('navidrome-music-player', () => ({
|
||||
default: vi.fn(({ customDownloader, className }) => {
|
||||
capturedCustomDownloader = customDownloader
|
||||
return <div data-testid="music-player" className={className} />
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
defaultLanguage: '',
|
||||
enableDownloads: true,
|
||||
publicBaseUrl: '/share',
|
||||
baseUrl: 'https://example.com',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../utils', async (importOriginal) => {
|
||||
const orig = await importOriginal()
|
||||
return {
|
||||
baseUrl: orig.baseUrl,
|
||||
shareStreamUrl: (id) => `/share/s/${id}`,
|
||||
shareCoverUrl: (id) => `/share/img/${id}`,
|
||||
shareDownloadUrl: (id) => `/share/d/${id}`,
|
||||
toDownloadUrl: (src) => `${src}?download=true`,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
useTranslate: () => (key) => key,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../dialogs/DialogTitle', () => ({
|
||||
DialogTitle: ({ children, onClose }) => (
|
||||
<div data-testid="dialog-title">
|
||||
{children}
|
||||
<button aria-label="close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../dataProvider', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
httpClient: vi.fn(),
|
||||
}))
|
||||
|
||||
// Spy on document.createElement to intercept anchor clicks
|
||||
let createdLinks = []
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
import { TestContext } from 'ra-test'
|
||||
import SharePlayer from './SharePlayer'
|
||||
import * as configModule from '../config'
|
||||
|
||||
describe('SharePlayer', () => {
|
||||
beforeEach(async () => {
|
||||
capturedCustomDownloader = null
|
||||
createdLinks = []
|
||||
configModule.shareInfo = {
|
||||
id: 'share-1',
|
||||
description: 'My Playlist',
|
||||
downloadable: true,
|
||||
tracks: [
|
||||
{
|
||||
id: 'track-1',
|
||||
title: 'Song One',
|
||||
artist: 'Artist One',
|
||||
duration: 180,
|
||||
},
|
||||
{
|
||||
id: 'track-2',
|
||||
title: 'Song Two',
|
||||
artist: 'Artist Two',
|
||||
duration: 240,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
const el = originalCreateElement(tag)
|
||||
if (tag === 'a') {
|
||||
vi.spyOn(el, 'click').mockImplementation(() => {})
|
||||
createdLinks.push(el)
|
||||
}
|
||||
return el
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
configModule.shareInfo = null
|
||||
})
|
||||
|
||||
it('renders the music player', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
expect(screen.getByTestId('music-player')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the download dialog initially', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
// Dialog is closed: the MUI Dialog root should not be present or should be hidden
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('customDownloader with multiple tracks', () => {
|
||||
it('opens the download dialog when customDownloader is called', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
expect(
|
||||
screen.getByText('resources.share.actions.download.title'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Current Track" and "All Tracks" buttons in the dialog', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
expect(
|
||||
screen.getByText('resources.share.actions.download.currentTrack'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('resources.share.actions.download.allTracks'),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the current track when "Current Track" is clicked', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.share.actions.download.currentTrack'),
|
||||
)
|
||||
|
||||
expect(createdLinks).toHaveLength(1)
|
||||
expect(createdLinks[0].href).toContain('download=true')
|
||||
expect(createdLinks[0].click).toHaveBeenCalled()
|
||||
expect(createdLinks[0]).not.toBeInTheDocument() // Link should be removed after click
|
||||
})
|
||||
|
||||
it('closes the dialog after clicking "Current Track"', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.share.actions.download.currentTrack'),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('resources.share.actions.download.title'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads all tracks as zip when "All Tracks" is clicked', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.share.actions.download.allTracks'),
|
||||
)
|
||||
|
||||
expect(createdLinks).toHaveLength(1)
|
||||
expect(createdLinks[0].href).toContain('/share/d/share-1')
|
||||
expect(createdLinks[0].download).toBe('My Playlist.zip')
|
||||
expect(createdLinks[0].click).toHaveBeenCalled()
|
||||
expect(createdLinks[0]).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fallback to share id if description is empty', () => {
|
||||
configModule.shareInfo.description = ''
|
||||
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.share.actions.download.allTracks'),
|
||||
)
|
||||
|
||||
expect(createdLinks).toHaveLength(1)
|
||||
expect(createdLinks[0].href).toContain('/share/d/share-1')
|
||||
expect(createdLinks[0].download).toBe('share-1.zip')
|
||||
expect(createdLinks[0].click).toHaveBeenCalled()
|
||||
expect(createdLinks[0]).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes the dialog after clicking "All Tracks"', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
fireEvent.click(
|
||||
screen.getByText('resources.share.actions.download.allTracks'),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('resources.share.actions.download.title'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the dialog when the close button is clicked', async () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
expect(
|
||||
screen.getByText('resources.share.actions.download.title'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('close'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('resources.share.actions.download.title'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('customDownloader with a single track', () => {
|
||||
beforeEach(() => {
|
||||
configModule.shareInfo = {
|
||||
id: 'share-2',
|
||||
description: 'Single Track',
|
||||
downloadable: true,
|
||||
tracks: [
|
||||
{
|
||||
id: 'track-1',
|
||||
title: 'Only Song',
|
||||
artist: 'Solo Artist',
|
||||
duration: 200,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
it('downloads the track directly without opening a dialog', () => {
|
||||
render(
|
||||
<TestContext>
|
||||
<SharePlayer />
|
||||
</TestContext>,
|
||||
)
|
||||
|
||||
capturedCustomDownloader({ src: '/share/s/track-1' })
|
||||
|
||||
// Dialog title should NOT appear — direct download, no dialog
|
||||
expect(
|
||||
screen.queryByText('resources.share.actions.download.title'),
|
||||
).not.toBeInTheDocument()
|
||||
// A link should have been clicked directly
|
||||
expect(createdLinks).toHaveLength(1)
|
||||
expect(createdLinks[0].href).toContain('download=true')
|
||||
expect(createdLinks[0].click).toHaveBeenCalled()
|
||||
expect(createdLinks[0]).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -29,6 +29,12 @@ export const shareStreamUrl = (id) => {
|
||||
return shareUrl(config.publicBaseUrl + '/s/' + id)
|
||||
}
|
||||
|
||||
export const toDownloadUrl = (src) => {
|
||||
const url = new URL(src, window.origin)
|
||||
url.searchParams.set('download', 'true')
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const shareDownloadUrl = (id) => {
|
||||
return shareUrl(config.publicBaseUrl + '/d/' + id)
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { isLastFmURL } from './urls'
|
||||
import { isLastFmURL, toDownloadUrl } from './urls'
|
||||
|
||||
describe('isLastFmURL', () => {
|
||||
it('returns true for valid Last.fm music URLs', () => {
|
||||
@ -23,3 +23,25 @@ describe('isLastFmURL', () => {
|
||||
expect(isLastFmURL('not-a-url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDownloadUrl', () => {
|
||||
it('appends download=true to an absolute URL', () => {
|
||||
const result = toDownloadUrl('https://example.com/share/s/abc123')
|
||||
expect(new URL(result).searchParams.get('download')).toBe('true')
|
||||
})
|
||||
|
||||
it('appends download=true to a relative URL resolved against window.origin', () => {
|
||||
const result = toDownloadUrl('/share/s/abc123')
|
||||
expect(new URL(result).searchParams.get('download')).toBe('true')
|
||||
expect(new URL(result).pathname).toBe('/share/s/abc123')
|
||||
})
|
||||
|
||||
it('preserves existing query parameters', () => {
|
||||
const result = toDownloadUrl(
|
||||
'https://example.com/share/s/abc123?format=mp3',
|
||||
)
|
||||
const url = new URL(result)
|
||||
expect(url.searchParams.get('format')).toBe('mp3')
|
||||
expect(url.searchParams.get('download')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
23
ui/src/utils/withTheme.jsx
Normal file
23
ui/src/utils/withTheme.jsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { ThemeProvider } from '@material-ui/core/styles'
|
||||
import { createMuiTheme, useVersion } from 'react-admin'
|
||||
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
|
||||
const withTheme = (Component) => {
|
||||
const WithTheme = (props) => {
|
||||
const theme = useCurrentTheme()
|
||||
const version = useVersion()
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||
<Component key={version} {...props} />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
WithTheme.displayName = `WithTheme(${Component.displayName ?? Component.name ?? 'Component'})`
|
||||
|
||||
return WithTheme
|
||||
}
|
||||
|
||||
export default withTheme
|
||||
Loading…
x
Reference in New Issue
Block a user