using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
namespace PainoBar_Helper
{
///
/// Manages the Pianobar process lifecycle and communication
///
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");
private readonly object _stationsLock = new object();
private readonly SortedDictionary _knownStations = new();
private readonly object _stationPromptLock = new object();
private TaskCompletionSource? _stationPromptTcs;
private readonly object _inputLock = new object();
private readonly object _logLock = new object();
private readonly List _ioLogLines = new();
private int? _pendingStationIndex;
public event EventHandler? SongChanged;
public event EventHandler? StationChanged;
public event EventHandler? EventOccurred;
public event EventHandler? Connected;
public event EventHandler? Disconnected;
public event EventHandler? 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;
lock (_stationPromptLock)
{
_pendingStationIndex = null;
_stationPromptTcs = null;
}
ResetIoLog();
// 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
{
WriteInputCommand(command.ToString());
}
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
{
WriteInputCommand(command);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to send command: {ex.Message}", ex);
}
}
private void WriteInputCommand(string command)
{
if (_inputWriter == null)
throw new InvalidOperationException("Standard input is not available.");
lock (_inputLock)
{
// Convert to ASCII bytes with Unix LF to avoid StreamWriter text encoding issues
// that can cause Pianobar's C-runtime to see extra characters
byte[] bytes = System.Text.Encoding.ASCII.GetBytes(command + "\n");
// Write raw bytes directly to base stream, bypassing StreamWriter
_inputWriter.BaseStream.Write(bytes, 0, bytes.Length);
// Flush both base stream and StreamWriter buffers immediately
_inputWriter.BaseStream.Flush();
_inputWriter.Flush();
}
LogIoLine("STDIN", command);
}
public void PlayPause()
{
SendCommand('p');
IsPlaying = !IsPlaying;
RaiseEvent(IsPlaying ? "Resumed" : "Paused");
}
public void Skip()
{
SendCommand('n');
RaiseEvent("Skipped Track");
}
public void ShowStations()
{
SendCommand('s');
}
public async Task SelectStationAsync(int stationIndex)
{
if (stationIndex < 0)
throw new ArgumentOutOfRangeException(nameof(stationIndex));
if (!IsConnected || _inputWriter == null)
throw new InvalidOperationException("Not connected to Pianobar.");
bool isInitialSelection = IsInitialStationSelection(stationIndex);
if (isInitialSelection)
{
// Startup flow: station list already printed by Pianobar, submit index directly.
SendCommand(stationIndex.ToString());
RaiseEvent($"Initial station selection submitted: {stationIndex}");
return;
}
// Station change flow: request station mode, then submit index quickly.
ShowStations();
await Task.Delay(150);
SendCommand(stationIndex.ToString());
RaiseEvent($"Station change submitted: {stationIndex}");
}
private bool IsInitialStationSelection(int stationIndex)
{
if (!string.IsNullOrEmpty(CurrentSong))
return false;
lock (_stationsLock)
{
return _knownStations.ContainsKey(stationIndex) && _knownStations.Count > 0;
}
}
public IReadOnlyList<(int Index, string Name)> GetKnownStationEntries()
{
lock (_stationsLock)
{
return _knownStations
.OrderBy(kvp => kvp.Key)
.Select(kvp => (kvp.Key, kvp.Value))
.ToList();
}
}
public IReadOnlyList GetKnownStations()
{
lock (_stationsLock)
{
return _knownStations.OrderBy(kvp => kvp.Key).Select(kvp => kvp.Value).ToList();
}
}
private void Process_OutputDataReceived(object? sender, DataReceivedEventArgs e)
{
if (string.IsNullOrEmpty(e.Data))
return;
string line = e.Data;
CaptureStationLine(line);
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))
{
CaptureStationLine(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 CaptureStationLine(string line)
{
var clean = StripAnsiCodes(line).Trim();
SignalStationPromptIfWaiting(clean);
if (clean.Contains("Get stations... Ok.", StringComparison.OrdinalIgnoreCase))
{
lock (_stationsLock)
{
_knownStations.Clear();
}
return;
}
var match = Regex.Match(clean, @"^\s*(\d+)\)\s*(?:[qQ]\s+)?(.+?)\s*$");
if (!match.Success)
return;
int index = int.Parse(match.Groups[1].Value);
string stationName = match.Groups[2].Value.Trim();
lock (_stationsLock)
{
_knownStations[index] = stationName;
}
}
private void SignalStationPromptIfWaiting(string cleanLine)
{
if (string.IsNullOrEmpty(cleanLine))
return;
bool isStationPrompt =
cleanLine.Contains("Select station:", StringComparison.OrdinalIgnoreCase) ||
cleanLine.StartsWith("[?]", StringComparison.Ordinal);
if (!isStationPrompt)
return;
int? pendingIndex = null;
TaskCompletionSource? tcs = null;
lock (_stationPromptLock)
{
pendingIndex = _pendingStationIndex;
_pendingStationIndex = null;
tcs = _stationPromptTcs;
_stationPromptTcs = null;
}
if (pendingIndex.HasValue)
{
SendCommand(pendingIndex.Value.ToString());
RaiseEvent($"Station change submitted: {pendingIndex.Value}");
}
tcs?.TrySetResult(true);
}
private void ResetIoLog()
{
try
{
var logDir = Path.GetDirectoryName(_ioLogPath);
if (!string.IsNullOrEmpty(logDir))
Directory.CreateDirectory(logDir);
lock (_logLock)
{
_ioLogLines.Clear();
File.WriteAllText(_ioLogPath, string.Empty);
}
}
catch
{
// Ignore logging errors
}
}
private void LogIoLine(string source, string line)
{
try
{
var logDir = Path.GetDirectoryName(_ioLogPath);
if (!string.IsNullOrEmpty(logDir))
Directory.CreateDirectory(logDir);
string entry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{source}] {line}";
lock (_logLock)
{
_ioLogLines.Add(entry);
File.WriteAllText(_ioLogPath, string.Join(Environment.NewLine, _ioLogLines) + 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();
}
private static string StripAnsiCodes(string value)
{
return Regex.Replace(value, @"\x1B\[[0-9;]*[A-Za-z]", string.Empty);
}
private void Process_Exited(object? sender, EventArgs e)
{
if (!_receivedStdOut)
RaiseEvent("No STDOUT data received before process exit");
IsConnected = false;
RaiseEvent("Pianobar process exited");
}
}
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;
}
}