mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
9 Commits
1766d3b26f
...
b895f5498c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b895f5498c | ||
|
|
131c0c565c | ||
|
|
53ff33866d | ||
|
|
508670ecfb | ||
|
|
c369224597 | ||
|
|
5336888a45 | ||
|
|
0a9b6473a5 | ||
|
|
00a8f68348 | ||
|
|
d9b3977ca3 |
@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
})
|
||||
|
||||
It("deduplicates items in buffer", func() {
|
||||
fc.SetReady(false) // Make cache unavailable so items stay in buffer
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
cw.PreCache(model.MustParseArtworkID("al-1"))
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
|
||||
data.Build.Settings, data.Build.GoVersion = buildInfo()
|
||||
data.OS.Containerized = consts.InContainer
|
||||
|
||||
// Install info
|
||||
packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
|
||||
packageFileData, err := os.ReadFile(packageFilename)
|
||||
if err == nil {
|
||||
data.OS.Package = string(packageFileData)
|
||||
}
|
||||
|
||||
// OS info
|
||||
data.OS.Type = runtime.GOOS
|
||||
data.OS.Arch = runtime.GOARCH
|
||||
|
||||
@ -16,6 +16,7 @@ type Data struct {
|
||||
Containerized bool `json:"containerized"`
|
||||
Arch string `json:"arch"`
|
||||
NumCPU int `json:"numCPU"`
|
||||
Package string `json:"package,omitempty"`
|
||||
} `json:"os"`
|
||||
Mem struct {
|
||||
Alloc uint64 `json:"alloc"`
|
||||
|
||||
@ -2,13 +2,9 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type Track interface {
|
||||
@ -20,6 +16,7 @@ type Track interface {
|
||||
SetPosition(offset int) error
|
||||
Close()
|
||||
String() string
|
||||
LoadFile(append bool, playNow bool)
|
||||
}
|
||||
|
||||
type playbackDevice struct {
|
||||
@ -33,7 +30,6 @@ type playbackDevice struct {
|
||||
Gain float32
|
||||
PlaybackDone chan bool
|
||||
ActiveTrack Track
|
||||
startTrackSwitcher sync.Once
|
||||
}
|
||||
|
||||
type DeviceStatus struct {
|
||||
@ -46,13 +42,17 @@ type DeviceStatus struct {
|
||||
const DefaultGain float32 = 1.0
|
||||
|
||||
func (pd *playbackDevice) getStatus() DeviceStatus {
|
||||
currentIndex := pd.ParentPlaybackServer.GetPlaylistPosition()
|
||||
|
||||
pos := 0
|
||||
if pd.ActiveTrack != nil {
|
||||
pos = pd.ActiveTrack.Position()
|
||||
isPlaying := false
|
||||
if currentIndex >= 0 {
|
||||
pos = pd.ParentPlaybackServer.Position()
|
||||
isPlaying = pd.isPlaying()
|
||||
}
|
||||
return DeviceStatus{
|
||||
CurrentIndex: pd.PlaybackQueue.Index,
|
||||
Playing: pd.isPlaying(),
|
||||
CurrentIndex: currentIndex,
|
||||
Playing: isPlaying,
|
||||
Gain: pd.Gain,
|
||||
Position: pos,
|
||||
}
|
||||
@ -75,16 +75,16 @@ func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) String() string {
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack)
|
||||
return fmt.Sprintf("Name: %s, Gain: %.4f", pd.Name, pd.Gain)
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) {
|
||||
func (pd *playbackDevice) Get(ctx context.Context) ([]string, DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Get action", "device", pd)
|
||||
return pd.PlaybackQueue.Get(), pd.getStatus(), nil
|
||||
return pd.ParentPlaybackServer.GetPlaylistIDs(), pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue))
|
||||
log.Debug(ctx, fmt.Sprintf("processing Status action on: %s", pd))
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
@ -102,29 +102,10 @@ func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus,
|
||||
|
||||
func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Start action", "device", pd)
|
||||
|
||||
pd.startTrackSwitcher.Do(func() {
|
||||
log.Info(ctx, "Starting trackSwitcher goroutine")
|
||||
// Start one trackSwitcher goroutine with each device
|
||||
go func() {
|
||||
pd.trackSwitcherGoroutine()
|
||||
}()
|
||||
})
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
if pd.isPlaying() {
|
||||
log.Debug("trying to start an already playing track")
|
||||
} else {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
if !pd.PlaybackQueue.IsEmpty() {
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
_, err := pd.ParentPlaybackServer.Start()
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting playback", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
@ -132,47 +113,21 @@ func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) {
|
||||
|
||||
func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Stop action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
_, err := pd.ParentPlaybackServer.Stop()
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping playback", err)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd)
|
||||
|
||||
wasPlaying := pd.isPlaying()
|
||||
|
||||
if pd.ActiveTrack != nil && wasPlaying {
|
||||
pd.ActiveTrack.Pause()
|
||||
}
|
||||
|
||||
if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if pd.ActiveTrack == nil {
|
||||
err := pd.switchActiveTrackByIndex(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
err := pd.ActiveTrack.SetPosition(offset)
|
||||
_, err := pd.ParentPlaybackServer.Skip(index, offset)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error setting position", err)
|
||||
log.Error(ctx, "error skipping to track", index, offset)
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
_, err = pd.Start(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error starting new track after skipping")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
@ -182,55 +137,44 @@ func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus,
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
items := model.MediaFiles{}
|
||||
|
||||
for _, id := range ids {
|
||||
mf, err := pd.ParentPlaybackServer.GetMediaFile(id)
|
||||
if err != nil {
|
||||
return DeviceStatus{}, err
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
log.Debug(ctx, "Found mediafile: "+mf.Path)
|
||||
items = append(items, *mf)
|
||||
_, err = pd.ParentPlaybackServer.LoadFile(mf, true, false)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
pd.PlaybackQueue.Add(items)
|
||||
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Clear action", "device", pd)
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Pause()
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
_, err := pd.ParentPlaybackServer.Clear()
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.PlaybackQueue.Clear()
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Remove action", "index", index, "device", pd)
|
||||
// pausing if attempting to remove running track
|
||||
if pd.isPlaying() && pd.PlaybackQueue.Index == index {
|
||||
_, err := pd.Stop(ctx)
|
||||
if err != nil {
|
||||
log.Error(ctx, "error stopping running track")
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
}
|
||||
|
||||
if index > -1 && index < pd.PlaybackQueue.Size() {
|
||||
pd.PlaybackQueue.Remove(index)
|
||||
} else {
|
||||
log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index))
|
||||
_, err := pd.ParentPlaybackServer.Remove(index)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing Shuffle action", "device", pd)
|
||||
if pd.PlaybackQueue.Size() > 1 {
|
||||
pd.PlaybackQueue.Shuffle()
|
||||
_, err := pd.ParentPlaybackServer.Shuffle()
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
return pd.getStatus(), nil
|
||||
}
|
||||
@ -238,9 +182,9 @@ func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) {
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) {
|
||||
log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd)
|
||||
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.SetVolume(gain)
|
||||
_, err := pd.ParentPlaybackServer.SetGain(gain)
|
||||
if err != nil {
|
||||
return pd.getStatus(), err
|
||||
}
|
||||
pd.Gain = gain
|
||||
|
||||
@ -248,52 +192,5 @@ func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStat
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) isPlaying() bool {
|
||||
return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying()
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) trackSwitcherGoroutine() {
|
||||
log.Debug("Started trackSwitcher goroutine", "device", pd)
|
||||
for {
|
||||
select {
|
||||
case <-pd.PlaybackDone:
|
||||
log.Debug("Track switching detected")
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Close()
|
||||
pd.ActiveTrack = nil
|
||||
}
|
||||
|
||||
if !pd.PlaybackQueue.IsAtLastElement() {
|
||||
pd.PlaybackQueue.IncreaseIndex()
|
||||
log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String())
|
||||
err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index)
|
||||
if err != nil {
|
||||
log.Error("Error switching track", err)
|
||||
}
|
||||
if pd.ActiveTrack != nil {
|
||||
pd.ActiveTrack.Unpause()
|
||||
}
|
||||
} else {
|
||||
log.Debug("There is no song left in the playlist. Finish.")
|
||||
}
|
||||
case <-pd.serviceCtx.Done():
|
||||
log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pd *playbackDevice) switchActiveTrackByIndex(index int) error {
|
||||
pd.PlaybackQueue.SetIndex(index)
|
||||
currentTrack := pd.PlaybackQueue.Current()
|
||||
if currentTrack == nil {
|
||||
return errors.New("could not get current track")
|
||||
}
|
||||
|
||||
track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.ActiveTrack = track
|
||||
pd.ActiveTrack.SetVolume(pd.Gain)
|
||||
return nil
|
||||
return pd.ParentPlaybackServer.IsPlaying()
|
||||
}
|
||||
|
||||
133
core/playback/mpv/jukebox.lua
Normal file
133
core/playback/mpv/jukebox.lua
Normal file
@ -0,0 +1,133 @@
|
||||
-- reporter.lua -- ROBUST VERSION with Cache Management
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
-- The cache is now a simple map of [filepath] -> database_id
|
||||
local id_cache = {}
|
||||
|
||||
-- This dependency-free JSON encoder is working well.
|
||||
function json_encode(val)
|
||||
local json_val
|
||||
local t = type(val)
|
||||
|
||||
if t == 'string' then
|
||||
json_val = '"' .. string.gsub(val, '"', '\\"') .. '"'
|
||||
elseif t == 'number' or t == 'boolean' then
|
||||
json_val = tostring(val)
|
||||
elseif t == 'nil' then
|
||||
return nil -- Return nil to allow omitting keys
|
||||
elseif t == 'table' then
|
||||
local parts = {}
|
||||
local is_array = true
|
||||
local i = 1
|
||||
for k in pairs(val) do
|
||||
if k ~= i then is_array = false; break; end
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
if is_array then
|
||||
for j = 1, #val do
|
||||
local part_val = json_encode(val[j])
|
||||
if part_val then table.insert(parts, part_val) end
|
||||
end
|
||||
json_val = '[' .. table.concat(parts, ',') .. ']'
|
||||
else
|
||||
for k, v in pairs(val) do
|
||||
local part_val = json_encode(v)
|
||||
if part_val then
|
||||
local key = '"' .. tostring(k) .. '":'
|
||||
table.insert(parts, key .. json_encode(v))
|
||||
end
|
||||
end
|
||||
json_val = '{' .. table.concat(parts, ',') .. '}'
|
||||
end
|
||||
else
|
||||
json_val = '"' .. tostring(val) .. '"'
|
||||
end
|
||||
return json_val
|
||||
end
|
||||
-- ### ID CACHE MANAGEMENT FUNCTIONS ###
|
||||
|
||||
-- NEW: This is the primary way your Go app will "attach" an ID to a file.
|
||||
-- It expects two arguments: the file path and the database ID.
|
||||
function attach_id(filepath, database_id)
|
||||
if filepath and database_id then
|
||||
msg.info("Attaching ID '" .. database_id .. "' to file: " .. filepath)
|
||||
id_cache[filepath] = database_id
|
||||
else
|
||||
msg.warn("attach-id called with missing arguments.")
|
||||
end
|
||||
end
|
||||
|
||||
-- This function clears the entire cache.
|
||||
function clear_id_cache()
|
||||
msg.info("Clearing all " .. tostring(#id_cache) .. " items from ID cache.")
|
||||
id_cache = {}
|
||||
end
|
||||
|
||||
-- This function syncs the cache with the current playlist, removing stale entries.
|
||||
function sync_id_cache(name, new_playlist)
|
||||
if not new_playlist then return end
|
||||
|
||||
local playlist_files = {}
|
||||
for _, track in ipairs(new_playlist) do
|
||||
if track.filename then
|
||||
playlist_files[track.filename] = true
|
||||
end
|
||||
end
|
||||
|
||||
for path_key, _ in pairs(id_cache) do
|
||||
if not playlist_files[path_key] then
|
||||
msg.info("Removing stale ID from cache for: " .. path_key)
|
||||
id_cache[path_key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- ### DATA PROVIDER FUNCTIONS ###
|
||||
|
||||
-- NEW: This function returns an ordered list of database IDs for the current playlist.
|
||||
function get_playlist_ids()
|
||||
local mpv_playlist = mp.get_property_native("playlist")
|
||||
local id_list = {}
|
||||
if not mpv_playlist then return id_list end
|
||||
|
||||
for _, track in ipairs(mpv_playlist) do
|
||||
if track.filename and id_cache[track.filename] then
|
||||
-- If we have an ID for this file, add it to the list.
|
||||
table.insert(id_list, id_cache[track.filename])
|
||||
else
|
||||
-- If we don't have an ID, insert null to maintain playlist order.
|
||||
table.insert(id_list, mp.null)
|
||||
end
|
||||
end
|
||||
return id_list
|
||||
end
|
||||
|
||||
-- NEW: This function provides the ordered ID list to your Go application.
|
||||
function update_playlist_ids_property()
|
||||
local id_list = get_playlist_ids()
|
||||
local json_string = json_encode(id_list)
|
||||
|
||||
if json_string and json_string ~= "null" then
|
||||
-- Set the data on a property with a new, more descriptive name.
|
||||
mp.set_property("user-data/ext-playlist-ids", json_string)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- ### REGISTER EVENTS, OBSERVERS, AND SCRIPT MESSAGES ###
|
||||
msg.info("Registering ID-based reporter events and observers...")
|
||||
-- For your Go app to attach data and get the playlist
|
||||
mp.register_script_message("attach-id", attach_id)
|
||||
mp.register_script_message("update_playlist_ids_property", update_playlist_ids_property)
|
||||
|
||||
-- Syncs the ID cache whenever the playlist is changed (add, remove, clear)
|
||||
mp.observe_property('playlist', 'native', sync_id_cache)
|
||||
|
||||
-- Clears the cache completely on shutdown
|
||||
mp.register_event("shutdown", clear_id_cache)
|
||||
|
||||
update_playlist_ids_property()
|
||||
|
||||
msg.info("ID-based event jukebox reporter script loaded.")
|
||||
@ -7,9 +7,12 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dexterlb/mpvipc"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
@ -74,7 +77,7 @@ func (j *Executor) wait() {
|
||||
}
|
||||
|
||||
// Path will always be an absolute path
|
||||
func createMPVCommand(deviceName string, filename string, socketName string) []string {
|
||||
func createMPVCommand(deviceName string, socketName string) []string {
|
||||
// Parse the template structure using shell parsing to handle quoted arguments
|
||||
templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate)
|
||||
if err != nil {
|
||||
@ -85,7 +88,6 @@ func createMPVCommand(deviceName string, filename string, socketName string) []s
|
||||
// Replace placeholders in each parsed argument to preserve spaces in substituted values
|
||||
for i, arg := range templateArgs {
|
||||
arg = strings.ReplaceAll(arg, "%d", deviceName)
|
||||
arg = strings.ReplaceAll(arg, "%f", filename)
|
||||
arg = strings.ReplaceAll(arg, "%s", socketName)
|
||||
templateArgs[i] = arg
|
||||
}
|
||||
@ -99,6 +101,7 @@ func createMPVCommand(deviceName string, filename string, socketName string) []s
|
||||
}
|
||||
}
|
||||
}
|
||||
templateArgs = append(templateArgs, "--script="+getJukeboxScriptPath())
|
||||
|
||||
return templateArgs
|
||||
}
|
||||
@ -124,6 +127,111 @@ func mpvCommand() (string, error) {
|
||||
return mpvPath, mpvErr
|
||||
}
|
||||
|
||||
func getJukeboxScriptPath() string {
|
||||
abs, err := filepath.Abs("./jukebox.lua")
|
||||
if err != nil {
|
||||
log.Error("Failed to find jukebox.lua script", err)
|
||||
return ""
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
type MpvConnection struct {
|
||||
Conn *mpvipc.Connection
|
||||
Exe *Executor
|
||||
CloseCalled bool
|
||||
IPCSocketName string
|
||||
}
|
||||
|
||||
func (t *MpvConnection) isSocketFilePresent() bool {
|
||||
if len(t.IPCSocketName) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(t.IPCSocketName)
|
||||
return err == nil && fileInfo != nil && !fileInfo.IsDir()
|
||||
}
|
||||
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries int = 0
|
||||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
|
||||
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
return fmt.Errorf("timeout reached: %s", timeout)
|
||||
}
|
||||
time.Sleep(pause)
|
||||
retries += 1
|
||||
}
|
||||
}
|
||||
|
||||
func NewConnection(ctx context.Context, deviceName string) (*MpvConnection, error) {
|
||||
log.Debug("Loading mpv connection")
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, tmpSocketName)
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := mpvipc.NewConnection(tmpSocketName)
|
||||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
theConn := &MpvConnection{Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
|
||||
|
||||
go func() {
|
||||
conn.WaitUntilClosed()
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
|
||||
if !theConn.CloseCalled {
|
||||
log.Debug("Close cleanup")
|
||||
// trying to shutdown mpv process using socket
|
||||
if theConn.isSocketFilePresent() {
|
||||
log.Debug("sending shutdown command")
|
||||
_, err := theConn.Conn.Call("quit")
|
||||
if err != nil {
|
||||
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if theConn.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = theConn.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
removeSocket(theConn.IPCSocketName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return theConn, nil
|
||||
}
|
||||
|
||||
var (
|
||||
mpvOnce sync.Once
|
||||
mpvPath string
|
||||
|
||||
@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
@ -22,6 +21,7 @@ var _ = Describe("MPV", func() {
|
||||
var (
|
||||
testScript string
|
||||
tempDir string
|
||||
scriptPath string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
@ -40,7 +40,7 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
// Create mock MPV script that outputs arguments to stdout
|
||||
testScript = createMockMPVScript(tempDir)
|
||||
|
||||
scriptPath = getJukeboxScriptPath()
|
||||
// Configure test MPV path
|
||||
conf.Server.MPVPath = testScript
|
||||
})
|
||||
@ -48,43 +48,43 @@ var _ = Describe("MPV", func() {
|
||||
Describe("createMPVCommand", func() {
|
||||
Context("with default template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("creates correct command with simple paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles paths with spaces", func() {
|
||||
args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/My Album/01 - Song.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
|
||||
It("handles complex device names", func() {
|
||||
deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1"
|
||||
args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand(deviceName, "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=" + deviceName,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -92,22 +92,22 @@ var _ = Describe("MPV", func() {
|
||||
Context("with snapcast template (issue #3619)", func() {
|
||||
BeforeEach(func() {
|
||||
// This is the template that fails with naive space splitting
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("creates correct command for snapcast integration", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--audio-samplerate=48000",
|
||||
"--audio-format=s16",
|
||||
"--ao=pcm",
|
||||
"--ao-pcm-file=/audio/snapcast_fifo",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -115,56 +115,56 @@ var _ = Describe("MPV", func() {
|
||||
Context("with wrapper script template", func() {
|
||||
BeforeEach(func() {
|
||||
// Test case that would break with naive splitting due to quoted arguments
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo`
|
||||
conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo`
|
||||
})
|
||||
|
||||
It("handles wrapper script paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"/tmp/mpv.sh",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--audio-channels=stereo",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with extra spaces in template", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("handles extra spaces correctly", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--audio-device=auto",
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
Context("with paths containing spaces in template arguments", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with spaces in the path arguments themselves
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s`
|
||||
})
|
||||
|
||||
It("handles spaces in quoted template argument paths", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
// This test reveals the limitation of strings.Fields() - it will split on all spaces
|
||||
// Expected behavior would be to keep the path as one argument
|
||||
Expect(args).To(Equal([]string{
|
||||
testScript,
|
||||
"--no-audio-display",
|
||||
"--pause",
|
||||
"/music/test.mp3",
|
||||
"--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument
|
||||
"--input-ipc-server=/tmp/socket",
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -172,11 +172,11 @@ var _ = Describe("MPV", func() {
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns nil when shell parsing fails", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(BeNil())
|
||||
})
|
||||
})
|
||||
@ -187,15 +187,17 @@ var _ = Describe("MPV", func() {
|
||||
})
|
||||
|
||||
It("returns empty slice for empty template", func() {
|
||||
args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{}))
|
||||
args := createMPVCommand("auto", "/tmp/socket")
|
||||
Expect(args).To(Equal([]string{
|
||||
"--script=" + scriptPath,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("start", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s"
|
||||
conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause --input-ipc-server=%s"
|
||||
})
|
||||
|
||||
It("executes MPV command and captures arguments correctly", func() {
|
||||
@ -203,10 +205,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/test.mp3"
|
||||
socketName := "/tmp/test_socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -221,8 +222,8 @@ var _ = Describe("MPV", func() {
|
||||
Expect(lines[1]).To(Equal("--audio-device=auto"))
|
||||
Expect(lines[2]).To(Equal("--no-audio-display"))
|
||||
Expect(lines[3]).To(Equal("--pause"))
|
||||
Expect(lines[4]).To(Equal("/music/test.mp3"))
|
||||
Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
Expect(lines[4]).To(Equal("--input-ipc-server=/tmp/test_socket"))
|
||||
Expect(lines[5]).To(Equal("--script=" + scriptPath))
|
||||
})
|
||||
|
||||
It("handles file paths with spaces", func() {
|
||||
@ -230,10 +231,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/My Album/01 - My Song.mp3"
|
||||
socketName := "/tmp/test socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -243,13 +243,13 @@ var _ = Describe("MPV", func() {
|
||||
|
||||
// Parse the captured arguments
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket"))
|
||||
Expect(lines).To(ContainElement("--script=" + scriptPath))
|
||||
})
|
||||
|
||||
Context("with complex snapcast configuration", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo"
|
||||
})
|
||||
|
||||
It("passes all snapcast arguments correctly", func() {
|
||||
@ -257,10 +257,9 @@ var _ = Describe("MPV", func() {
|
||||
defer cancel()
|
||||
|
||||
deviceName := "auto"
|
||||
filename := "/music/album/track.flac"
|
||||
socketName := "/tmp/mpv-ctrl-test.socket"
|
||||
|
||||
args := createMPVCommand(deviceName, filename, socketName)
|
||||
args := createMPVCommand(deviceName, socketName)
|
||||
executor, err := start(ctx, args)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -274,13 +273,13 @@ var _ = Describe("MPV", func() {
|
||||
// Verify all expected arguments are present
|
||||
Expect(lines).To(ContainElement("--no-audio-display"))
|
||||
Expect(lines).To(ContainElement("--pause"))
|
||||
Expect(lines).To(ContainElement("/music/album/track.flac"))
|
||||
Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket"))
|
||||
Expect(lines).To(ContainElement("--audio-channels=stereo"))
|
||||
Expect(lines).To(ContainElement("--audio-samplerate=48000"))
|
||||
Expect(lines).To(ContainElement("--audio-format=s16"))
|
||||
Expect(lines).To(ContainElement("--ao=pcm"))
|
||||
Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo"))
|
||||
Expect(lines).To(ContainElement("--script=" + scriptPath))
|
||||
})
|
||||
})
|
||||
|
||||
@ -320,38 +319,6 @@ var _ = Describe("MPV", func() {
|
||||
Expect(path).To(Equal(testScript))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("NewTrack integration", func() {
|
||||
var testMediaFile model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.MPVPath = testScript
|
||||
|
||||
// Create a test media file
|
||||
testMediaFile = model.MediaFile{
|
||||
ID: "test-id",
|
||||
Path: "/music/test.mp3",
|
||||
}
|
||||
})
|
||||
|
||||
Context("with malformed template", func() {
|
||||
BeforeEach(func() {
|
||||
// Template with unmatched quotes that will cause shell parsing to fail
|
||||
conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote`
|
||||
})
|
||||
|
||||
It("returns error when createMPVCommand fails", func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
playbackDone := make(chan bool, 1)
|
||||
_, err := NewTrack(ctx, playbackDone, "auto", testMediaFile)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(Equal("no mpv command arguments provided"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// createMockMPVScript creates a mock script that outputs arguments to stdout
|
||||
|
||||
@ -1,223 +0,0 @@
|
||||
package mpv
|
||||
|
||||
// Audio-playback using mpv media-server. See mpv.io
|
||||
// https://github.com/dexterlb/mpvipc
|
||||
// https://mpv.io/manual/master/#json-ipc
|
||||
// https://mpv.io/manual/master/#properties
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/dexterlb/mpvipc"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type MpvTrack struct {
|
||||
MediaFile model.MediaFile
|
||||
PlaybackDone chan bool
|
||||
Conn *mpvipc.Connection
|
||||
IPCSocketName string
|
||||
Exe *Executor
|
||||
CloseCalled bool
|
||||
}
|
||||
|
||||
func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) {
|
||||
log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType())
|
||||
|
||||
if _, err := mpvCommand(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tmpSocketName := socketName("mpv-ctrl-", ".socket")
|
||||
|
||||
args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName)
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("no mpv command arguments provided")
|
||||
}
|
||||
exe, err := start(ctx, args)
|
||||
if err != nil {
|
||||
log.Error("Error starting mpv process", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// wait for socket to show up
|
||||
err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn := mpvipc.NewConnection(tmpSocketName)
|
||||
err = conn.Open()
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false}
|
||||
|
||||
go func() {
|
||||
conn.WaitUntilClosed()
|
||||
log.Info("Hitting end-of-stream, signalling on channel")
|
||||
if !theTrack.CloseCalled {
|
||||
playbackDoneChannel <- true
|
||||
}
|
||||
}()
|
||||
|
||||
return theTrack, nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) String() string {
|
||||
return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName)
|
||||
}
|
||||
|
||||
// Used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (t *MpvTrack) SetVolume(value float32) {
|
||||
// mpv's volume as described in the --volume parameter:
|
||||
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
|
||||
// Negative values can be passed for compatibility, but are treated as 0.
|
||||
log.Debug("Setting volume", "volume", value, "track", t)
|
||||
vol := int(value * 100)
|
||||
|
||||
err := t.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error("Error setting volume", "volume", value, "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Unpause() {
|
||||
log.Debug("Unpausing track", "track", t)
|
||||
err := t.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error("Error unpausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Pause() {
|
||||
log.Debug("Pausing track", "track", t)
|
||||
err := t.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error("Error pausing track", "track", t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) Close() {
|
||||
log.Debug("Closing resources", "track", t)
|
||||
t.CloseCalled = true
|
||||
// trying to shutdown mpv process using socket
|
||||
if t.isSocketFilePresent() {
|
||||
log.Debug("sending shutdown command")
|
||||
_, err := t.Conn.Call("quit")
|
||||
if err != nil {
|
||||
log.Warn("Error sending quit command to mpv-ipc socket", err)
|
||||
|
||||
if t.Exe != nil {
|
||||
log.Debug("cancelling executor")
|
||||
err = t.Exe.Cancel()
|
||||
if err != nil {
|
||||
log.Warn("Error canceling executor", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.isSocketFilePresent() {
|
||||
removeSocket(t.IPCSocketName)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) isSocketFilePresent() bool {
|
||||
if len(t.IPCSocketName) < 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(t.IPCSocketName)
|
||||
return err == nil && fileInfo != nil && !fileInfo.IsDir()
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds.
|
||||
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
|
||||
// in this case we have to retry
|
||||
func (t *MpvTrack) Position() int {
|
||||
retryCount := 0
|
||||
for {
|
||||
position, err := t.Conn.Get("time-pos")
|
||||
if err != nil && err.Error() == "mpv error: property unavailable" {
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting position in track", "track", t, err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("Could not cast position from mpv into float64", "position", position, "track", t)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MpvTrack) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset, "track", t)
|
||||
pos := t.Position()
|
||||
if pos == offset {
|
||||
log.Debug("No position difference, skipping operation", "track", t)
|
||||
return nil
|
||||
}
|
||||
err := t.Conn.Set("time-pos", float64(offset))
|
||||
if err != nil {
|
||||
log.Error("Could not set the position in track", "track", t, "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MpvTrack) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing", "track", t)
|
||||
pausing, err := t.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("Problem getting paused status", "track", t, err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("Could not cast pausing to boolean", "track", t, "value", pausing)
|
||||
return false
|
||||
}
|
||||
return !pause
|
||||
}
|
||||
|
||||
func waitForSocket(path string, timeout time.Duration, pause time.Duration) error {
|
||||
start := time.Now()
|
||||
end := start.Add(timeout)
|
||||
var retries int = 0
|
||||
|
||||
for {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err == nil && fileInfo != nil && !fileInfo.IsDir() {
|
||||
log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(end) {
|
||||
return fmt.Errorf("timeout reached: %s", timeout)
|
||||
}
|
||||
time.Sleep(pause)
|
||||
retries += 1
|
||||
}
|
||||
}
|
||||
@ -7,30 +7,76 @@ package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/playback/mpv"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/singleton"
|
||||
)
|
||||
|
||||
// Define a Go struct that mirrors the data structure returned by the Lua script.
|
||||
type PlaylistTrack struct {
|
||||
// Basic info from mpv's playlist property
|
||||
Filename string `json:"filename"`
|
||||
IsPlaying bool `json:"isPlaying"`
|
||||
IsCurrent bool `json:"isCurrent"`
|
||||
PlaylistIndex int `json:"playlistIndex"`
|
||||
|
||||
// Rich metadata from our Lua cache (optional fields)
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Year string `json:"year,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Track int `json:"track,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
Size float64 `json:"size,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Suffix string `json:"suffix,omitempty"`
|
||||
BitRate float64 `json:"bitRate,omitempty"`
|
||||
}
|
||||
|
||||
type PlaybackServer interface {
|
||||
Run(ctx context.Context) error
|
||||
GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
GetMediaFile(id string) (*model.MediaFile, error)
|
||||
GetConnection() (*mpv.MpvConnection, error)
|
||||
LoadFile(mf *model.MediaFile, append bool, playNow bool) (bool, error)
|
||||
Clear() (bool, error)
|
||||
Remove(index int) (bool, error)
|
||||
Shuffle() (bool, error)
|
||||
SetGain(gain float32) (bool, error)
|
||||
IsPlaying() bool
|
||||
Skip(index int, offset int) (bool, error)
|
||||
Stop() (bool, error)
|
||||
Start() (bool, error)
|
||||
SetPosition(offset int) error
|
||||
Position() int
|
||||
GetPlaylistPosition() int
|
||||
GetPlaylistIDs() []string
|
||||
}
|
||||
|
||||
type playbackServer struct {
|
||||
ctx *context.Context
|
||||
datastore model.DataStore
|
||||
playbackDevices []playbackDevice
|
||||
Conn *mpv.MpvConnection
|
||||
}
|
||||
|
||||
// GetInstance returns the playback-server singleton
|
||||
func GetInstance(ds model.DataStore) PlaybackServer {
|
||||
return singleton.GetInstance(func() *playbackServer {
|
||||
return &playbackServer{datastore: ds}
|
||||
conn, err := mpv.NewConnection(context.Background(), "auto")
|
||||
if err != nil {
|
||||
log.Error("Error opening new connection", err)
|
||||
return nil
|
||||
}
|
||||
return &playbackServer{datastore: ds, Conn: conn}
|
||||
})
|
||||
}
|
||||
|
||||
@ -55,6 +101,11 @@ func (ps *playbackServer) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) GetConnection() (*mpv.MpvConnection, error) {
|
||||
log.Debug("Returning connection")
|
||||
return ps.Conn, nil
|
||||
}
|
||||
|
||||
func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) {
|
||||
pbDevices := make([]playbackDevice, max(1, len(devices)))
|
||||
defaultDeviceFound := false
|
||||
@ -125,3 +176,314 @@ func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error)
|
||||
device.User = user
|
||||
return device, nil
|
||||
}
|
||||
|
||||
// LoadFile loads a file into the MPV player, and optionally plays it right away
|
||||
func (ps *playbackServer) LoadFile(mf *model.MediaFile, append bool, playNow bool) (bool, error) {
|
||||
log.Debug("Loading file", "mf", mf, "append", append, "playNow", playNow)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
command := ""
|
||||
if append {
|
||||
command += "append"
|
||||
if playNow {
|
||||
log.Debug("Stopping current file")
|
||||
_, err := conn.Conn.Call("playlist-play-index", "none")
|
||||
if err != nil {
|
||||
log.Error("Error stopping current file", "mf", mf, err)
|
||||
}
|
||||
command += "-play"
|
||||
}
|
||||
} else {
|
||||
command += "replace"
|
||||
}
|
||||
|
||||
log.Debug("Loading file", "mf", mf.AbsolutePath(), "command", command)
|
||||
// Example: Tell Lua about the track's ID
|
||||
_, err = conn.Conn.Call("script-message", "attach-id", mf.AbsolutePath(), mf.ID)
|
||||
if err != nil {
|
||||
log.Error("Error attaching ID", "mf", mf, err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = conn.Conn.Call("loadfile", mf.AbsolutePath(), command)
|
||||
if err != nil {
|
||||
log.Error("Error loading file", "mf", mf, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Clear clears the playlist
|
||||
func (ps *playbackServer) Clear() (bool, error) {
|
||||
log.Debug("Clearing playlist")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("stop")
|
||||
if err != nil {
|
||||
log.Error("Error stopping current file", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Remove the playlist entry at the given index. Index values start counting with 0.
|
||||
// The special value current removes the current entry.
|
||||
// Note that removing the current entry also stops playback and starts playing the next entry.
|
||||
func (ps *playbackServer) Remove(index int) (bool, error) {
|
||||
log.Debug("Removing file", "index", index)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-remove", index)
|
||||
if err != nil {
|
||||
log.Error("Error removing file", "index", index, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SetGain is used to control the playback volume. A float value between 0.0 and 1.0.
|
||||
func (ps *playbackServer) SetGain(gain float32) (bool, error) {
|
||||
// mpv's volume as described in the --volume parameter:
|
||||
// Set the startup volume. 0 means silence, 100 means no volume reduction or amplification.
|
||||
// Negative values can be passed for compatibility, but are treated as 0.
|
||||
vol := int(gain * 100)
|
||||
log.Debug("Setting volume", "volume", vol)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("volume", vol)
|
||||
if err != nil {
|
||||
log.Error("Error setting volume", "volume", vol, err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Shuffle shuffles the playlist
|
||||
func (ps *playbackServer) Shuffle() (bool, error) {
|
||||
log.Debug("Shuffling playlist")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-shuffle")
|
||||
if err != nil {
|
||||
log.Error("Error shuffling playlist", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsPlaying checks if the currently playing track is paused
|
||||
func (ps *playbackServer) IsPlaying() bool {
|
||||
log.Debug("Checking if track is playing")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false
|
||||
}
|
||||
pausing, err := conn.Conn.Get("pause")
|
||||
if err != nil {
|
||||
log.Error("Problem getting paused status", err)
|
||||
return false
|
||||
}
|
||||
|
||||
pause, ok := pausing.(bool)
|
||||
if !ok {
|
||||
log.Error("Could not cast pausing to boolean", "value", pausing)
|
||||
return false
|
||||
}
|
||||
log.Debug("Checked if track is playing", "pausing", pause)
|
||||
return !pause
|
||||
}
|
||||
|
||||
// Skip skips to the given track
|
||||
func (ps *playbackServer) Skip(index int, offset int) (bool, error) {
|
||||
log.Debug("Skipping to track", "index", index, "offset", offset)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
_, err = conn.Conn.Call("playlist-play-index", index)
|
||||
if err != nil {
|
||||
log.Error("Error skipping to track", "index", index, err)
|
||||
return false, err
|
||||
}
|
||||
if offset > 0 {
|
||||
_, err = conn.Conn.Call("seek", offset, "absolute")
|
||||
if err != nil {
|
||||
log.Error("Error skipping to offset", "offset", offset, err)
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Stop stops the currently playing track
|
||||
func (ps *playbackServer) Stop() (bool, error) {
|
||||
log.Debug("Stopping track")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("pause", true)
|
||||
if err != nil {
|
||||
log.Error("Error stopping track", "err", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Start starts the currently playing track
|
||||
func (ps *playbackServer) Start() (bool, error) {
|
||||
log.Debug("Starting track")
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return false, err
|
||||
}
|
||||
err = conn.Conn.Set("pause", false)
|
||||
if err != nil {
|
||||
log.Error("Error starting track", "err", err)
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Position returns the playback position in seconds.
|
||||
// Every now and then the mpv IPC interface returns "mpv error: property unavailable"
|
||||
// in this case we have to retry
|
||||
func (ps *playbackServer) Position() int {
|
||||
retryCount := 0
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return 0
|
||||
}
|
||||
for {
|
||||
position, err := conn.Conn.Get("time-pos")
|
||||
if err != nil && err.Error() == "mpv error: property unavailable" {
|
||||
retryCount += 1
|
||||
log.Debug("Got mpv error, retrying...", "retries", retryCount, err)
|
||||
if retryCount > 5 {
|
||||
return 0
|
||||
}
|
||||
time.Sleep(time.Duration(retryCount) * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting position in track", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
pos, ok := position.(float64)
|
||||
if !ok {
|
||||
log.Error("Could not cast position from mpv into float64", "position", position)
|
||||
return 0
|
||||
} else {
|
||||
return int(pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetPosition sets the position in the currently playing track
|
||||
func (ps *playbackServer) SetPosition(offset int) error {
|
||||
log.Debug("Setting position", "offset", offset)
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return err
|
||||
}
|
||||
_, err = conn.Conn.Call("seek", float64(offset), "absolute")
|
||||
if err != nil {
|
||||
log.Error("Error setting position", "offset", offset, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Current position on playlist. The first entry is on position 0.
|
||||
func (ps *playbackServer) GetPlaylistPosition() int {
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting connection", err)
|
||||
return 0
|
||||
}
|
||||
position, err := conn.Conn.Get("playlist-pos")
|
||||
if err != nil {
|
||||
log.Error("Error getting current position", err)
|
||||
return 0
|
||||
}
|
||||
return int(position.(float64))
|
||||
}
|
||||
|
||||
// This function now retrieves an ordered list of database IDs from the mpv playlist.
|
||||
// The return type has been changed from []PlaylistTrack to []string.
|
||||
func (ps *playbackServer) GetPlaylistIDs() []string {
|
||||
conn, err := ps.GetConnection()
|
||||
if err != nil {
|
||||
log.Error("Error getting mpv connection", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Call the new, renamed script message
|
||||
_, err = conn.Conn.Call("script-message", "update_playlist_ids_property")
|
||||
if err != nil {
|
||||
log.Error("Error calling mpv script 'update_playlist_ids_property'", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Poll for the property with a retry loop to avoid a race condition
|
||||
var result interface{}
|
||||
maxRetries := 5
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
// Use the new, renamed property. Using GetProperty is slightly safer.
|
||||
result, err = conn.Conn.Get("user-data/ext-playlist-ids")
|
||||
if err == nil && result != nil {
|
||||
break // Success!
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Error("Error getting 'user-data/ext-playlist-ids' property after retries", "error", err)
|
||||
return []string{}
|
||||
}
|
||||
if result == nil {
|
||||
log.Error("Property 'user-data/ext-playlist-ids' was still nil after retries.")
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// The result from mpv is the JSON string of the ID list.
|
||||
jsonString, ok := result.(string)
|
||||
if !ok {
|
||||
log.Error("Expected a string from 'ext-playlist-ids' property but got something else", "type", fmt.Sprintf("%T", result))
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// Unmarshal the JSON array of strings into a slice of strings.
|
||||
var idList []string
|
||||
if err := json.Unmarshal([]byte(jsonString), &idList); err != nil {
|
||||
log.Error("Error unmarshalling playlist ID JSON into slice of strings", "error", err, "data", jsonString)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
log.Info("Successfully retrieved playlist IDs from mpv.", "id_count", len(idList))
|
||||
return idList
|
||||
}
|
||||
|
||||
@ -83,6 +83,15 @@ nfpms:
|
||||
owner: navidrome
|
||||
group: navidrome
|
||||
|
||||
- src: release/linux/.package.rpm # contents: "rpm"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: rpm
|
||||
- src: release/linux/.package.deb # contents: "deb"
|
||||
dst: /var/lib/navidrome/.package
|
||||
type: "config|noreplace"
|
||||
packager: deb
|
||||
|
||||
scripts:
|
||||
preinstall: "release/linux/preinstall.sh"
|
||||
postinstall: "release/linux/postinstall.sh"
|
||||
|
||||
1
release/linux/.package.deb
Normal file
1
release/linux/.package.deb
Normal file
@ -0,0 +1 @@
|
||||
deb
|
||||
1
release/linux/.package.rpm
Normal file
1
release/linux/.package.rpm
Normal file
@ -0,0 +1 @@
|
||||
rpm
|
||||
@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
|
||||
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
|
||||
cp "$BINARY" "$MSI_OUTPUT_DIR"
|
||||
|
||||
# package type indicator file
|
||||
echo "msi" > "$MSI_OUTPUT_DIR/.package"
|
||||
|
||||
# workaround for wixl WixVariable not working to override bmp locations
|
||||
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
|
||||
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
|
||||
|
||||
@ -69,6 +69,12 @@
|
||||
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]">
|
||||
<Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)">
|
||||
<File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' />
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<InstallUISequence>
|
||||
@ -81,6 +87,7 @@
|
||||
<ComponentRef Id='Configuration'/>
|
||||
<ComponentRef Id='MainExecutable' />
|
||||
<ComponentRef Id='FFMpegExecutable' />
|
||||
<ComponentRef Id='PackageFile' />
|
||||
</Feature>
|
||||
</Product>
|
||||
</Wix>
|
||||
|
||||
@ -1,628 +0,0 @@
|
||||
{
|
||||
"languageName": "Tiếng Việt",
|
||||
"resources": {
|
||||
"song": {
|
||||
"name": "Tên bài hát",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"duration": "Thời lượng",
|
||||
"trackNumber": "#",
|
||||
"playCount": "Số lượt phát",
|
||||
"title": "Tên",
|
||||
"artist": "Nghệ sĩ",
|
||||
"album": "Album",
|
||||
"path": "Đường dẫn file",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"size": "Kích thước tệp",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"bitRate": "Số bit",
|
||||
"discSubtitle": "Tiêu đề phụ của đĩa",
|
||||
"starred": "Yêu thích",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"quality": "Chất lượng",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Phát lần cuối",
|
||||
"channels": "Kênh",
|
||||
"createdAt": "Ngày thêm bài hát",
|
||||
"grouping": "Nhóm",
|
||||
"mood": "Tâm trạng",
|
||||
"participants": "Người tham gia bổ sung",
|
||||
"tags": "Tag bổ sung",
|
||||
"mappedTags": "Thẻ đã liên kết",
|
||||
"rawTags": "Thẻ gốc",
|
||||
"bitDepth": "",
|
||||
"sampleRate": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "Thêm bài hát vào hàng chờ",
|
||||
"playNow": "Phát ",
|
||||
"addToPlaylist": "Thêm vào danh sách",
|
||||
"shuffleAll": "Ngẫu nhiên Tất cả",
|
||||
"download": "Tải bài hát xuống",
|
||||
"playNext": "Phát tiếp theo",
|
||||
"info": "Lấy thông tin bài hát",
|
||||
"showInPlaylist": ""
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"name": "Tên album",
|
||||
"fields": {
|
||||
"albumArtist": "Nghệ sĩ trong album",
|
||||
"artist": "Nghệ sĩ",
|
||||
"duration": "Thời lượng",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"name": "Tên",
|
||||
"genre": "Thể loại",
|
||||
"compilation": "Tuyển tập",
|
||||
"year": "Năm",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"comment": "Bình luận",
|
||||
"rating": "Đánh giá",
|
||||
"createdAt": "Ngày thêm album",
|
||||
"size": "Kích cỡ",
|
||||
"originalDate": "Bản gốc",
|
||||
"releaseDate": "Ngày phát hành",
|
||||
"releases": "Bản phát hành |||| Các bản phát hành",
|
||||
"released": "Đã phát hành",
|
||||
"recordLabel": "Hãng đĩa",
|
||||
"catalogNum": "Số Catalog",
|
||||
"releaseType": "Loai",
|
||||
"grouping": "Nhóm",
|
||||
"media": "",
|
||||
"mood": "",
|
||||
"date": "",
|
||||
"missing": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"playAll": "Phát",
|
||||
"playNext": "Tiếp theo",
|
||||
"addToQueue": "Thêm album vào hàng chờ",
|
||||
"shuffle": "phát ngẫu nhiên",
|
||||
"addToPlaylist": "Thêm vào danh sách phát",
|
||||
"download": "Tải Album xuống",
|
||||
"info": "Lấy thông tin album",
|
||||
"share": "Chia sẻ"
|
||||
},
|
||||
"lists": {
|
||||
"all": "Tất cả",
|
||||
"random": "Ngẫu nhiên",
|
||||
"recentlyAdded": "Thêm vào gần đây",
|
||||
"recentlyPlayed": "Đã phát gần đây",
|
||||
"mostPlayed": "Phát nhiều nhất",
|
||||
"starred": "Album Yêu thích",
|
||||
"topRated": "Được đánh giá cao nhất"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"name": "Nghệ sĩ",
|
||||
"fields": {
|
||||
"name": "Tên nghệ sĩ",
|
||||
"albumCount": "Số Album",
|
||||
"songCount": "Số bài hát",
|
||||
"playCount": "Số lượt phát",
|
||||
"rating": "Đánh giá",
|
||||
"genre": "Thể loại",
|
||||
"size": "Kích cỡ",
|
||||
"role": "",
|
||||
"missing": ""
|
||||
},
|
||||
"roles": {
|
||||
"albumartist": "",
|
||||
"artist": "",
|
||||
"composer": "",
|
||||
"conductor": "",
|
||||
"lyricist": "",
|
||||
"arranger": "",
|
||||
"producer": "",
|
||||
"director": "",
|
||||
"engineer": "",
|
||||
"mixer": "",
|
||||
"remixer": "",
|
||||
"djmixer": "",
|
||||
"performer": "",
|
||||
"maincredit": ""
|
||||
},
|
||||
"actions": {
|
||||
"shuffle": "",
|
||||
"radio": "",
|
||||
"topSongs": ""
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"name": "Người dùng",
|
||||
"fields": {
|
||||
"userName": "Tên người dùng",
|
||||
"isAdmin": "Quản trị viên",
|
||||
"lastLoginAt": "Lần đăng nhập cuối",
|
||||
"updatedAt": "Cập nhật lúc",
|
||||
"name": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"createdAt": "Tạo vào",
|
||||
"changePassword": "Đổi mật khẩu ?",
|
||||
"currentPassword": "Mật khẩu hiện tại",
|
||||
"newPassword": "Mật khẩu mới",
|
||||
"token": "Token",
|
||||
"lastAccessAt": "Lần truy cập cuối",
|
||||
"libraries": ""
|
||||
},
|
||||
"helperTexts": {
|
||||
"name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
|
||||
"libraries": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Tạo bởi user",
|
||||
"updated": "Cập nhật bởi user",
|
||||
"deleted": "Xóa người dùng"
|
||||
},
|
||||
"message": {
|
||||
"listenBrainzToken": "Nhập token của MusicBrainz",
|
||||
"clickHereForToken": "",
|
||||
"selectAllLibraries": "",
|
||||
"adminAutoLibraries": ""
|
||||
},
|
||||
"validation": {
|
||||
"librariesRequired": ""
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"name": "Trình phát |||| Các trình phát",
|
||||
"fields": {
|
||||
"name": "Tên trình phát",
|
||||
"transcodingId": "Mã chuyển mã",
|
||||
"maxBitRate": "Bit Rate cao nhất",
|
||||
"client": "",
|
||||
"userName": "Tên người dùng",
|
||||
"lastSeen": "Lần cuối nhìn thấy",
|
||||
"reportRealPath": "Hiện đường dẫn thực",
|
||||
"scrobbleEnabled": ""
|
||||
}
|
||||
},
|
||||
"transcoding": {
|
||||
"name": "Chuyển đổi định dạng",
|
||||
"fields": {
|
||||
"name": "Tên cấu hình chuyển mã",
|
||||
"targetFormat": "Định dạng cuối",
|
||||
"defaultBitRate": "Số Bit mặc định",
|
||||
"command": "Câu lệnh"
|
||||
}
|
||||
},
|
||||
"playlist": {
|
||||
"name": "Danh sách phát |||| Các danh sách phát",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"duration": "Thời lượng",
|
||||
"ownerName": "Chủ sở hữu",
|
||||
"public": "Công khai",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc",
|
||||
"songCount": "Số bài hát",
|
||||
"comment": "Bình luận",
|
||||
"sync": "Tự động thêm vào",
|
||||
"path": "Nhập từ"
|
||||
},
|
||||
"actions": {
|
||||
"selectPlaylist": "Chọn 1 danh sách phát",
|
||||
"addNewPlaylist": "Tạo \"%{name}\"",
|
||||
"export": "Xuất danh sách phát",
|
||||
"makePublic": "",
|
||||
"makePrivate": "",
|
||||
"saveQueue": "",
|
||||
"searchOrCreate": "",
|
||||
"pressEnterToCreate": "",
|
||||
"removeFromSelection": ""
|
||||
},
|
||||
"message": {
|
||||
"duplicate_song": "Thêm các bài hát trùng lặp",
|
||||
"song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
|
||||
"noPlaylistsFound": "",
|
||||
"noPlaylists": ""
|
||||
}
|
||||
},
|
||||
"radio": {
|
||||
"name": "Radio |||| Radios",
|
||||
"fields": {
|
||||
"name": "Tên",
|
||||
"streamUrl": "Stream URL",
|
||||
"homePageUrl": "URL trang chủ",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào lúc"
|
||||
},
|
||||
"actions": {
|
||||
"playNow": "Phát ngay"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Chia sẻ |||| Chia sẻ",
|
||||
"fields": {
|
||||
"username": "Chia sẻ bởi",
|
||||
"url": "URL",
|
||||
"description": "Phần mô tả",
|
||||
"contents": "Nội dung",
|
||||
"expiresAt": "Hết hạn",
|
||||
"lastVisitedAt": "Lần mở cuối ",
|
||||
"visitCount": "Lượt ",
|
||||
"format": "Định dạng",
|
||||
"maxBitRate": "Số Bit cao nhất",
|
||||
"updatedAt": "Cập nhật vào",
|
||||
"createdAt": "Tạo vào",
|
||||
"downloadable": "Cho phép tải xuống?"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"path": "",
|
||||
"size": "",
|
||||
"updatedAt": "",
|
||||
"libraryName": ""
|
||||
},
|
||||
"actions": {
|
||||
"remove": "",
|
||||
"remove_all": ""
|
||||
},
|
||||
"notifications": {
|
||||
"removed": ""
|
||||
},
|
||||
"empty": ""
|
||||
},
|
||||
"library": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"name": "",
|
||||
"path": "",
|
||||
"remotePath": "",
|
||||
"lastScanAt": "",
|
||||
"songCount": "",
|
||||
"albumCount": "",
|
||||
"artistCount": "",
|
||||
"totalSongs": "",
|
||||
"totalAlbums": "",
|
||||
"totalArtists": "",
|
||||
"totalFolders": "",
|
||||
"totalFiles": "",
|
||||
"totalMissingFiles": "",
|
||||
"totalSize": "",
|
||||
"totalDuration": "",
|
||||
"defaultNewUsers": "",
|
||||
"createdAt": "",
|
||||
"updatedAt": ""
|
||||
},
|
||||
"sections": {
|
||||
"basic": "",
|
||||
"statistics": ""
|
||||
},
|
||||
"actions": {
|
||||
"scan": "",
|
||||
"manageUsers": "",
|
||||
"viewDetails": ""
|
||||
},
|
||||
"notifications": {
|
||||
"created": "",
|
||||
"updated": "",
|
||||
"deleted": "Xóa thư viện thành công",
|
||||
"scanStarted": "Bắt đầu quét thư viện",
|
||||
"scanCompleted": "Quét thư viện hoàn tất"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "",
|
||||
"pathRequired": "",
|
||||
"pathNotDirectory": "",
|
||||
"pathNotFound": "",
|
||||
"pathNotAccessible": "",
|
||||
"pathInvalid": ""
|
||||
},
|
||||
"messages": {
|
||||
"deleteConfirm": "",
|
||||
"scanInProgress": "Đang quét...",
|
||||
"noLibrariesAssigned": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
"auth": {
|
||||
"welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
|
||||
"welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
|
||||
"confirmPassword": "Xác nhận mật khẩu",
|
||||
"buttonCreateAdmin": "Tạo quản trị viên",
|
||||
"auth_check_error": "Hãy đăng nhập để tiếp tục",
|
||||
"user_menu": "Profile",
|
||||
"username": "Tên người dùng",
|
||||
"password": "Mật khẩu",
|
||||
"sign_in": "Đăng nhập",
|
||||
"sign_in_error": "Xác thực thất bại, hãy thử lại",
|
||||
"logout": "Đăng xuất",
|
||||
"insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
|
||||
},
|
||||
"validation": {
|
||||
"invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
|
||||
"passwordDoesNotMatch": "Mật khẩu không đúng",
|
||||
"required": "Yêu cầu",
|
||||
"minLength": "Ít nhất là %{min} ký tự",
|
||||
"maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
|
||||
"minValue": "Ít nhất là %{min}",
|
||||
"maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
|
||||
"number": "Phải là một số",
|
||||
"email": "Phải là một email ",
|
||||
"oneOf": "Phải là một trong các lựa chọn sau: %{options}",
|
||||
"regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
|
||||
"unique": "Phải đặc biệt",
|
||||
"url": "Phải là một URL hợp lệ"
|
||||
},
|
||||
"action": {
|
||||
"add_filter": "Thêm bộ lọc",
|
||||
"add": "Thêm",
|
||||
"back": "Quay lại",
|
||||
"bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
|
||||
"cancel": "Hủy",
|
||||
"clear_input_value": "Xóa thiết đặt",
|
||||
"clone": "Nhân bản",
|
||||
"confirm": "Xác nhận",
|
||||
"create": "Tạo",
|
||||
"delete": "Xóa",
|
||||
"edit": "Sửa",
|
||||
"export": "Xuất",
|
||||
"list": "Danh sách",
|
||||
"refresh": "Làm mới",
|
||||
"remove_filter": "Bỏ bộ lọc này",
|
||||
"remove": "Gỡ bỏ",
|
||||
"save": "Lưu lại",
|
||||
"search": "Tìm kiếm",
|
||||
"show": "Hiển thị",
|
||||
"sort": "Lọc",
|
||||
"undo": "Hoàn tác",
|
||||
"expand": "Mở rộng",
|
||||
"close": "Đóng",
|
||||
"open_menu": "Mở menu",
|
||||
"close_menu": "Đóng menu",
|
||||
"unselect": "Bỏ chọn",
|
||||
"skip": "Bỏ qua",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Chia sẻ",
|
||||
"download": "Tải xuống"
|
||||
},
|
||||
"boolean": {
|
||||
"true": "Có",
|
||||
"false": "Không"
|
||||
},
|
||||
"page": {
|
||||
"create": "Tạo %{name}",
|
||||
"dashboard": "Trang chủ",
|
||||
"edit": "%{name} #%{id}",
|
||||
"error": "Có gì đó không ổn",
|
||||
"list": "%{name}",
|
||||
"loading": "Đang tải",
|
||||
"not_found": "Không tìm thấy",
|
||||
"show": "%{name} #%{id}",
|
||||
"empty": "Chưa có %{name}",
|
||||
"invite": "Bạn muốn thêm vào không ?"
|
||||
},
|
||||
"input": {
|
||||
"file": {
|
||||
"upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"image": {
|
||||
"upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
|
||||
"upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
|
||||
},
|
||||
"references": {
|
||||
"all_missing": "Không thể tìm thấy dữ liệu",
|
||||
"many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
|
||||
"single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
|
||||
},
|
||||
"password": {
|
||||
"toggle_visible": "Ẩn mật khẩu",
|
||||
"toggle_hidden": "Hiện mật khẩu"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"about": "Giới thiệu",
|
||||
"are_you_sure": "Bạn chắc chứ ?",
|
||||
"bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
|
||||
"bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
|
||||
"delete_content": "Xác nhận xóa ?",
|
||||
"delete_title": "Xóa %{name} #%{id}",
|
||||
"details": "Chi tiết",
|
||||
"error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
|
||||
"invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
|
||||
"loading": "Trang đang được tải, hãy kiên nhận",
|
||||
"no": "Không",
|
||||
"not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
|
||||
"yes": "Có",
|
||||
"unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
|
||||
},
|
||||
"navigation": {
|
||||
"no_results": "Không tìm thấy kết quả",
|
||||
"no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
|
||||
"page_out_of_boundaries": "Trang %{page} không hợp lệ",
|
||||
"page_out_from_end": "Bạn đang ở trang cuối rồi",
|
||||
"page_out_from_begin": "Không thể quay về trước trang 1",
|
||||
"page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
|
||||
"page_rows_per_page": "Số mục mỗi trang :",
|
||||
"next": "Tiếp theo",
|
||||
"prev": "Trước",
|
||||
"skip_nav": "Bỏ qua đến nội dung"
|
||||
},
|
||||
"notification": {
|
||||
"updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
|
||||
"created": "Đã tạo mục mới",
|
||||
"deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
|
||||
"bad_item": "Mục không đúng",
|
||||
"item_doesnt_exist": "Mục không tồn tại",
|
||||
"http_error": "Lỗi kết nối đến máy chủ",
|
||||
"data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
|
||||
"i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
|
||||
"canceled": "Hành động đã bị hủy",
|
||||
"logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
|
||||
"new_version": "Có phiên bản mới! Hãy làm mới trang"
|
||||
},
|
||||
"toggleFieldsMenu": {
|
||||
"columnsToDisplay": "Các cột hiển thị",
|
||||
"layout": "Bố cục",
|
||||
"grid": "Lưới",
|
||||
"table": "Bảng"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"note": "Lưu ý",
|
||||
"transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
|
||||
"transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
|
||||
"songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
|
||||
"noPlaylistsAvailable": "Không có danh sách phát",
|
||||
"delete_user_title": "Xóa người dùng '%{name}'",
|
||||
"delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
|
||||
"notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
|
||||
"notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
|
||||
"lastfmLinkSuccess": "",
|
||||
"lastfmLinkFailure": "",
|
||||
"lastfmUnlinkSuccess": "",
|
||||
"lastfmUnlinkFailure": "",
|
||||
"openIn": {
|
||||
"lastfm": "Mở trong Last.fm",
|
||||
"musicbrainz": "Mở trong MusicBrainz"
|
||||
},
|
||||
"lastfmLink": "Đọc thêm...",
|
||||
"listenBrainzLinkSuccess": "",
|
||||
"listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
|
||||
"listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
|
||||
"listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
|
||||
"downloadOriginalFormat": "Tải xuống ở định dạng gốc",
|
||||
"shareOriginalFormat": "Chia sẻ ở định dạng gốc",
|
||||
"shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
|
||||
"shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
|
||||
"shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
|
||||
"shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
|
||||
"downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
|
||||
"shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
|
||||
"remove_missing_title": "",
|
||||
"remove_missing_content": "",
|
||||
"remove_all_missing_title": "",
|
||||
"remove_all_missing_content": "",
|
||||
"noSimilarSongsFound": "",
|
||||
"noTopSongsFound": ""
|
||||
},
|
||||
"menu": {
|
||||
"library": "Thư viện",
|
||||
"settings": "Cài đặt",
|
||||
"version": "Phiên bản",
|
||||
"theme": "Theme",
|
||||
"personal": {
|
||||
"name": "Cá nhân hóa",
|
||||
"options": {
|
||||
"theme": "Theme",
|
||||
"language": "Ngôn ngữ",
|
||||
"defaultView": "",
|
||||
"desktop_notifications": "Thông báo trên desktop",
|
||||
"lastfmScrobbling": "",
|
||||
"listenBrainzScrobbling": "",
|
||||
"replaygain": "Chế độ ReplayGain",
|
||||
"preAmp": "ReplayGain PreAmp (dB)",
|
||||
"gain": {
|
||||
"none": "Tắt",
|
||||
"album": "Dùng Album Gain",
|
||||
"track": "Dùng Track Gain"
|
||||
},
|
||||
"lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
|
||||
}
|
||||
},
|
||||
"albumList": "Albums",
|
||||
"about": "Về",
|
||||
"playlists": "Danh sách phát",
|
||||
"sharedPlaylists": "Danh sách phát được chia sẻ",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Tất cả thư viện (%{count})",
|
||||
"multipleLibraries": "",
|
||||
"selectLibraries": "",
|
||||
"none": "Không có"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Danh sách chờ",
|
||||
"openText": "Mở",
|
||||
"closeText": "Thoát",
|
||||
"notContentText": "Không có bài hát",
|
||||
"clickToPlayText": "Nhấp để phát",
|
||||
"clickToPauseText": "Nhấp để tạm dừng",
|
||||
"nextTrackText": "Track tiếp theo",
|
||||
"previousTrackText": "Track trước đó",
|
||||
"reloadText": "Làm mới",
|
||||
"volumeText": "Âm lượng",
|
||||
"toggleLyricText": "Bật lời bài hát",
|
||||
"toggleMiniModeText": "Thu nhỏ",
|
||||
"destroyText": "Xóa",
|
||||
"downloadText": "Tải xuống",
|
||||
"removeAudioListsText": "Xóa danh sách ",
|
||||
"clickToDeleteText": "Nhấp để xóa %{name}",
|
||||
"emptyLyricText": "Không có lời",
|
||||
"playModeText": {
|
||||
"order": "Theo thứ tự",
|
||||
"orderLoop": "Lặp lại",
|
||||
"singleLoop": "Lặp lại một lần",
|
||||
"shufflePlay": "Phát ngẫu nhiên"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"links": {
|
||||
"homepage": "Trang chủ",
|
||||
"source": "Mã nguồn",
|
||||
"featureRequests": "Yêu cầu tính năng",
|
||||
"lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
|
||||
"insights": {
|
||||
"disabled": "Đã tắt",
|
||||
"waiting": "Đang chờ"
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
"about": "",
|
||||
"config": ""
|
||||
},
|
||||
"config": {
|
||||
"configName": "",
|
||||
"environmentVariable": "",
|
||||
"currentValue": "",
|
||||
"configurationFile": "",
|
||||
"exportToml": "",
|
||||
"exportSuccess": "",
|
||||
"exportFailed": "",
|
||||
"devFlagsHeader": "",
|
||||
"devFlagsComment": ""
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Hoạt động",
|
||||
"totalScanned": "Tổng Folder đã quét",
|
||||
"quickScan": "Quét nhanh",
|
||||
"fullScan": "Quét toàn bộ",
|
||||
"serverUptime": "Server Uptime",
|
||||
"serverDown": "Ngoại tuyến",
|
||||
"scanType": "",
|
||||
"status": "",
|
||||
"elapsedTime": ""
|
||||
},
|
||||
"help": {
|
||||
"title": "Phím tắt của Navidrome",
|
||||
"hotkeys": {
|
||||
"show_help": "Hiện giúp đỡ",
|
||||
"toggle_menu": "Bật thanh phát bên",
|
||||
"toggle_play": "Phát / tạm dừng",
|
||||
"prev_song": "Bài hát trước đó",
|
||||
"next_song": "Bài hát sau đó",
|
||||
"vol_up": "Tăng âm lượng",
|
||||
"vol_down": "Giảm âm lượng",
|
||||
"toggle_love": "Thêm track này vào yêu thích",
|
||||
"current_song": "Đi đến bài hát hiện tại"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "",
|
||||
"empty": "",
|
||||
"minutesAgo": ""
|
||||
}
|
||||
}
|
||||
@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "createBookmark", api.CreateBookmark)
|
||||
h(r, "deleteBookmark", api.DeleteBookmark)
|
||||
h(r, "getPlayQueue", api.GetPlayQueue)
|
||||
h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
|
||||
h(r, "savePlayQueue", api.SavePlayQueue)
|
||||
h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
|
||||
@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
pq, err := repo.RetrieveWithMediaFiles(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if pq == nil || len(pq.Items) == 0 {
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
var index *int
|
||||
if len(pq.Items) > 0 {
|
||||
index = &pq.Current
|
||||
}
|
||||
|
||||
response.PlayQueueByIndex = &responses.PlayQueueByIndex{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
CurrentIndex: index,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: pq.UpdatedAt,
|
||||
ChangedBy: pq.ChangedBy,
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
var err error
|
||||
var currentIndex int
|
||||
|
||||
if len(ids) > 0 {
|
||||
currentIndex, err = p.Int("currentIndex")
|
||||
if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
CreatedAt: time.Time{},
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
|
||||
repo := api.ds.PlayQueue(r.Context())
|
||||
err = repo.Store(pq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/public"
|
||||
@ -166,6 +167,33 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
return
|
||||
}
|
||||
|
||||
// childFromPlaylistTrack finds a media file in the database based on the path from an
|
||||
// mpv PlaylistTrack, and then uses the original childFromMediaFile to perform the full conversion.
|
||||
// This is the correct, robust way to handle the conversion.
|
||||
func (api *Router) childFromPlaylistTrack(ctx context.Context, ptId string) responses.Child {
|
||||
log.Debug(ctx, "childFromPlaylistTrack", "track", ptId)
|
||||
if ptId == "" {
|
||||
return responses.Child{}
|
||||
}
|
||||
|
||||
// Step 1: Use the path from the mpv track to find the full MediaFile in the database.
|
||||
mf, err := api.ds.MediaFile(ctx).Get(ptId)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Could not find media file in database for path from mpv", "filename", ptId, "error", err)
|
||||
return responses.Child{}
|
||||
}
|
||||
// Step 2: Once we have the full MediaFile object, call the original, trusted function.
|
||||
// This reuses the existing logic and ensures all database-backed fields are populated.
|
||||
log.Debug(ctx, "childFromPlaylistTrack retrieved", "mediafile", mf)
|
||||
child := childFromMediaFile(ctx, *mf)
|
||||
|
||||
// Optional Step 3: We can override fields with more current data from mpv if desired.
|
||||
// For example, if the user is playing a transcoded stream, the bitrate might differ.
|
||||
// For now, we will trust the database record and the original function.
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
|
||||
@ -52,14 +52,15 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error)
|
||||
|
||||
switch actionString {
|
||||
case ActionGet:
|
||||
mediafiles, status, err := pb.Get(ctx)
|
||||
playlistTrackIds, status, err := pb.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info(ctx, "JukeboxControl get", "playlistTracks", playlistTrackIds, "status", status)
|
||||
|
||||
playlist := responses.JukeboxPlaylist{
|
||||
JukeboxStatus: *deviceStatusToJukeboxStatus(status),
|
||||
Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile),
|
||||
Entry: slice.MapWithArg(playlistTrackIds, ctx, api.childFromPlaylistTrack),
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
|
||||
@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "transcodeOffset", Versions: []int32{1}},
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(3),
|
||||
HaveLen(4),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"openSubsonic": true,
|
||||
"playQueue": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue username="" changedBy=""></playQueue>
|
||||
<playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"entry": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
"position": 243,
|
||||
"username": "user1",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": "a_client"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@ -0,0 +1,12 @@
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
"type": "navidrome",
|
||||
"serverVersion": "v0.55.0",
|
||||
"openSubsonic": true,
|
||||
"playQueueByIndex": {
|
||||
"username": "",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"changedBy": ""
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex>
|
||||
</subsonic-response>
|
||||
@ -60,6 +60,7 @@ type Subsonic struct {
|
||||
// OpenSubsonic extensions
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@ -439,12 +440,21 @@ type TopSongs struct {
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type PlayQueueByIndex struct {
|
||||
Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
|
||||
CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
|
||||
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
|
||||
Username string `xml:"username,attr" json:"username"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
|
||||
}
|
||||
|
||||
type Bookmark struct {
|
||||
|
||||
@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
|
||||
response.PlayQueue.Username = "user1"
|
||||
response.PlayQueue.Current = "111"
|
||||
response.PlayQueue.Position = 243
|
||||
response.PlayQueue.Changed = &time.Time{}
|
||||
response.PlayQueue.Changed = time.Time{}
|
||||
response.PlayQueue.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("PlayQueueByIndex", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex = &PlayQueueByIndex{}
|
||||
})
|
||||
|
||||
Context("without data", func() {
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
BeforeEach(func() {
|
||||
response.PlayQueueByIndex.Username = "user1"
|
||||
response.PlayQueueByIndex.CurrentIndex = gg.P(0)
|
||||
response.PlayQueueByIndex.Position = 243
|
||||
response.PlayQueueByIndex.Changed = time.Time{}
|
||||
response.PlayQueueByIndex.ChangedBy = "a_client"
|
||||
child := make([]Child, 1)
|
||||
child[0] = Child{Id: "1", Title: "title", IsDir: false}
|
||||
response.PlayQueueByIndex.Entry = child
|
||||
})
|
||||
It("should match .XML", func() {
|
||||
Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
It("should match .JSON", func() {
|
||||
Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Shares", func() {
|
||||
BeforeEach(func() {
|
||||
response.Shares = &Shares{}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user