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