Init commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
src/bin/
|
||||
src/obj/
|
||||
23
README.md
Normal file
23
README.md
Normal 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
29
UniversalTagEditor.sln
Normal 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
25
src/Helpers/CsvParser.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/Helpers/DiscogsService.cs
Normal file
60
src/Helpers/DiscogsService.cs
Normal 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
136
src/Helpers/TagEditor.cs
Normal 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
114
src/Program.cs
Normal 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
8
src/Types/AlbumInfo.cs
Normal 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
35
src/Types/TrackInfo.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
16
src/UniversalTagEditor.csproj
Normal file
16
src/UniversalTagEditor.csproj
Normal 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>
|
||||
Reference in New Issue
Block a user