Merge 54e9f61c953466777a7763756a63b27568ab1dbf into ecf606e52320b87d099991f4f3da87913523c303

This commit is contained in:
Katelyn Dickey 2026-07-25 15:33:54 -04:00 committed by GitHub
commit 2510b15c46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 826 additions and 70 deletions

View File

@ -196,6 +196,13 @@
"updatedAt": "آخر تحديث",
"createdAt": "تاريخ الإنشاء",
"downloadable": "هل يسمح بالتنزيل؟"
},
"actions": {
"download": {
"title": "تحميل",
"currentTrack": "المقطع الحالي",
"allTracks": "جميع المقاطع"
}
}
}
},

View File

@ -256,6 +256,13 @@
"updatedAt": "Актуализирана на",
"createdAt": "Създадена на",
"downloadable": "Разреши изтегляния?"
},
"actions": {
"download": {
"title": "Изтегляне",
"currentTrack": "Текуща песен",
"allTracks": "Всички песни"
}
}
},
"missing": {

View File

@ -253,6 +253,13 @@
"updatedAt": "Ažurirano",
"createdAt": "Kreirano",
"downloadable": "Dozvoli preuzimanje?"
},
"actions": {
"download": {
"title": "Preuzmi",
"currentTrack": "Trenutna pjesma",
"allTracks": "Sve pjesme"
}
}
},
"missing": {

View File

@ -256,6 +256,13 @@
"updatedAt": "Actualitzat",
"createdAt": "Creat",
"downloadable": "Permet descarregar?"
},
"actions": {
"download": {
"title": "Descarrega",
"currentTrack": "Pista actual",
"allTracks": "Totes les pistes"
}
}
},
"missing": {

View File

@ -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"
}
}
}
},

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -256,6 +256,13 @@
"updatedAt": "Ενημερώθηκε στις",
"createdAt": "Δημιουργήθηκε στις",
"downloadable": "Επιτρέπονται οι λήψεις?"
},
"actions": {
"download": {
"title": "Λήψη",
"currentTrack": "Τρέχον κομμάτι",
"allTracks": "Όλα τα κομμάτια"
}
}
},
"missing": {
@ -718,4 +725,4 @@
"empty": "Δεν παίζει τίποτα",
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -196,6 +196,13 @@
"updatedAt": "",
"createdAt": "",
"downloadable": ""
},
"actions": {
"download": {
"title": "دانلود",
"currentTrack": "آهنگ فعلی",
"allTracks": "همه آهنگ‌ها"
}
}
}
},
@ -457,4 +464,4 @@
"current_song": ""
}
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -255,7 +255,13 @@
"createdAt": "बनाया गया"
},
"notifications": {},
"actions": {}
"actions": {
"download": {
"title": "डाउनलोड",
"currentTrack": "वर्तमान ट्रैक",
"allTracks": "सभी ट्रैक"
}
}
},
"missing": {
"name": "गुम फ़ाइल |||| गुम फ़ाइलें",

View File

@ -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"
}
}
}
}

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -253,6 +253,13 @@
"updatedAt": "更新日",
"createdAt": "作成日",
"downloadable": "ダウンロードを許可しますか?"
},
"actions": {
"download": {
"title": "ダウンロード",
"currentTrack": "現在のトラック",
"allTracks": "すべてのトラック"
}
}
},
"missing": {
@ -631,4 +638,4 @@
"empty": "何も再生されていません",
"minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
}
}
}

View File

@ -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": "누락 파일 |||| 누락 파일들",

View File

@ -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"
}
}
}

View File

@ -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": ""
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

View File

@ -258,6 +258,13 @@
"updatedAt": "Обновлено",
"createdAt": "Создано",
"downloadable": "Разрешить загрузку?"
},
"actions": {
"download": {
"title": "Скачать",
"currentTrack": "Текущий трек",
"allTracks": "Все треки"
}
}
},
"missing": {

View File

@ -256,6 +256,13 @@
"updatedAt": "Posodobljeno ob",
"createdAt": "Ustvarjeno ob",
"downloadable": "Dovoli prenose?"
},
"actions": {
"download": {
"title": "Prenesi",
"currentTrack": "Trenutna skladba",
"allTracks": "Vse skladbe"
}
}
},
"missing": {

View File

@ -260,7 +260,13 @@
"createdAt": "Креирано"
},
"notifications": {},
"actions": {}
"actions": {
"download": {
"title": "Преузми",
"currentTrack": "Тренутна песма",
"allTracks": "Све песме"
}
}
},
"missing": {
"name": "Фајл који недостаје|||| Фајлови који недостају",

View File

@ -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"
}
}
}

View File

@ -258,6 +258,13 @@
"updatedAt": "อัปเดตเมื่อ",
"createdAt": "สร้างเมื่อ",
"downloadable": "อนุญาตให้ดาวโหลด?"
},
"actions": {
"download": {
"title": "ดาวน์โหลด",
"currentTrack": "แทร็กปัจจุบัน",
"allTracks": "แทร็กทั้งหมด"
}
}
},
"missing": {
@ -720,4 +727,4 @@
"empty": "ไม่มีเพลงเล่น",
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
}
}
}

View File

@ -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"
}
}
}

View File

@ -256,6 +256,13 @@
"updatedAt": "Оновлено",
"createdAt": "Створено",
"downloadable": "Дозволити завантаження?"
},
"actions": {
"download": {
"title": "Завантажити",
"currentTrack": "Поточний трек",
"allTracks": "Усі треки"
}
}
},
"missing": {
@ -718,4 +725,4 @@
"empty": "Нічого не грає",
"minutesAgo": "%{smart_count} хвилин тому |||| %{smart_count} хвилин тому"
}
}
}

View File

@ -260,7 +260,13 @@
"createdAt": "创建于"
},
"notifications": {},
"actions": {}
"actions": {
"download": {
"title": "下载",
"currentTrack": "当前曲目",
"allTracks": "所有曲目"
}
}
},
"missing": {
"name": "丢失文件",

View File

@ -258,6 +258,13 @@
"updatedAt": "更新於",
"createdAt": "建立於",
"downloadable": "允許下載?"
},
"actions": {
"download": {
"title": "下載",
"currentTrack": "目前曲目",
"allTracks": "所有曲目"
}
}
},
"missing": {
@ -720,4 +727,4 @@
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View File

@ -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)
}

View File

@ -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"))
})
})

View File

@ -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,

View File

@ -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))
})
})
})
})

View File

@ -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: {} };

View File

@ -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}>

View File

@ -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",

View File

@ -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

View File

@ -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

View 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()
})
})
})

View File

@ -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)
}

View File

@ -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')
})
})

View 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