562 lines
19 KiB
C#
562 lines
19 KiB
C#
using System.Diagnostics;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace PainoBar_Helper
|
|
{
|
|
/// <summary>
|
|
/// Manages the Pianobar process lifecycle and communication
|
|
/// </summary>
|
|
public class PianobarManager : IDisposable
|
|
{
|
|
private Process? _process;
|
|
private StreamWriter? _inputWriter;
|
|
private bool _isConnected;
|
|
private readonly object _lockObject = new object();
|
|
private PianobarEventMonitor? _eventMonitor;
|
|
private bool _receivedStdOut;
|
|
private bool _receivedStdErr;
|
|
private readonly string _ioLogPath = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"PianobarHelper",
|
|
"pianobar-io.log");
|
|
|
|
public event EventHandler<SongChangedEventArgs>? SongChanged;
|
|
public event EventHandler<string>? StationChanged;
|
|
public event EventHandler<PianobarEventArgs>? EventOccurred;
|
|
public event EventHandler? Connected;
|
|
public event EventHandler? Disconnected;
|
|
public event EventHandler<string>? OutputReceived;
|
|
|
|
public bool IsConnected
|
|
{
|
|
get { lock (_lockObject) return _isConnected; }
|
|
private set
|
|
{
|
|
bool changed = false;
|
|
lock (_lockObject)
|
|
{
|
|
if (_isConnected != value)
|
|
{
|
|
_isConnected = value;
|
|
changed = true;
|
|
}
|
|
}
|
|
if (changed)
|
|
{
|
|
if (value)
|
|
Connected?.Invoke(this, EventArgs.Empty);
|
|
else
|
|
Disconnected?.Invoke(this, EventArgs.Empty);
|
|
}
|
|
}
|
|
}
|
|
|
|
public string? CurrentSong { get; private set; }
|
|
public string? CurrentArtist { get; private set; }
|
|
public string? CurrentAlbum { get; private set; }
|
|
public string? CurrentAlbumArtUrl { get; private set; }
|
|
public string? CurrentStation { get; private set; }
|
|
public bool IsPlaying { get; private set; } = true;
|
|
|
|
public void Connect(string pianobarPath, string? configPath = null, string? eventFilePath = null)
|
|
{
|
|
if (IsConnected)
|
|
throw new InvalidOperationException("Already connected to Pianobar.");
|
|
|
|
_receivedStdOut = false;
|
|
_receivedStdErr = false;
|
|
|
|
// Check if pianobar is already running (check both with and without .exe)
|
|
var existingProcesses = Process.GetProcessesByName("pianobar");
|
|
if (existingProcesses.Length == 0)
|
|
{
|
|
// Also try without extension
|
|
string processName = Path.GetFileNameWithoutExtension(pianobarPath);
|
|
existingProcesses = Process.GetProcessesByName(processName);
|
|
}
|
|
|
|
if (existingProcesses.Length > 0)
|
|
{
|
|
foreach (var proc in existingProcesses)
|
|
proc.Dispose();
|
|
throw new InvalidOperationException("Pianobar is already running. Please close existing instances before connecting.");
|
|
}
|
|
|
|
// Validate the pianobar path exists
|
|
string fullPath = pianobarPath;
|
|
|
|
// If it's just a filename or not found, try to find it in PATH
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
string? foundPath = FindInPath(pianobarPath);
|
|
if (!string.IsNullOrEmpty(foundPath))
|
|
{
|
|
fullPath = foundPath;
|
|
}
|
|
else
|
|
{
|
|
throw new FileNotFoundException(
|
|
$"Pianobar executable not found.\n\n" +
|
|
$"Path checked: {pianobarPath}\n" +
|
|
$"Full path: {Path.GetFullPath(pianobarPath)}\n\n" +
|
|
$"Please verify:\n" +
|
|
$"1. The path in Settings is correct\n" +
|
|
$"2. Pianobar is installed\n" +
|
|
$"3. The file exists at the specified location");
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = fullPath,
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true, // Run in background without terminal window
|
|
WindowStyle = ProcessWindowStyle.Hidden,
|
|
StandardOutputEncoding = Encoding.UTF8,
|
|
StandardErrorEncoding = Encoding.UTF8,
|
|
WorkingDirectory = Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(configPath))
|
|
{
|
|
startInfo.Arguments = $"-c \"{configPath}\"";
|
|
}
|
|
|
|
_process = new Process { StartInfo = startInfo };
|
|
_process.OutputDataReceived += Process_OutputDataReceived;
|
|
_process.ErrorDataReceived += Process_ErrorDataReceived;
|
|
_process.Exited += Process_Exited;
|
|
_process.EnableRaisingEvents = true;
|
|
|
|
bool started = _process.Start();
|
|
|
|
if (!started)
|
|
{
|
|
throw new InvalidOperationException("Process.Start() returned false. The process could not be started.");
|
|
}
|
|
|
|
// Verify the process actually started
|
|
if (_process.HasExited)
|
|
{
|
|
throw new InvalidOperationException($"Process started but immediately exited with code: {_process.ExitCode}");
|
|
}
|
|
|
|
_inputWriter = _process.StandardInput;
|
|
_process.BeginOutputReadLine();
|
|
_process.BeginErrorReadLine();
|
|
|
|
IsConnected = true;
|
|
|
|
// Start monitoring event file if provided
|
|
if (!string.IsNullOrEmpty(eventFilePath))
|
|
{
|
|
_eventMonitor = new PianobarEventMonitor();
|
|
_eventMonitor.EventReceived += OnEventReceived;
|
|
_eventMonitor.StartMonitoring(eventFilePath);
|
|
}
|
|
|
|
RaiseEvent($"Pianobar started successfully (PID: {_process.Id})");
|
|
RaiseEvent($"I/O log: {_ioLogPath}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Cleanup();
|
|
throw new InvalidOperationException($"Failed to start Pianobar: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
private string? FindInPath(string command)
|
|
{
|
|
// Check if the command is available in PATH
|
|
var pathEnv = Environment.GetEnvironmentVariable("PATH");
|
|
if (string.IsNullOrEmpty(pathEnv))
|
|
return null;
|
|
|
|
var paths = pathEnv.Split(Path.PathSeparator);
|
|
var extensions = new[] { ".exe", ".cmd", ".bat", "" };
|
|
|
|
// Remove .exe if already present in command
|
|
string baseCommand = Path.GetFileNameWithoutExtension(command);
|
|
string commandExt = Path.GetExtension(command);
|
|
|
|
foreach (var path in paths)
|
|
{
|
|
try
|
|
{
|
|
if (!Directory.Exists(path))
|
|
continue;
|
|
|
|
foreach (var ext in extensions)
|
|
{
|
|
// If command already has extension, use it as-is
|
|
if (!string.IsNullOrEmpty(commandExt))
|
|
{
|
|
var fullPath = Path.Combine(path, command);
|
|
if (File.Exists(fullPath))
|
|
return fullPath;
|
|
}
|
|
else
|
|
{
|
|
var fullPath = Path.Combine(path, baseCommand + ext);
|
|
if (File.Exists(fullPath))
|
|
return fullPath;
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Skip paths that cause errors
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private bool IsCommandInPath(string command)
|
|
{
|
|
return !string.IsNullOrEmpty(FindInPath(command));
|
|
}
|
|
|
|
public void Disconnect()
|
|
{
|
|
if (!IsConnected)
|
|
return;
|
|
|
|
Cleanup();
|
|
IsConnected = false;
|
|
RaiseEvent("Disconnected from Pianobar (process detached)");
|
|
}
|
|
|
|
public void DisconnectAndKill()
|
|
{
|
|
if (!IsConnected)
|
|
return;
|
|
|
|
try
|
|
{
|
|
if (_process != null && !_process.HasExited)
|
|
{
|
|
int pid = _process.Id;
|
|
_process.Kill();
|
|
_process.WaitForExit(5000); // Wait up to 5 seconds for graceful exit
|
|
RaiseEvent($"Pianobar process killed (PID: {pid})");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
RaiseEvent($"Error killing process: {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
Cleanup();
|
|
IsConnected = false;
|
|
}
|
|
}
|
|
|
|
public void SendCommand(char command)
|
|
{
|
|
if (!IsConnected || _inputWriter == null)
|
|
throw new InvalidOperationException("Not connected to Pianobar.");
|
|
|
|
try
|
|
{
|
|
_inputWriter.WriteLine(command);
|
|
_inputWriter.Flush();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException($"Failed to send command: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
public void SendCommand(string command)
|
|
{
|
|
if (!IsConnected || _inputWriter == null)
|
|
throw new InvalidOperationException("Not connected to Pianobar.");
|
|
|
|
try
|
|
{
|
|
_inputWriter.WriteLine(command);
|
|
_inputWriter.Flush();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
throw new InvalidOperationException($"Failed to send command: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
public void PlayPause()
|
|
{
|
|
SendCommand('p');
|
|
IsPlaying = !IsPlaying;
|
|
RaiseEvent(IsPlaying ? "Resumed" : "Paused");
|
|
}
|
|
|
|
public void Skip()
|
|
{
|
|
SendCommand('n');
|
|
RaiseEvent("Skipped Track");
|
|
}
|
|
|
|
public void ShowStations()
|
|
{
|
|
SendCommand('s');
|
|
}
|
|
|
|
private void Process_OutputDataReceived(object? sender, DataReceivedEventArgs e)
|
|
{
|
|
if (string.IsNullOrEmpty(e.Data))
|
|
return;
|
|
|
|
string line = e.Data;
|
|
if (!_receivedStdOut)
|
|
{
|
|
_receivedStdOut = true;
|
|
RaiseEvent("Received output on STDOUT");
|
|
}
|
|
|
|
LogIoLine("STDOUT", line);
|
|
OutputReceived?.Invoke(this, line);
|
|
|
|
ParseOutput(line);
|
|
}
|
|
|
|
private void Process_ErrorDataReceived(object? sender, DataReceivedEventArgs e)
|
|
{
|
|
if (!string.IsNullOrEmpty(e.Data))
|
|
{
|
|
if (!_receivedStdErr)
|
|
{
|
|
_receivedStdErr = true;
|
|
RaiseEvent("Received output on STDERR");
|
|
}
|
|
|
|
LogIoLine("STDERR", e.Data);
|
|
OutputReceived?.Invoke(this, e.Data);
|
|
ParseOutput(e.Data);
|
|
}
|
|
}
|
|
|
|
private void Process_Exited(object? sender, EventArgs e)
|
|
{
|
|
if (!_receivedStdOut)
|
|
RaiseEvent("No STDOUT data received before process exit");
|
|
|
|
IsConnected = false;
|
|
RaiseEvent("Pianobar process exited");
|
|
}
|
|
|
|
private void LogIoLine(string source, string line)
|
|
{
|
|
try
|
|
{
|
|
var logDir = Path.GetDirectoryName(_ioLogPath);
|
|
if (!string.IsNullOrEmpty(logDir))
|
|
Directory.CreateDirectory(logDir);
|
|
|
|
File.AppendAllText(_ioLogPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{source}] {line}{Environment.NewLine}");
|
|
}
|
|
catch
|
|
{
|
|
// Ignore logging errors
|
|
}
|
|
}
|
|
|
|
private void ParseOutput(string line)
|
|
{
|
|
try
|
|
{
|
|
// Parse song information
|
|
if (line.Contains("|>"))
|
|
{
|
|
// Song is playing
|
|
IsPlaying = true;
|
|
ParseSongLine(line);
|
|
}
|
|
else if (line.Contains("||"))
|
|
{
|
|
// Song is paused
|
|
IsPlaying = false;
|
|
}
|
|
|
|
// Parse station selection
|
|
var stationMatch = Regex.Match(line, @"\|\s*Station\s+""(.+?)""");
|
|
if (stationMatch.Success)
|
|
{
|
|
CurrentStation = stationMatch.Groups[1].Value;
|
|
StationChanged?.Invoke(this, CurrentStation);
|
|
RaiseEvent("Station Selected");
|
|
}
|
|
|
|
// Parse song metadata
|
|
if (line.Contains("Title:"))
|
|
{
|
|
var match = Regex.Match(line, @"Title:\s*(.+)");
|
|
if (match.Success)
|
|
CurrentSong = match.Groups[1].Value.Trim();
|
|
}
|
|
else if (line.Contains("Artist:"))
|
|
{
|
|
var match = Regex.Match(line, @"Artist:\s*(.+)");
|
|
if (match.Success)
|
|
CurrentArtist = match.Groups[1].Value.Trim();
|
|
}
|
|
else if (line.Contains("Album:"))
|
|
{
|
|
var match = Regex.Match(line, @"Album:\s*(.+)");
|
|
if (match.Success)
|
|
CurrentAlbum = match.Groups[1].Value.Trim();
|
|
}
|
|
else if (line.Contains("coverArt"))
|
|
{
|
|
var match = Regex.Match(line, @"coverArt:\s*(.+)");
|
|
if (match.Success)
|
|
{
|
|
CurrentAlbumArtUrl = match.Groups[1].Value.Trim();
|
|
TriggerSongChanged();
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore parsing errors
|
|
}
|
|
}
|
|
|
|
private void ParseSongLine(string line)
|
|
{
|
|
// Parse format: |> "Song Title" by "Artist" on "Album"
|
|
var match = Regex.Match(line, @"\|>\s+""(.+?)""\s+by\s+""(.+?)""\s+on\s+""(.+?)""");
|
|
if (match.Success)
|
|
{
|
|
CurrentSong = match.Groups[1].Value;
|
|
CurrentArtist = match.Groups[2].Value;
|
|
CurrentAlbum = match.Groups[3].Value;
|
|
TriggerSongChanged();
|
|
}
|
|
}
|
|
|
|
private void TriggerSongChanged()
|
|
{
|
|
if (!string.IsNullOrEmpty(CurrentSong) && !string.IsNullOrEmpty(CurrentArtist))
|
|
{
|
|
SongChanged?.Invoke(this, new SongChangedEventArgs
|
|
{
|
|
Title = CurrentSong,
|
|
Artist = CurrentArtist,
|
|
Album = CurrentAlbum,
|
|
AlbumArtUrl = CurrentAlbumArtUrl
|
|
});
|
|
RaiseEvent("Song Changed");
|
|
}
|
|
}
|
|
|
|
private void OnEventReceived(object? sender, PianobarEventData eventData)
|
|
{
|
|
// Update metadata from event file
|
|
if (!string.IsNullOrEmpty(eventData.Title))
|
|
CurrentSong = eventData.Title;
|
|
|
|
if (!string.IsNullOrEmpty(eventData.Artist))
|
|
CurrentArtist = eventData.Artist;
|
|
|
|
if (!string.IsNullOrEmpty(eventData.Album))
|
|
CurrentAlbum = eventData.Album;
|
|
|
|
if (!string.IsNullOrEmpty(eventData.CoverArt))
|
|
CurrentAlbumArtUrl = eventData.CoverArt;
|
|
|
|
if (!string.IsNullOrEmpty(eventData.StationName))
|
|
{
|
|
CurrentStation = eventData.StationName;
|
|
StationChanged?.Invoke(this, CurrentStation);
|
|
}
|
|
|
|
// Raise event based on event type
|
|
if (!string.IsNullOrEmpty(eventData.Event))
|
|
{
|
|
switch (eventData.Event.ToLower())
|
|
{
|
|
case "songstart":
|
|
IsPlaying = true;
|
|
TriggerSongChanged();
|
|
break;
|
|
case "songfinish":
|
|
break;
|
|
case "songlove":
|
|
RaiseEvent("Song Loved");
|
|
break;
|
|
case "songban":
|
|
RaiseEvent("Song Banned");
|
|
break;
|
|
default:
|
|
RaiseEvent($"Event: {eventData.Event}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void RaiseEvent(string eventText)
|
|
{
|
|
EventOccurred?.Invoke(this, new PianobarEventArgs { EventText = eventText });
|
|
}
|
|
|
|
private void Cleanup()
|
|
{
|
|
try
|
|
{
|
|
// Stop event monitoring
|
|
if (_eventMonitor != null)
|
|
{
|
|
_eventMonitor.EventReceived -= OnEventReceived;
|
|
_eventMonitor.Dispose();
|
|
_eventMonitor = null;
|
|
}
|
|
|
|
if (_process != null)
|
|
{
|
|
_process.OutputDataReceived -= Process_OutputDataReceived;
|
|
_process.ErrorDataReceived -= Process_ErrorDataReceived;
|
|
_process.Exited -= Process_Exited;
|
|
|
|
_inputWriter?.Close();
|
|
_inputWriter = null;
|
|
|
|
// Don't kill the process, just detach
|
|
_process.Close();
|
|
_process = null;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Disconnect();
|
|
}
|
|
}
|
|
|
|
public class SongChangedEventArgs : EventArgs
|
|
{
|
|
public string? Title { get; set; }
|
|
public string? Artist { get; set; }
|
|
public string? Album { get; set; }
|
|
public string? AlbumArtUrl { get; set; }
|
|
}
|
|
|
|
public class PianobarEventArgs : EventArgs
|
|
{
|
|
public string EventText { get; set; } = string.Empty;
|
|
}
|
|
}
|