using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Threading; namespace QobuzWatchdog { internal class Program { private static string _ripPath; private static string _projectRoot; private static string _downloadsPath; // Количество попыток запуска rip.exe для одной ссылки за один проход. private const int MaxRunsPerUrl = 10; // Максимальное количество полных проходов по незавершённым ссылкам. private const int MaxRounds = 20; // Через сколько секунд без вывода считать процесс зависшим. private const int IdleLimitSeconds = 60; // Задержка перед повторным запуском. private const int RestartDelaySeconds = 3; private static DateTime _lastOutputTime; private static readonly object OutputLock = new object(); private static string _completedPath; private static string _failedPath; static void Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; string exeFolder = AppDomain.CurrentDomain.BaseDirectory; _projectRoot = FindProjectRoot(exeFolder); _downloadsPath = Path.Combine( _projectRoot, "downloads" ); string dataFolder = Path.Combine( _projectRoot, "data" ); Directory.CreateDirectory(_downloadsPath); Directory.CreateDirectory(dataFolder); string urlsPath = Path.Combine( dataFolder, "urls.txt" ); _completedPath = Path.Combine( dataFolder, "completed_urls.txt" ); _failedPath = Path.Combine( dataFolder, "failed_urls.txt" ); _ripPath = FindRipExecutable(_projectRoot); if (string.IsNullOrWhiteSpace(_ripPath)) { Console.WriteLine("Не найден Streamrip (rip.exe)."); Console.WriteLine(); Console.WriteLine("Проверены:"); Console.WriteLine( Path.Combine(_projectRoot, "tools", "rip.exe") ); Console.WriteLine("и системная переменная PATH."); WaitForExit(); return; } Console.WriteLine("Streamrip:"); Console.WriteLine(_ripPath); Console.WriteLine(); Console.WriteLine("Папка загрузок:"); Console.WriteLine(_downloadsPath); Console.WriteLine(); if (!ConfigureStreamripDownloadFolder(_downloadsPath)) { WaitForExit(); return; } if (!File.Exists(urlsPath)) { File.WriteAllText( urlsPath, "# Каждая ссылка должна находиться на отдельной строке." + Environment.NewLine, Encoding.UTF8 ); Console.WriteLine("Создан файл:"); Console.WriteLine(urlsPath); Console.WriteLine(); Console.WriteLine( "Добавь в него ссылки и снова запусти программу." ); WaitForExit(); return; } List allUrls = ReadUrls(urlsPath); if (allUrls.Count == 0) { Console.WriteLine("В urls.txt нет ссылок."); WaitForExit(); return; } var allUrlsSet = new HashSet( allUrls, StringComparer.OrdinalIgnoreCase ); HashSet completedUrls = ReadUrlSet(_completedPath); // Удаляем из completed_urls.txt старые ссылки, // которых больше нет в urls.txt. completedUrls.RemoveWhere( completedUrl => !allUrlsSet.Contains(completedUrl) ); SaveCompletedUrls(completedUrls); List pendingUrls = allUrls .Where(url => !completedUrls.Contains(url)) .ToList(); Console.WriteLine("======================================="); Console.WriteLine(" НАЧАЛЬНОЕ СОСТОЯНИЕ"); Console.WriteLine("======================================="); Console.WriteLine("Всего ссылок: " + allUrls.Count); Console.WriteLine("Уже завершено: " + completedUrls.Count); Console.WriteLine("Осталось скачать: " + pendingUrls.Count); Console.WriteLine("======================================="); if (pendingUrls.Count == 0) { if (File.Exists(_failedPath)) { File.Delete(_failedPath); } Console.WriteLine(); Console.WriteLine( "Все ссылки из urls.txt уже обработаны." ); WaitForExit(); return; } int round = 1; while ( pendingUrls.Count > 0 && round <= MaxRounds) { int failedInCurrentRound = 0; Console.WriteLine(); Console.WriteLine( "##################################################" ); Console.WriteLine( "ПРОХОД {0} ИЗ {1}", round, MaxRounds ); Console.WriteLine( "Ссылок в этом проходе: " + pendingUrls.Count ); Console.WriteLine( "Уже завершено: " + completedUrls.Count ); Console.WriteLine( "Всего ссылок: " + allUrls.Count ); Console.WriteLine( "##################################################" ); var nextRoundUrls = new List(); for ( int index = 0; index < pendingUrls.Count; index++) { string url = pendingUrls[index]; int remainingBeforeDownload = allUrls.Count - completedUrls.Count; ShowProgress( allUrls.Count, completedUrls.Count, remainingBeforeDownload, failedInCurrentRound, index + 1, pendingUrls.Count, round ); Console.WriteLine(); Console.WriteLine( "==================================================" ); Console.WriteLine( "Ссылка {0} из {1}", index + 1, pendingUrls.Count ); Console.WriteLine(url); Console.WriteLine( "==================================================" ); bool success = DownloadWithRetries( url, index + 1, pendingUrls.Count, round ); if (success) { completedUrls.Add(url); SaveCompletedUrls(completedUrls); Console.WriteLine(); Console.WriteLine( "Ссылка успешно завершена." ); } else { failedInCurrentRound++; nextRoundUrls.Add(url); Console.WriteLine(); Console.WriteLine( "Ссылка не завершена." ); Console.WriteLine( "Она будет повторена в следующем проходе." ); } int remainingAfterDownload = allUrls.Count - completedUrls.Count; ShowProgress( allUrls.Count, completedUrls.Count, remainingAfterDownload, failedInCurrentRound, index + 1, pendingUrls.Count, round ); // Сохраняем незавершённые ссылки после // каждой обработки. SaveFailedUrls( nextRoundUrls.Concat( pendingUrls.Skip(index + 1) ) ); } pendingUrls = nextRoundUrls .Distinct( StringComparer.OrdinalIgnoreCase ) .ToList(); SaveFailedUrls(pendingUrls); Console.WriteLine(); Console.WriteLine( "=======================================" ); Console.WriteLine( " РЕЗУЛЬТАТ ПРОХОДА" ); Console.WriteLine( "=======================================" ); Console.WriteLine( "Номер прохода: " + round ); Console.WriteLine( "Всего ссылок: " + allUrls.Count ); Console.WriteLine( "Успешно завершено: " + completedUrls.Count ); Console.WriteLine( "Ошибок в проходе: " + failedInCurrentRound ); Console.WriteLine( "Осталось скачать: " + pendingUrls.Count ); double roundPercent = allUrls.Count == 0 ? 0 : completedUrls.Count * 100.0 / allUrls.Count; Console.WriteLine( "Общий прогресс: {0:0.0}%", roundPercent ); Console.WriteLine( "=======================================" ); if ( pendingUrls.Count > 0 && round < MaxRounds) { Console.WriteLine(); Console.WriteLine( "Через {0} секунд начнётся повтор " + "незавершённых ссылок.", RestartDelaySeconds ); Thread.Sleep( RestartDelaySeconds * 1000 ); } round++; } ShowFinalResult( allUrls.Count, completedUrls.Count, pendingUrls ); WaitForExit(); } private static bool DownloadWithRetries( string url, int urlNumber, int totalUrlsInRound, int round) { for ( int attempt = 1; attempt <= MaxRunsPerUrl; attempt++) { Console.WriteLine(); Console.WriteLine( "Проход {0}/{1} | попытка {2}/{3} | " + "ссылка {4}/{5}", round, MaxRounds, attempt, MaxRunsPerUrl, urlNumber, totalUrlsInRound ); lock (OutputLock) { _lastOutputTime = DateTime.Now; } bool killedByWatchdog = false; using (var process = new Process()) { process.StartInfo.FileName = _ripPath; process.StartInfo.Arguments = "--no-progress --verbose url \"" + url + "\""; process.StartInfo.UseShellExecute = false; process.StartInfo.RedirectStandardOutput = true; process.StartInfo.RedirectStandardError = true; process.StartInfo.CreateNoWindow = false; process.OutputDataReceived += OnOutput; process.ErrorDataReceived += OnOutput; try { process.Start(); } catch (Exception ex) { Console.WriteLine( "Не удалось запустить rip.exe:" ); Console.WriteLine(ex.Message); return false; } process.BeginOutputReadLine(); process.BeginErrorReadLine(); while (!process.HasExited) { Thread.Sleep(1000); double idleSeconds; lock (OutputLock) { idleSeconds = ( DateTime.Now - _lastOutputTime ).TotalSeconds; } Console.Title = string.Format( "Проход {0}/{1} | ссылка {2}/{3} | " + "попытка {4}/{5} | без вывода {6:0} сек.", round, MaxRounds, urlNumber, totalUrlsInRound, attempt, MaxRunsPerUrl, idleSeconds ); if ( idleSeconds >= IdleLimitSeconds) { Console.WriteLine(); Console.WriteLine( "Нет вывода {0} секунд.", IdleLimitSeconds ); Console.WriteLine( "Процесс будет перезапущен." ); KillProcessTree(process.Id); killedByWatchdog = true; Thread.Sleep( RestartDelaySeconds * 1000 ); break; } } if (!killedByWatchdog) { try { process.WaitForExit(); if (process.ExitCode == 0) { Console.WriteLine(); Console.WriteLine( "rip.exe завершился успешно." ); return true; } Console.WriteLine(); Console.WriteLine( "rip.exe завершился с кодом: " + process.ExitCode ); } catch (Exception ex) { Console.WriteLine(); Console.WriteLine( "Ошибка при проверке процесса:" ); Console.WriteLine(ex.Message); } } } if (attempt < MaxRunsPerUrl) { Console.WriteLine(); Console.WriteLine( "Повтор через {0} секунд...", RestartDelaySeconds ); Thread.Sleep( RestartDelaySeconds * 1000 ); } } return false; } private static void ShowProgress( int totalUrls, int completedCount, int remainingCount, int failedInCurrentRound, int currentUrl, int urlsInCurrentRound, int currentRound) { double percent = totalUrls == 0 ? 0 : completedCount * 100.0 / totalUrls; Console.WriteLine(); Console.WriteLine( "**************** СТАТИСТИКА ****************" ); Console.WriteLine( "Всего ссылок: " + totalUrls ); Console.WriteLine( "Успешно завершено: " + completedCount ); Console.WriteLine( "Осталось скачать: " + remainingCount ); Console.WriteLine( "Ошибок в текущем проходе: " + failedInCurrentRound ); Console.WriteLine( "Текущая ссылка: {0} из {1}", currentUrl, urlsInCurrentRound ); Console.WriteLine( "Текущий проход: {0} из {1}", currentRound, MaxRounds ); Console.WriteLine( "Общий прогресс: {0:0.0}%", percent ); Console.WriteLine( "********************************************" ); Console.Title = string.Format( "Готово {0}/{1} ({2:0.0}%) | осталось {3}", completedCount, totalUrls, percent, remainingCount ); } private static void ShowFinalResult( int totalUrls, int completedCount, List pendingUrls) { Console.WriteLine(); Console.WriteLine( "=======================================" ); Console.WriteLine( " ИТОГОВЫЙ РЕЗУЛЬТАТ" ); Console.WriteLine( "=======================================" ); Console.WriteLine( "Всего ссылок: " + totalUrls ); Console.WriteLine( "Успешно скачано: " + completedCount ); Console.WriteLine( "Осталось скачать: " + pendingUrls.Count ); double finalPercent = totalUrls == 0 ? 0 : completedCount * 100.0 / totalUrls; Console.WriteLine( "Выполнено: {0:0.0}%", finalPercent ); if (pendingUrls.Count == 0) { if (File.Exists(_failedPath)) { File.Delete(_failedPath); } Console.WriteLine(); Console.WriteLine( "Все ссылки успешно обработаны." ); } else { SaveFailedUrls(pendingUrls); Console.WriteLine(); Console.WriteLine( "Незавершённые ссылки:" ); foreach (string failedUrl in pendingUrls) { Console.WriteLine("- " + failedUrl); } Console.WriteLine(); Console.WriteLine( "Незавершённые ссылки сохранены в:" ); Console.WriteLine(_failedPath); Console.WriteLine(); Console.WriteLine( "При следующем запуске программа " + "снова попробует их скачать." ); } Console.WriteLine( "=======================================" ); Console.Title = string.Format( "Завершено {0}/{1} | осталось {2}", completedCount, totalUrls, pendingUrls.Count ); } private static string FindProjectRoot( string startFolder) { DirectoryInfo current = new DirectoryInfo(startFolder); for (int i = 0; i < 8 && current != null; i++) { string downloads = Path.Combine(current.FullName, "downloads"); string data = Path.Combine(current.FullName, "data"); string tools = Path.Combine(current.FullName, "tools"); if ( Directory.Exists(downloads) && Directory.Exists(data) && Directory.Exists(tools)) { return current.FullName; } current = current.Parent; } return startFolder; } private static string FindRipExecutable( string projectRoot) { string localRip = Path.Combine( projectRoot, "tools", "rip.exe" ); if (File.Exists(localRip)) { return localRip; } string pathValue = Environment.GetEnvironmentVariable("PATH"); if (string.IsNullOrWhiteSpace(pathValue)) { return null; } foreach ( string folder in pathValue.Split( new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)) { try { string candidate = Path.Combine( folder.Trim(), "rip.exe" ); if (File.Exists(candidate)) { return candidate; } } catch { } } return null; } private static bool ConfigureStreamripDownloadFolder( string downloadsFolder) { string appData = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData ); string configPath = Path.Combine( appData, "streamrip", "config.toml" ); if (!File.Exists(configPath)) { Console.WriteLine( "Не найден конфигурационный файл Streamrip:" ); Console.WriteLine(configPath); Console.WriteLine(); Console.WriteLine( "Сначала запустите команду rip хотя бы один раз " + "и настройте доступ к Qobuz." ); return false; } try { string[] lines = File.ReadAllLines( configPath, Encoding.UTF8 ); string escapedPath = downloadsFolder.Replace("\\", "\\\\"); bool inDownloadsSection = false; bool folderUpdated = false; var result = new List(); foreach (string originalLine in lines) { string trimmed = originalLine.Trim(); if ( trimmed.StartsWith("[") && trimmed.EndsWith("]")) { if ( inDownloadsSection && !folderUpdated) { result.Add( "folder = \"" + escapedPath + "\"" ); folderUpdated = true; } inDownloadsSection = string.Equals( trimmed, "[downloads]", StringComparison.OrdinalIgnoreCase ); } if ( inDownloadsSection && trimmed.StartsWith( "folder", StringComparison.OrdinalIgnoreCase ) && trimmed.Contains("=")) { result.Add( "folder = \"" + escapedPath + "\"" ); folderUpdated = true; } else { result.Add(originalLine); } } if (inDownloadsSection && !folderUpdated) { result.Add( "folder = \"" + escapedPath + "\"" ); folderUpdated = true; } if (!folderUpdated) { Console.WriteLine( "В config.toml не найден раздел [downloads]." ); Console.WriteLine( "Проверьте конфигурацию Streamrip." ); return false; } File.WriteAllLines( configPath, result, new UTF8Encoding(false) ); Console.WriteLine( "Папка загрузок Streamrip настроена:" ); Console.WriteLine(downloadsFolder); Console.WriteLine(); return true; } catch (Exception ex) { Console.WriteLine( "Не удалось обновить config.toml Streamrip:" ); Console.WriteLine(ex.Message); return false; } } private static List ReadUrls( string path) { return File.ReadAllLines( path, Encoding.UTF8 ) .Select(line => line.Trim()) .Where( line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith("#") ) .Distinct( StringComparer.OrdinalIgnoreCase ) .ToList(); } private static HashSet ReadUrlSet( string path) { if (!File.Exists(path)) { return new HashSet( StringComparer.OrdinalIgnoreCase ); } return new HashSet( File.ReadAllLines( path, Encoding.UTF8 ) .Select(line => line.Trim()) .Where( line => !string.IsNullOrWhiteSpace(line) ), StringComparer.OrdinalIgnoreCase ); } private static void SaveCompletedUrls( IEnumerable completedUrls) { try { File.WriteAllLines( _completedPath, completedUrls .Distinct( StringComparer.OrdinalIgnoreCase ) .OrderBy(url => url), Encoding.UTF8 ); } catch (Exception ex) { Console.WriteLine(); Console.WriteLine( "Не удалось сохранить completed_urls.txt:" ); Console.WriteLine(ex.Message); } } private static void SaveFailedUrls( IEnumerable failedUrls) { try { List urls = failedUrls .Where( url => !string.IsNullOrWhiteSpace(url) ) .Distinct( StringComparer.OrdinalIgnoreCase ) .ToList(); if (urls.Count == 0) { if (File.Exists(_failedPath)) { File.Delete(_failedPath); } return; } File.WriteAllLines( _failedPath, urls, Encoding.UTF8 ); } catch (Exception ex) { Console.WriteLine(); Console.WriteLine( "Не удалось сохранить failed_urls.txt:" ); Console.WriteLine(ex.Message); } } private static void OnOutput( object sender, DataReceivedEventArgs e) { if (string.IsNullOrWhiteSpace(e.Data)) { return; } lock (OutputLock) { _lastOutputTime = DateTime.Now; } Console.WriteLine(e.Data); } private static void KillProcessTree( int pid) { try { var startInfo = new ProcessStartInfo { FileName = "taskkill", Arguments = "/PID " + pid + " /T /F", CreateNoWindow = true, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true }; using ( Process taskkill = Process.Start(startInfo)) { if (taskkill != null) { taskkill.WaitForExit(); } } } catch (Exception ex) { Console.WriteLine(); Console.WriteLine( "Не удалось завершить процесс:" ); Console.WriteLine(ex.Message); } } private static void WaitForExit() { Console.WriteLine(); Console.WriteLine( "Нажми Enter для выхода." ); Console.ReadLine(); } } }