From 332f15aa02ec4d9f314476b8867d0d3655899985 Mon Sep 17 00:00:00 2001 From: TheLanc3 Date: Mon, 2 Feb 2026 15:33:30 +0300 Subject: [PATCH] Init commit --- .gitignore | 2 + README.md | 23 ++++++ UniversalTagEditor.sln | 29 ++++++++ src/Helpers/CsvParser.cs | 25 +++++++ src/Helpers/DiscogsService.cs | 60 +++++++++++++++ src/Helpers/TagEditor.cs | 136 ++++++++++++++++++++++++++++++++++ src/Program.cs | 114 ++++++++++++++++++++++++++++ src/Types/AlbumInfo.cs | 8 ++ src/Types/TrackInfo.cs | 35 +++++++++ src/UniversalTagEditor.csproj | 16 ++++ 10 files changed, 448 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 UniversalTagEditor.sln create mode 100644 src/Helpers/CsvParser.cs create mode 100644 src/Helpers/DiscogsService.cs create mode 100644 src/Helpers/TagEditor.cs create mode 100644 src/Program.cs create mode 100644 src/Types/AlbumInfo.cs create mode 100644 src/Types/TrackInfo.cs create mode 100644 src/UniversalTagEditor.csproj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa06a6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +src/bin/ +src/obj/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceca580 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Простой скрипт для автоматизированной правки тегов музыкальных файлов +Данный скрипт писался в первую очередь для упрощенной работы с выкачанными из @LosslessRobot flac-треками, у которых обычно неправильный разделитель стоит в теге исполнителя, отсутствуют жанры, да и имя файла такое себе. Данным скриптом пользуюсь для последующей выгрузки на свой частный Navidrome сервер. +## Доступные аргументы +- `-w ` - обязательный аргумент для указания рабочей директории с музыкой +- `-a ` **optional** - ищет в указанной директории указанный файл с `.csv` таблицей +- `-f ` **optional** - формат треков для фильтрованного чтения указанной директории (mp3, flac, wav и т.д) +- `-c ` **optional** - ищет в указанной директории указанное изображение обложки тех или иных альбомов **(БУДЕТ ИЗМЕНЕНО)** +- `--fix-tags` **optional** - исправляет теги (меняет разделитель в теге исполнителей, заполняет пустой тег исполнителей альбома самими исполнителями и пытается найти жанры в MusicBrainz API) +- `--enhance-structure` **optional** - улучшает структуру +## Примерный шаблон Windows .bat файла +```bat +@setlocal enableextensions +@pushd %~dp0 +.\UniversalTagEditor.exe -w %1 -f flac --fix-tags --enhance-structure +@popd +@pause +``` +## TODO +- Исправить неожиданные проблемы с потоками +- Улучшить структуру чтения .csv файлов +- Документировать структуру .csv +- Улучшить работу с обложками альбомов, если парсер работает с несколькими альбомами +- Улучшить решение по поиску жанров \ No newline at end of file diff --git a/UniversalTagEditor.sln b/UniversalTagEditor.sln new file mode 100644 index 0000000..29b63f4 --- /dev/null +++ b/UniversalTagEditor.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UniversalTagEditor", "src\UniversalTagEditor.csproj", "{F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F12A64BA-EF0D-DBA6-7B72-63FC4220F8EC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D0E6B6A0-0573-41C6-88A8-2678B8799BA7} + EndGlobalSection +EndGlobal diff --git a/src/Helpers/CsvParser.cs b/src/Helpers/CsvParser.cs new file mode 100644 index 0000000..da8198b --- /dev/null +++ b/src/Helpers/CsvParser.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using UniversalTagEditor.Types; + +namespace UniversalTagEditor.Helpers; + +public static class CsvParser +{ + public static TrackInfo[] ParseCsvTable(string path) + { + CsvConfiguration config = new(CultureInfo.InvariantCulture) + { + Delimiter = "\t" + }; + using (var reader = new StreamReader(path)) + using (var csv = new CsvReader(reader, config)) + { + csv.Context.Configuration.Delimiter = "\t"; + csv.Context.RegisterClassMap(); + + return csv.GetRecords().ToArray(); + } + } +} \ No newline at end of file diff --git a/src/Helpers/DiscogsService.cs b/src/Helpers/DiscogsService.cs new file mode 100644 index 0000000..2272b89 --- /dev/null +++ b/src/Helpers/DiscogsService.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading.Tasks; + +public class DiscogsService +{ + private readonly HttpClient _client = new HttpClient(); + private string Token; // Get from Discogs settings + private string UserAgent; + + public DiscogsService(string token, string app, string version, string email) + { + Token = token; + UserAgent = $"{app}/{version} (contact: {email})"; + } + + public async Task> GetGenresAsync(string artist, string album) + { + // 1. Setup Headers (Crucial for Discogs) + _client.DefaultRequestHeaders.Clear(); + _client.DefaultRequestHeaders.Add("User-Agent", UserAgent); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Discogs", $"token={Token}"); + + // 2. Search for the release + // We use the search endpoint to find the ID of the album first + string searchUrl = $"https://api.discogs.com/database/search?artist={Uri.EscapeDataString(artist)}&release_title={Uri.EscapeDataString(album)}&type=release"; + + try + { + var response = await _client.GetStringAsync(searchUrl); + using var doc = JsonDocument.Parse(response); + var firstResult = doc.RootElement.GetProperty("results").EnumerateArray().FirstOrDefault(); + + if (firstResult.ValueKind == JsonValueKind.Undefined) + { + Console.WriteLine("No match found on Discogs."); + return new List(); + } + + // 3. Extract Genre and Style from search result + // Discogs provides these directly in the search results for releases! + var genres = firstResult.GetProperty("genre").EnumerateArray().Select(g => g.GetString()); + var styles = firstResult.GetProperty("style").EnumerateArray().Select(s => s.GetString()); + + Console.WriteLine($"--- Discogs Results for {album} ---"); + Console.WriteLine($"Genres: {string.Join(", ", genres)}"); + Console.WriteLine($"Styles: {string.Join(", ", styles)}"); + + return genres.ToList(); + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + } + return new List(); + } +} \ No newline at end of file diff --git a/src/Helpers/TagEditor.cs b/src/Helpers/TagEditor.cs new file mode 100644 index 0000000..defb71d --- /dev/null +++ b/src/Helpers/TagEditor.cs @@ -0,0 +1,136 @@ +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using TagLib; +using UniversalTagEditor.Types; + +public static class TagEditor { + public static void Edit(string filePath, AlbumInfo info, int trackNum) + { + TagLib.File file = TagLib.File.Create(filePath); + + if (info.Name != null) + file.Tag.Album = info.Name; + + if (info.Cover != null) + { + Picture picture = new Picture(info.Cover); + picture.Type = PictureType.FrontCover; + file.Tag.Pictures = new[] { picture }; + } + + if (info.Tracks.Length > 0) + { + file.Tag.Title = info.Tracks[trackNum].Title; + file.Tag.Track = uint.Parse(info.Tracks[trackNum].TrackNumber); + file.Tag.Year = uint.Parse(info.Tracks[trackNum].Year); + file.Tag.Composers = info.Tracks[trackNum].Composers.Split(";"); + file.Tag.Disc = uint.Parse(info.Tracks[trackNum].Disc); + file.Tag.Performers = info.Tracks[trackNum].Artists.Split(";"); + file.Tag.AlbumArtists = new List(["Microsoft"]).ToArray(); + file.Tag.Genres = info.Tracks[trackNum].Genres.Split(";"); + file.Tag.Publisher = info.Tracks[trackNum].Publisher; + } + + file.Save(); + } + + public static async Task Fix(string filePath, Query query, bool enhanceStructure, string format) + { + TagLib.File file = TagLib.File.Create(filePath); + + if (file.Tag.Performers.Length == 1) { + List performers = file.Tag.Performers[0] + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .SelectMany(p => p.Split('&', StringSplitOptions.RemoveEmptyEntries)) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(); + + file.Tag.Performers = performers.ToArray(); + if (file.Tag.AlbumArtists == null || file.Tag.AlbumArtists.Length == 0) + file.Tag.AlbumArtists = new [] { performers[0] }; + else + { + List albumArtist = file.Tag.AlbumArtists[0] + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .SelectMany(p => p.Split('&', StringSplitOptions.RemoveEmptyEntries)) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(); + file.Tag.AlbumArtists = albumArtist.ToArray(); + } + } + + if (file.Tag.Genres == null || file.Tag.Genres.Length == 0) + { + bool noException = false; + + while (!noException) + { + try + { + var albumSearch = await query.FindReleaseGroupsAsync($"artist:\"{file.Tag.AlbumArtists[0]}\" AND releasegroup:\"{file.Tag.Album}\""); + var albumResult = albumSearch.Results.FirstOrDefault()?.Item; + + if (albumResult != null) + { + IReleaseGroup fullAlbum = await query.LookupReleaseGroupAsync(albumResult.Id, Include.Genres); + file.Tag.Genres = fullAlbum.Genres?.Select(g => g.Name).ToArray(); + } + } + catch { } + finally + { + noException = true; + } + } + } + + file.Save(); + + if (enhanceStructure) + { + FileInfo path = new FileInfo(filePath); + Console.WriteLine(file.Tag.Album); + string album = ReplaceSystemSymbols(file.Tag.Album); + if (!Directory.Exists(Path.Combine(path.DirectoryName!, album))) + Directory.CreateDirectory(Path.Combine(path.DirectoryName!, album)); + + string output = Path.Combine(path.DirectoryName!, + album, + $"{file.Tag.Track}. {string.Join(", ", file.Tag.Performers.Select(p => ReplaceSystemSymbols(p)))} - {file.Tag.Title}.{format}"); + + if (System.IO.File.Exists(output)) + System.IO.File.Delete(filePath); + else + System.IO.File.Move(filePath, output); + await Task.Delay(1000); + } + } + + private static string ReplaceSystemSymbols(string origin) + { + string result = origin; + + if (result.Contains("/")) + result.Replace("/", "-"); + if (result.Contains(":")) + result.Replace(":", "-"); + if (result.Contains("|")) + result.Replace("|", "-"); + if (result.Contains("\\")) + result.Replace("\\", "-"); + if (result.Contains("?")) + result.Replace("?", ""); + if (result.Contains("\"")) + result.Replace("\"", "_"); + if (result.Contains("*")) + result.Replace("*", "_"); + if (result.Contains("<")) + result.Replace("<", "_"); + if (result.Contains(">")) + result.Replace(">", "_"); + + return result; + } +} \ No newline at end of file diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..835747f --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,114 @@ +using UniversalTagEditor.Helpers; +using UniversalTagEditor.Types; + +using MetaBrainz.MusicBrainz; + +namespace UniversalTagEditor; + +class Program +{ + static async Task Main(string[] args) + { + string albumInfo = ""; + string albumCover = ""; + string format = ""; + string workingDirectory = ""; + bool fixTags = false; + bool enhanceStructure = false; + + var query = new Query("NavidromeMetadataRecovery1", "1.0"); + var query2 = new Query("NavidromeMetadataRecovery2", "1.0"); + var query3 = new Query("NavidromeMetadataRecovery3", "1.0"); + var query4 = new Query("NavidromeMetadataRecovery4", "1.0"); + + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "-w": + if (i + 1 < args.Length) workingDirectory = args[i + 1]; + i++; + break; + + case "-a": + if (i + 1 < args.Length) albumInfo = args[i + 1]; + i++; + break; + + case "-f": + if (i + 1 < args.Length) format = args[i + 1]; + i++; + break; + + case "-c": + if (i + 1 < args.Length) albumCover = args[i + 1]; + i++; + break; + + case "--fix-tags": + fixTags = true; + break; + case "--enhance-structure": + enhanceStructure = true; + break; + } + } + + TrackInfo[] records; + AlbumInfo album = new(); + if (albumInfo != "") + { + records = CsvParser.ParseCsvTable(Path.Combine(workingDirectory, albumInfo)); + album = new() + { + Tracks = records + }; + } + if (albumCover != "") + album.Cover = Path.Combine(workingDirectory, albumCover); + + List files = new(); + files.AddRange(Directory.GetFiles(workingDirectory, $"*.{format}")); + + if (!enhanceStructure) { + string[] directories = Directory.GetDirectories(workingDirectory); + foreach(string directory in directories) + files.AddRange(Directory.GetFiles(directory, $"*.{format}")); + } + + Func, Query, Task> cycle = async (List chunk, Query query) => + { + for (int i = 0; i < chunk.Count; i++) + { + if (fixTags) + await TagEditor.Fix(chunk[i], query, enhanceStructure, format); + else + TagEditor.Edit(chunk[i], album, i); + Console.WriteLine($"{i+1}. {chunk[i]} successfully edited"); + } + }; + + if ((int)(files.Count / 4) > 1) + { + Thread thread = new Thread(async () => await cycle(files.GetRange(0, (int)(files.Count / 4)), query)) { IsBackground = true }; + thread.Start(); + + Thread thread2 = new Thread(async () => await cycle(files.GetRange((int)(files.Count / 4), (int)(files.Count / 4)), query2)) { IsBackground = true }; + thread2.Start(); + + if ((int)(files.Count / 4) >= 3) { + Thread thread3 = new Thread(async () => await cycle(files.GetRange(((int)(files.Count / 4) * 2), (int)(files.Count / 4)), query3)) { IsBackground = true }; + thread3.Start(); + } + if ((int)(files.Count / 4) >= 4) { + Thread thread4 = new Thread(async () => await cycle(files.GetRange(((int)(files.Count / 4) * 3), files.Count - ((int)(files.Count / 4) * 3)), query4)) { IsBackground = true }; + thread4.Start(); + } + + Console.WriteLine("Waiting for threads complete"); + Console.ReadKey(); + } + else + await cycle(files, query); + } +} \ No newline at end of file diff --git a/src/Types/AlbumInfo.cs b/src/Types/AlbumInfo.cs new file mode 100644 index 0000000..2bdbe1a --- /dev/null +++ b/src/Types/AlbumInfo.cs @@ -0,0 +1,8 @@ +namespace UniversalTagEditor.Types; + +public class AlbumInfo +{ + public string Name; + public string Cover; + public TrackInfo[] Tracks; +} \ No newline at end of file diff --git a/src/Types/TrackInfo.cs b/src/Types/TrackInfo.cs new file mode 100644 index 0000000..cea0567 --- /dev/null +++ b/src/Types/TrackInfo.cs @@ -0,0 +1,35 @@ +using CsvHelper.Configuration; +using CsvHelper.Configuration.Attributes; + +namespace UniversalTagEditor.Types; + +public class TrackInfo +{ + public string TrackNumber; + public string Disc; + + public string Title; + + public string Composers; + public string Artists; + + public string Publisher; + + public string Year; + public string Genres; +} + +public class TrackInfoMap : ClassMap +{ + public TrackInfoMap() + { + Map(m => m.TrackNumber).Name("Track #").Index(0); + Map(m => m.Disc).Name("Disc").Index(1); + Map(m => m.Title).Name("Track Title").Index(2); + Map(m => m.Composers).Name("Composers").Index(3); + Map(m => m.Artists).Name("Artists").Index(4); + Map(m => m.Publisher).Name("Publisher").Index(5); + Map(m => m.Year).Name("Copyright Year").Index(6); + Map(m => m.Genres).Name("Genres").Index(7); + } +} \ No newline at end of file diff --git a/src/UniversalTagEditor.csproj b/src/UniversalTagEditor.csproj new file mode 100644 index 0000000..2b01bac --- /dev/null +++ b/src/UniversalTagEditor.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + +