Init commit

This commit is contained in:
2026-02-02 15:33:30 +03:00
commit 332f15aa02
10 changed files with 448 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
src/bin/
src/obj/

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# Простой скрипт для автоматизированной правки тегов музыкальных файлов
Данный скрипт писался в первую очередь для упрощенной работы с выкачанными из @LosslessRobot flac-треками, у которых обычно неправильный разделитель стоит в теге исполнителя, отсутствуют жанры, да и имя файла такое себе. Данным скриптом пользуюсь для последующей выгрузки на свой частный Navidrome сервер.
## Доступные аргументы
- `-w <path>` - обязательный аргумент для указания рабочей директории с музыкой
- `-a <filename>` **optional** - ищет в указанной директории указанный файл с `.csv` таблицей
- `-f <format>` **optional** - формат треков для фильтрованного чтения указанной директории (mp3, flac, wav и т.д)
- `-c <filename>` **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
- Улучшить работу с обложками альбомов, если парсер работает с несколькими альбомами
- Улучшить решение по поиску жанров

29
UniversalTagEditor.sln Normal file
View File

@@ -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

25
src/Helpers/CsvParser.cs Normal file
View File

@@ -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<TrackInfoMap>();
return csv.GetRecords<TrackInfo>().ToArray();
}
}
}

View File

@@ -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<List<string>> 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<string>();
}
// 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<string>();
}
}

136
src/Helpers/TagEditor.cs Normal file
View File

@@ -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<string>(["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<string> 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<string> 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;
}
}

114
src/Program.cs Normal file
View File

@@ -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<string> 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<List<string>, Query, Task> cycle = async (List<string> 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);
}
}

8
src/Types/AlbumInfo.cs Normal file
View File

@@ -0,0 +1,8 @@
namespace UniversalTagEditor.Types;
public class AlbumInfo
{
public string Name;
public string Cover;
public TrackInfo[] Tracks;
}

35
src/Types/TrackInfo.cs Normal file
View File

@@ -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<TrackInfo>
{
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);
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="MetaBrainz.MusicBrainz" Version="7.0.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
</Project>