From c3b3ce9901d395c07d160a8a389dad9104c220b7 Mon Sep 17 00:00:00 2001 From: oleg Date: Sun, 9 Aug 2026 12:06:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=BB=D0=B8=D0=B7=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 29 + LICENSE | 21 + QobuzWatchdog.sln | 18 + QobuzWatchdog/App.config | 6 + QobuzWatchdog/Program.cs | 1074 ++++++++++++++++++++++ QobuzWatchdog/Properties/AssemblyInfo.cs | 33 + QobuzWatchdog/QobuzWatchdog.csproj | 53 ++ README.md | 328 +++++++ data/urls.txt | 2 + downloads/.gitkeep | 0 scripts/Clear-StreamripDb.bat | 40 + scripts/Convert-FlacToCD.ps1 | 510 ++++++++++ scripts/GroupAlbumsByArtist.ps1 | 93 ++ tools/.gitkeep | 0 14 files changed, 2207 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 QobuzWatchdog.sln create mode 100644 QobuzWatchdog/App.config create mode 100644 QobuzWatchdog/Program.cs create mode 100644 QobuzWatchdog/Properties/AssemblyInfo.cs create mode 100644 QobuzWatchdog/QobuzWatchdog.csproj create mode 100644 README.md create mode 100644 data/urls.txt create mode 100644 downloads/.gitkeep create mode 100644 scripts/Clear-StreamripDb.bat create mode 100644 scripts/Convert-FlacToCD.ps1 create mode 100644 scripts/GroupAlbumsByArtist.ps1 create mode 100644 tools/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..516c49b --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Visual Studio / .NET +.vs/ +**/bin/ +**/obj/ +*.user +*.suo +*.pdb + +# Загруженная музыка +downloads/* +!downloads/.gitkeep + +# Локальные бинарные зависимости +tools/* +!tools/.gitkeep + +# Состояние QobuzWatchdog +data/completed_urls.txt +data/failed_urls.txt + +# Streamrip / временные файлы +*.db +*.tmp +*.part +*.log + +# Windows +Thumbs.db +Desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/QobuzWatchdog.sln b/QobuzWatchdog.sln new file mode 100644 index 0000000..35ee672 --- /dev/null +++ b/QobuzWatchdog.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QobuzWatchdog", "QobuzWatchdog\QobuzWatchdog.csproj", "{6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/QobuzWatchdog/App.config b/QobuzWatchdog/App.config new file mode 100644 index 0000000..56efbc7 --- /dev/null +++ b/QobuzWatchdog/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/QobuzWatchdog/Program.cs b/QobuzWatchdog/Program.cs new file mode 100644 index 0000000..e04655c --- /dev/null +++ b/QobuzWatchdog/Program.cs @@ -0,0 +1,1074 @@ +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(); + } + } +} \ No newline at end of file diff --git a/QobuzWatchdog/Properties/AssemblyInfo.cs b/QobuzWatchdog/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..c797145 --- /dev/null +++ b/QobuzWatchdog/Properties/AssemblyInfo.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// Общие сведения об этой сборке предоставляются следующим набором +// набора атрибутов. Измените значения этих атрибутов для изменения сведений, +// связанные с этой сборкой. +[assembly: AssemblyTitle("QobuzWatchdog")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("QobuzWatchdog")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми +// для компонентов COM. Если необходимо обратиться к типу в этой сборке через +// из модели COM задайте для атрибута ComVisible этого типа значение true. +[assembly: ComVisible(false)] + +// Следующий GUID представляет идентификатор typelib, если этот проект доступен из модели COM +[assembly: Guid("6e3089c9-7e68-4f3f-b2bc-5da23bdded6b")] + +// Сведения о версии сборки состоят из указанных ниже четырех значений: +// +// Основной номер версии +// Дополнительный номер версии +// Номер сборки +// Номер редакции +// +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/QobuzWatchdog/QobuzWatchdog.csproj b/QobuzWatchdog/QobuzWatchdog.csproj new file mode 100644 index 0000000..3360a64 --- /dev/null +++ b/QobuzWatchdog/QobuzWatchdog.csproj @@ -0,0 +1,53 @@ + + + + + Debug + AnyCPU + {6E3089C9-7E68-4F3F-B2BC-5DA23BDDED6B} + Exe + QobuzWatchdog + QobuzWatchdog + v4.7.2 + 512 + true + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c70e0ef --- /dev/null +++ b/README.md @@ -0,0 +1,328 @@ +# Qobuz Streamrip Tools + +Набор утилит для Windows для автоматизации работы со **Streamrip** и загрузками из **Qobuz**. + +Проект объединяет: + +- программу `QobuzWatchdog`, которая последовательно обрабатывает список ссылок через Streamrip и перезапускает зависший процесс; +- очистку базы истории Streamrip; +- приведение скачанных FLAC-файлов к CD-качеству; +- группировку нескольких альбомов одного исполнителя в общую папку. + +> Используйте проект только для контента, который вы имеете право скачивать и обрабатывать, и в соответствии с условиями используемых сервисов. + +## Структура проекта + +```text +qobuz-streamrip-tools/ +├── QobuzWatchdog/ +│ ├── Properties/ +│ ├── App.config +│ ├── Program.cs +│ └── QobuzWatchdog.csproj +│ +├── scripts/ +│ ├── Clear-StreamripDb.bat +│ ├── Convert-FlacToCD.ps1 +│ └── GroupAlbumsByArtist.ps1 +│ +├── tools/ +│ └── .gitkeep +│ +├── downloads/ +│ └── .gitkeep +│ +├── data/ +│ └── urls.txt +│ +├── QobuzWatchdog.sln +├── README.md +├── LICENSE +└── .gitignore +``` + +## Общий рабочий процесс + +```text +data/urls.txt + ↓ +QobuzWatchdog + ↓ +Streamrip + ↓ +downloads/ + ↓ +Convert-FlacToCD.ps1 + ↓ +GroupAlbumsByArtist.ps1 +``` + +Все загрузки Streamrip направляются в папку `downloads` в корне проекта. + +`QobuzWatchdog` при каждом запуске проверяет `%APPDATA%\streamrip\config.toml` и устанавливает параметр `downloads.folder` в путь к локальной папке `downloads`. + +## Скачать все зависимости: + +[Скачать (.zip)](https://cloud.kolbox.duckdns.org/seafhttp/f/f918d0ca3aac4b4195de/?op=view) + +## Основная зависимость — Streamrip + +Streamrip: + +https://github.com/nathom/streamrip + +Для актуальной версии Streamrip необходим Python 3.10 или новее и `pip`. + +Установка: + +```powershell +pip3 install streamrip --upgrade +``` + +Проверка: + +```powershell +rip +``` + +Перед первым использованием необходимо настроить Streamrip и авторизацию Qobuz. + +Для Qobuz требуется подходящая подписка, позволяющая получать соответствующее качество аудио. + +### Где ищется Streamrip + +`QobuzWatchdog` ищет `rip.exe` в следующем порядке: + +1. `tools\rip.exe`; +2. системная переменная `PATH`. + +Если Streamrip установлен через `pip`, обычно достаточно, чтобы каталог Python `Scripts` был добавлен в `PATH`. + +## FFmpeg + +Для `Convert-FlacToCD.ps1` необходимы: + +```text +ffmpeg.exe +ffprobe.exe +``` + +Сборка FFmpeg для Windows: + +https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-full.7z + +После распаковки файлы находятся в каталоге `bin`. + +Их можно положить непосредственно в: + +```text +tools/ +``` + +Скрипт ищет `ffmpeg.exe` и `ffprobe.exe`: + +1. в `tools`; +2. затем в системном `PATH`. + +## Зависимости по компонентам + +| Компонент | Streamrip | FFmpeg | FFprobe | PowerShell | .NET Framework | +| ------------------------- |:---------:|:------:|:-------:|:----------:|:--------------:| +| `QobuzWatchdog` | ✓ | — | — | — | 4.7.2 | +| `Clear-StreamripDb.bat` | ✓ | — | — | — | — | +| `Convert-FlacToCD.ps1` | — | ✓ | ✓ | 5.1+ | — | +| `GroupAlbumsByArtist.ps1` | — | — | — | 5.1+ | — | + +## QobuzWatchdog + +`QobuzWatchdog` читает ссылки из: + +```text +data/urls.txt +``` + +Каждая ссылка должна находиться на отдельной строке. + +Пример: + +```text +https://www.qobuz.com/... +https://open.qobuz.com/... +``` + +Строки, начинающиеся с `#`, игнорируются. + +Программа хранит состояние в: + +```text +data/completed_urls.txt +data/failed_urls.txt +``` + +Эти файлы создаются автоматически и не добавляются в Git. + +### Что делает Watchdog + +Для каждой ссылки программа: + +1. запускает `rip --no-progress --verbose url ""`; +2. отслеживает вывод процесса; +3. если Streamrip долго не выводит ничего, завершает дерево процесса; +4. повторяет попытку; +5. сохраняет успешно обработанные ссылки; +6. повторяет незавершённые ссылки в следующих проходах. + +Текущие параметры находятся в `Program.cs`: + +```csharp +private const int MaxRunsPerUrl = 10; +private const int MaxRounds = 20; +private const int IdleLimitSeconds = 60; +private const int RestartDelaySeconds = 3; +``` + +### Сборка QobuzWatchdog + +Проект рассчитан на: + +```text +.NET Framework 4.7.2 +``` + +Откройте: + +```text +QobuzWatchdog.sln +``` + +в Visual Studio и соберите конфигурацию `Release`. + +Каталоги `.vs`, `bin` и `obj` исключены из Git. + +## Очистка базы Streamrip + +Запустите: + +```text +scripts\Clear-StreamripDb.bat +``` + +Скрипт удаляет: + +```text +%APPDATA%\streamrip\downloads.db +%APPDATA%\streamrip\failed_downloads.db +``` + +Это сбрасывает историю скачанных и неудачно обработанных элементов Streamrip. + +## Конвертация FLAC в CD-качество + +Запустите: + +```powershell +.\scripts\Convert-FlacToCD.ps1 +``` + +Скрипт автоматически обрабатывает: + +```text +downloads/ +``` + +Целевые параметры: + +```text +FLAC +16 бит +44,1 кГц +2 канала +``` + +Скрипт: + +- рекурсивно ищет FLAC; +- пропускает уже подходящие файлы; +- сохраняет метаданные и встроенные изображения; +- создаёт временный файл; +- проверяет результат через FFprobe; +- только после успешной проверки заменяет исходник; +- при ошибке пытается восстановить исходный файл. + +## Группировка альбомов по исполнителям + +Запустите: + +```powershell +.\scripts\GroupAlbumsByArtist.ps1 +``` + +Скрипт работает с папкой `downloads`. + +Он ожидает названия альбомов в формате: + +```text +Исполнитель - Альбом +``` + +Если у одного исполнителя найдено два или больше альбомов: + +```text +downloads/ +├── Исполнитель/ +│ ├── Исполнитель - Альбом 1/ +│ └── Исполнитель - Альбом 2/ +``` + +Если альбом только один, его папка остаётся на первом уровне. + +## Папка downloads + +Вся музыка находится в: + +```text +downloads/ +``` + +Содержимое этой папки исключено из Git: + +```gitignore +downloads/* +!downloads/.gitkeep +``` + +## Папка tools + +В `tools` можно положить локальные зависимости: + +```text +rip.exe +ffmpeg.exe +ffprobe.exe +``` + +Бинарные файлы из `tools` не добавляются в Git. + +## PowerShell + +Скрипты рассчитаны на Windows PowerShell 5.1 или новее. + +Если Windows блокирует запуск локальных `.ps1`, проверьте текущую политику: + +```powershell +Get-ExecutionPolicy +``` + +При необходимости для текущего пользователя можно использовать: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser RemoteSigned +``` + +Меняйте политику выполнения только если понимаете последствия этой настройки. + +## Лицензия + +Исходный код этого репозитория распространяется по лицензии MIT. + +Streamrip и FFmpeg являются отдельными проектами и распространяются на условиях собственных лицензий. diff --git a/data/urls.txt b/data/urls.txt new file mode 100644 index 0000000..aac8950 --- /dev/null +++ b/data/urls.txt @@ -0,0 +1,2 @@ +# Каждая ссылка Qobuz должна находиться на отдельной строке. +# Строки, начинающиеся с #, игнорируются. diff --git a/downloads/.gitkeep b/downloads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/Clear-StreamripDb.bat b/scripts/Clear-StreamripDb.bat new file mode 100644 index 0000000..2d5bce8 --- /dev/null +++ b/scripts/Clear-StreamripDb.bat @@ -0,0 +1,40 @@ +@echo off +chcp 65001 >nul +setlocal + +set "STREAMRIP_DIR=%APPDATA%\streamrip" +set "DOWNLOADS_DB=%STREAMRIP_DIR%\downloads.db" +set "FAILED_DB=%STREAMRIP_DIR%\failed_downloads.db" + +echo. +echo ============================================ +echo ОЧИСТКА БАЗЫ STREAMRIP +echo ============================================ +echo. + +if exist "%DOWNLOADS_DB%" ( + del /q "%DOWNLOADS_DB%" >nul 2>&1 + if exist "%DOWNLOADS_DB%" ( + echo ОШИБКА: не удалось удалить downloads.db + ) else ( + echo downloads.db удалён. + ) +) else ( + echo downloads.db не найден — пропущено. +) + +if exist "%FAILED_DB%" ( + del /q "%FAILED_DB%" >nul 2>&1 + if exist "%FAILED_DB%" ( + echo ОШИБКА: не удалось удалить failed_downloads.db + ) else ( + echo failed_downloads.db удалён. + ) +) else ( + echo failed_downloads.db не найден — пропущено. +) + +echo. +echo Готово. +echo. +pause diff --git a/scripts/Convert-FlacToCD.ps1 b/scripts/Convert-FlacToCD.ps1 new file mode 100644 index 0000000..6cbdf63 --- /dev/null +++ b/scripts/Convert-FlacToCD.ps1 @@ -0,0 +1,510 @@ +#requires -Version 5.1 + +<# +Конвертация всех FLAC-файлов в CD-качество: +16 бит / 44,1 кГц / стерео. + +Скрипт: +- рекурсивно ищет все *.flac; +- пропускает файлы, которые уже 16 бит / 44100 Гц / стерео; +- сохраняет теги и встроенную обложку; +- проверяет результат через ffprobe; +- после успешной проверки заменяет исходный файл; +- показывает прогресс; +- в конце выводит список файлов с ошибками. +#> + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$ToolsFolder = Join-Path $ProjectRoot "tools" +$RootFolder = Join-Path $ProjectRoot "downloads" + +$ErrorActionPreference = "Stop" + +function Find-Tool { + param( + [Parameter(Mandatory = $true)] + [string]$CommandName, + + [Parameter(Mandatory = $true)] + [string[]]$LocalNames + ) + + foreach ($LocalName in $LocalNames) { + $LocalPath = Join-Path $ToolsFolder $LocalName + if (Test-Path -LiteralPath $LocalPath -PathType Leaf) { + return (Resolve-Path -LiteralPath $LocalPath).Path + } + } + + $Command = Get-Command $CommandName -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } + + return $null +} + +$FfmpegPath = Find-Tool -CommandName "ffmpeg.exe" -LocalNames @("ffmpeg.exe") +$FfprobePath = Find-Tool -CommandName "ffprobe.exe" -LocalNames @("ffprobe.exe") + + +try { + $Host.UI.RawUI.WindowTitle = "FLAC -> CD quality" +} +catch { +} + +function Wait-BeforeExit { + Write-Host "" + [void](Read-Host "Нажмите Enter для выхода") +} + +function Get-FlacAudioInfo { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $probeOutput = & $FfprobePath ` + -v error ` + -select_streams a:0 ` + -show_entries "stream=codec_name,sample_rate,channels,bits_per_sample,bits_per_raw_sample,sample_fmt" ` + -of json ` + -- ` + $Path 2>&1 + + if ($LASTEXITCODE -ne 0) { + throw "ffprobe не смог прочитать файл:`n$probeOutput" + } + + $probeData = $probeOutput | ConvertFrom-Json + + if ( + -not $probeData.streams -or + $probeData.streams.Count -lt 1 + ) { + throw "Аудиопоток не найден." + } + + $stream = $probeData.streams[0] + $bitDepth = 0 + + if ( + $null -ne $stream.bits_per_raw_sample -and + [string]$stream.bits_per_raw_sample -ne "" -and + [int]$stream.bits_per_raw_sample -gt 0 + ) { + $bitDepth = [int]$stream.bits_per_raw_sample + } + elseif ( + $null -ne $stream.bits_per_sample -and + [string]$stream.bits_per_sample -ne "" -and + [int]$stream.bits_per_sample -gt 0 + ) { + $bitDepth = [int]$stream.bits_per_sample + } + elseif ([string]$stream.sample_fmt -match "s16") { + $bitDepth = 16 + } + elseif ([string]$stream.sample_fmt -match "s32") { + $bitDepth = 24 + } + + [PSCustomObject]@{ + Codec = [string]$stream.codec_name + SampleRate = [int]$stream.sample_rate + Channels = [int]$stream.channels + BitDepth = [int]$bitDepth + SampleFmt = [string]$stream.sample_fmt + } +} + +Clear-Host + +Write-Host "FLAC -> CD quality" -ForegroundColor Cyan +Write-Host "16 бит / 44,1 кГц / стерео" +Write-Host "Папка: $RootFolder" +Write-Host "" + +if ( + -not ( + Test-Path ` + -LiteralPath $RootFolder ` + -PathType Container + ) +) { + Write-Host "ОШИБКА: папка не найдена:" -ForegroundColor Red + Write-Host $RootFolder -ForegroundColor Red + + Wait-BeforeExit + exit 1 +} + +if (-not $FfmpegPath) { + Write-Host "ОШИБКА: ffmpeg.exe не найден." -ForegroundColor Red + Write-Host "Проверены папка tools и системный PATH." + Wait-BeforeExit + exit 1 +} + +if (-not $FfprobePath) { + Write-Host "ОШИБКА: ffprobe.exe не найден." -ForegroundColor Red + Write-Host "Проверены папка tools и системный PATH." + Wait-BeforeExit + exit 1 +} + +$files = @( + Get-ChildItem ` + -LiteralPath $RootFolder ` + -Filter "*.flac" ` + -File ` + -Recurse | + Where-Object { + $_.Name -notlike "*.__cd_temp__.flac" + } | + Sort-Object FullName +) + +$total = $files.Count + +if ($total -eq 0) { + Write-Host "FLAC-файлы не найдены." ` + -ForegroundColor Yellow + + Wait-BeforeExit + exit 0 +} + +$converted = 0 +$skipped = 0 +$failed = 0 +$index = 0 +$startTime = Get-Date + +$failedFiles = [System.Collections.Generic.List[object]]::new() + +foreach ($file in $files) { + $index++ + + $percent = [int][Math]::Floor( + (($index - 1) / $total) * 100 + ) + + Write-Progress ` + -Activity "Конвертация FLAC в CD-качество" ` + -Status ( + "Файл {0} из {1}: {2}" -f ` + $index, + $total, + $file.Name + ) ` + -CurrentOperation $file.DirectoryName ` + -PercentComplete $percent + + Write-Host "" + + Write-Host ( + "[{0}/{1}] {2}" -f ` + $index, + $total, + $file.FullName + ) -ForegroundColor White + + $tempFile = Join-Path ` + -Path $file.DirectoryName ` + -ChildPath ( + $file.BaseName + + ".__cd_temp__.flac" + ) + + $backupFile = Join-Path ` + -Path $file.DirectoryName ` + -ChildPath ( + $file.Name + + ".__original_backup__" + ) + + try { + if (Test-Path -LiteralPath $tempFile) { + Remove-Item ` + -LiteralPath $tempFile ` + -Force + } + + if (Test-Path -LiteralPath $backupFile) { + Remove-Item ` + -LiteralPath $backupFile ` + -Force + } + + $sourceInfo = Get-FlacAudioInfo ` + -Path $file.FullName + + Write-Host ( + "Исходник: {0} бит, {1} Гц, {2} канал(а)" -f ` + $sourceInfo.BitDepth, + $sourceInfo.SampleRate, + $sourceInfo.Channels + ) + + $alreadyCdQuality = ( + $sourceInfo.BitDepth -eq 16 -and + $sourceInfo.SampleRate -eq 44100 -and + $sourceInfo.Channels -eq 2 + ) + + if ($alreadyCdQuality) { + $skipped++ + + Write-Host ` + "Уже CD-качество — пропущено." ` + -ForegroundColor Green + + continue + } + + Write-Host "Конвертация..." ` + -ForegroundColor Yellow + + $ffmpegOutput = & $FfmpegPath ` + -hide_banner ` + -loglevel error ` + -y ` + -i ` + $file.FullName ` + -map 0:a:0 ` + -map "0:v?" ` + -map_metadata 0 ` + -c:a flac ` + -sample_fmt s16 ` + -ar 44100 ` + -ac 2 ` + -compression_level 8 ` + -c:v copy ` + -- ` + $tempFile 2>&1 + + if ($LASTEXITCODE -ne 0) { + throw "ffmpeg завершился с ошибкой:`n$ffmpegOutput" + } + + if ( + -not ( + Test-Path ` + -LiteralPath $tempFile ` + -PathType Leaf + ) + ) { + throw "Временный файл не был создан." + } + + $tempFileInfo = Get-Item ` + -LiteralPath $tempFile + + if ($tempFileInfo.Length -le 0) { + throw "Создан пустой временный файл." + } + + $resultInfo = Get-FlacAudioInfo ` + -Path $tempFile + + $validResult = ( + $resultInfo.BitDepth -eq 16 -and + $resultInfo.SampleRate -eq 44100 -and + $resultInfo.Channels -eq 2 + ) + + if (-not $validResult) { + throw ( + "Проверка результата не пройдена. " + + "Получено: {0} бит, {1} Гц, " + + "{2} канал(а)." -f ` + $resultInfo.BitDepth, + $resultInfo.SampleRate, + $resultInfo.Channels + ) + } + + $originalCreationTime = $file.CreationTime + $originalLastWriteTime = $file.LastWriteTime + + Move-Item ` + -LiteralPath $file.FullName ` + -Destination $backupFile + + try { + Move-Item ` + -LiteralPath $tempFile ` + -Destination $file.FullName + + $newFile = Get-Item ` + -LiteralPath $file.FullName + + $newFile.CreationTime = $originalCreationTime + $newFile.LastWriteTime = $originalLastWriteTime + + Remove-Item ` + -LiteralPath $backupFile ` + -Force + } + catch { + if ( + Test-Path ` + -LiteralPath $file.FullName + ) { + Remove-Item ` + -LiteralPath $file.FullName ` + -Force ` + -ErrorAction SilentlyContinue + } + + if ( + Test-Path ` + -LiteralPath $backupFile + ) { + Move-Item ` + -LiteralPath $backupFile ` + -Destination $file.FullName ` + -ErrorAction SilentlyContinue + } + + throw + } + + $converted++ + + Write-Host ` + "Готово — исходный файл заменён." ` + -ForegroundColor Cyan + } + catch { + $failed++ + + $errorMessage = $_.Exception.Message + + $failedFiles.Add( + [PSCustomObject]@{ + Name = $file.BaseName + FileName = $file.Name + FullName = $file.FullName + Directory = $file.DirectoryName + Error = $errorMessage + } + ) + + Write-Host "ОШИБКА:" ` + -ForegroundColor Red + + Write-Host $errorMessage ` + -ForegroundColor Red + + if ( + Test-Path ` + -LiteralPath $tempFile + ) { + Remove-Item ` + -LiteralPath $tempFile ` + -Force ` + -ErrorAction SilentlyContinue + } + + if ( + (Test-Path -LiteralPath $backupFile) -and + -not ( + Test-Path ` + -LiteralPath $file.FullName + ) + ) { + Move-Item ` + -LiteralPath $backupFile ` + -Destination $file.FullName ` + -ErrorAction SilentlyContinue + } + } +} + +Write-Progress ` + -Activity "Конвертация FLAC в CD-качество" ` + -Completed + +$elapsed = (Get-Date) - $startTime + +Write-Host "" +Write-Host "========================================" ` + -ForegroundColor DarkGray + +Write-Host "Обработка завершена" ` + -ForegroundColor Cyan + +Write-Host ( + "Всего файлов: {0}" -f $total +) + +Write-Host ( + "Конвертировано: {0}" -f $converted +) -ForegroundColor Cyan + +Write-Host ( + "Пропущено (уже CD): {0}" -f $skipped +) -ForegroundColor Green + +if ($failed -gt 0) { + Write-Host ( + "Ошибок: {0}" -f $failed + ) -ForegroundColor Red +} +else { + Write-Host "Ошибок: 0" ` + -ForegroundColor Gray +} + +Write-Host ( + "Время: {0:hh\:mm\:ss}" -f $elapsed +) + +Write-Host "========================================" ` + -ForegroundColor DarkGray + +if ($failedFiles.Count -gt 0) { + Write-Host "" + Write-Host "ТРЕКИ С ОШИБКАМИ:" ` + -ForegroundColor Red + + Write-Host "========================================" ` + -ForegroundColor DarkGray + + $failedNumber = 0 + + foreach ($failedFile in $failedFiles) { + $failedNumber++ + + Write-Host ( + "{0}. {1}" -f ` + $failedNumber, + $failedFile.Name + ) -ForegroundColor Red + + Write-Host ( + " Файл: {0}" -f ` + $failedFile.FullName + ) -ForegroundColor DarkGray + + Write-Host ( + " Ошибка: {0}" -f ` + $failedFile.Error + ) -ForegroundColor DarkYellow + + Write-Host "" + } + + Write-Host "========================================" ` + -ForegroundColor DarkGray +} +else { + Write-Host "" + Write-Host "Все файлы обработаны без ошибок." ` + -ForegroundColor Green +} + +Wait-BeforeExit \ No newline at end of file diff --git a/scripts/GroupAlbumsByArtist.ps1 b/scripts/GroupAlbumsByArtist.ps1 new file mode 100644 index 0000000..4f7285f --- /dev/null +++ b/scripts/GroupAlbumsByArtist.ps1 @@ -0,0 +1,93 @@ +#requires -Version 5.1 + +$ErrorActionPreference = "Stop" + +$ProjectRoot = Split-Path -Parent $PSScriptRoot +$RootPath = Join-Path $ProjectRoot "downloads" +$Separator = " - " + +try { + $Host.UI.RawUI.WindowTitle = "Группировка альбомов по исполнителям" +} +catch { +} + +function Wait-BeforeExit { + Write-Host "" + [void](Read-Host "Нажмите Enter для выхода") +} + +Clear-Host + +Write-Host "Группировка альбомов по исполнителям" -ForegroundColor Cyan +Write-Host "Папка: $RootPath" +Write-Host "" + +if (-not (Test-Path -LiteralPath $RootPath -PathType Container)) { + New-Item -ItemType Directory -Path $RootPath -Force | Out-Null +} + +$AlbumFolders = Get-ChildItem -LiteralPath $RootPath -Directory | + Where-Object { + $_.Name.Contains($Separator) + } | + ForEach-Object { + $Artist = ($_.Name -split [regex]::Escape($Separator), 2)[0].Trim() + + [PSCustomObject]@{ + Artist = $Artist + Folder = $_ + } + } + +$ArtistGroups = $AlbumFolders | Group-Object -Property Artist + +$Moved = 0 +$Skipped = 0 + +foreach ($Group in $ArtistGroups) { + # Папка исполнителя создаётся только при наличии двух и более альбомов. + if ($Group.Count -lt 2) { + continue + } + + $ArtistName = $Group.Name + $ArtistPath = Join-Path -Path $RootPath -ChildPath $ArtistName + + if (-not (Test-Path -LiteralPath $ArtistPath -PathType Container)) { + New-Item -Path $ArtistPath -ItemType Directory | Out-Null + Write-Host "Создана папка: $ArtistName" -ForegroundColor Green + } + + foreach ($Album in $Group.Group) { + $SourcePath = $Album.Folder.FullName + $DestinationPath = Join-Path -Path $ArtistPath -ChildPath $Album.Folder.Name + + if (Test-Path -LiteralPath $DestinationPath) { + Write-Warning "Папка уже существует, пропущено: $DestinationPath" + $Skipped++ + continue + } + + try { + Move-Item ` + -LiteralPath $SourcePath ` + -Destination $ArtistPath ` + -ErrorAction Stop + + Write-Host "Перемещено: $($Album.Folder.Name) -> $ArtistName" -ForegroundColor Cyan + $Moved++ + } + catch { + Write-Warning "Не удалось переместить '$SourcePath': $($_.Exception.Message)" + $Skipped++ + } + } +} + +Write-Host "" +Write-Host "Обработка завершена." -ForegroundColor Yellow +Write-Host "Перемещено: $Moved" +Write-Host "Пропущено: $Skipped" + +Wait-BeforeExit diff --git a/tools/.gitkeep b/tools/.gitkeep new file mode 100644 index 0000000..e69de29