Files
2026-08-09 12:06:46 +03:00

1074 lines
34 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> allUrls = ReadUrls(urlsPath);
if (allUrls.Count == 0)
{
Console.WriteLine("В urls.txt нет ссылок.");
WaitForExit();
return;
}
var allUrlsSet = new HashSet<string>(
allUrls,
StringComparer.OrdinalIgnoreCase
);
HashSet<string> completedUrls =
ReadUrlSet(_completedPath);
// Удаляем из completed_urls.txt старые ссылки,
// которых больше нет в urls.txt.
completedUrls.RemoveWhere(
completedUrl => !allUrlsSet.Contains(completedUrl)
);
SaveCompletedUrls(completedUrls);
List<string> 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<string>();
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<string> 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<string>();
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<string> 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<string> ReadUrlSet(
string path)
{
if (!File.Exists(path))
{
return new HashSet<string>(
StringComparer.OrdinalIgnoreCase
);
}
return new HashSet<string>(
File.ReadAllLines(
path,
Encoding.UTF8
)
.Select(line => line.Trim())
.Where(
line =>
!string.IsNullOrWhiteSpace(line)
),
StringComparer.OrdinalIgnoreCase
);
}
private static void SaveCompletedUrls(
IEnumerable<string> 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<string> failedUrls)
{
try
{
List<string> 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();
}
}
}