Several Fixes for ln.podget

master
Harald Wolff 2024-02-02 23:16:23 +01:00
parent 1044b830e0
commit b8b42af1d8
13 changed files with 475 additions and 10 deletions

View File

@ -1,17 +1,17 @@
using System.Text.RegularExpressions;
namespace ln.podget;
namespace ln.tagorganizer;
public static class PathHelper
{
public static string GetValidPathElement(string pathElement)
{
return Regex.Replace(pathElement, "[^a-zA-Z0-9_ äöü]", "_");
return Regex.Replace(pathElement, "[^a-zA-Z0-9_ äöü\\-\\[\\].]", "_");
}
public static string GetValidPathElement(string folderPath, string pathElement)
{
string cleanElement = Regex.Replace(pathElement, "[^a-zA-Z0-9_ äöü]", "_");
string cleanElement = Regex.Replace(pathElement, "[^a-zA-Z0-9_ äöü\\-\\[\\].]", "_");
int remaining = 254 - folderPath.Length - cleanElement.Length;
if (remaining < 0)
{

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -2,6 +2,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ln.podget", "ln.podget\ln.podget.csproj", "{97F573B1-9EBC-48F0-95BB-2AAEF06E85F4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ln.tagorganizer", "ln.tagorganizer\ln.tagorganizer.csproj", "{BD0E70EC-FA33-47D3-8EF5-60B260EEDC33}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ln.pathhelper", "ln.pathhelper\ln.pathhelper.csproj", "{299D7772-0007-4F67-9E1B-22F92D55DDF4}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -13,5 +17,13 @@ Global
{97F573B1-9EBC-48F0-95BB-2AAEF06E85F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97F573B1-9EBC-48F0-95BB-2AAEF06E85F4}.Release|Any CPU.Build.0 = Release|Any CPU
{97F573B1-9EBC-48F0-95BB-2AAEF06E85F4}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
{BD0E70EC-FA33-47D3-8EF5-60B260EEDC33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BD0E70EC-FA33-47D3-8EF5-60B260EEDC33}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BD0E70EC-FA33-47D3-8EF5-60B260EEDC33}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BD0E70EC-FA33-47D3-8EF5-60B260EEDC33}.Release|Any CPU.Build.0 = Release|Any CPU
{299D7772-0007-4F67-9E1B-22F92D55DDF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{299D7772-0007-4F67-9E1B-22F92D55DDF4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{299D7772-0007-4F67-9E1B-22F92D55DDF4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{299D7772-0007-4F67-9E1B-22F92D55DDF4}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -77,7 +77,6 @@ public class PodcastCatcher : IDisposable
Console.WriteLine($"Downloaded to {fileInfo}");
}
}
}
public void SaveFeeds()

View File

@ -11,7 +11,8 @@ namespace ln.podget
public static void Main(string[] args,
string libraryPath = "/var/cache/podcasts",
bool createLibrary = false,
bool skipDownload = false
bool skipDownload = false,
bool singleThread = false
)
{
if (createLibrary)
@ -22,7 +23,7 @@ namespace ln.podget
}
string[] feeds = args;
using (PodcastCatcher catcher = new PodcastCatcher(libraryPath, skipDownload))
using (PodcastCatcher catcher = new PodcastCatcher(libraryPath, skipDownload, singleThread))
{
if (feeds.Length > 0)
{

View File

@ -1,4 +1,5 @@
using System.Xml;
using ln.tagorganizer;
namespace ln.podget;
@ -9,10 +10,11 @@ public class RSSChannel : IDisposable
public string Description { get; set; }
public string Language { get; set; }
public string Copyright { get; set; }
public string Author { get; set; }
private HashSet<string> _itemIds = new HashSet<string>();
private SortedList<long, RSSItem> _items = new SortedList<long, RSSItem>();
public IEnumerable<RSSItem> Items => _items.Values;
private List<RSSItem> _items = new List<RSSItem>();
public IEnumerable<RSSItem> Items => _items;
public string StoragePath { get; }
@ -20,6 +22,9 @@ public class RSSChannel : IDisposable
public RSSChannel(XMLFeed xmlFeed, XmlNode channelNode)
{
var namespaceManager = new XmlNamespaceManager(channelNode.OwnerDocument.NameTable);
namespaceManager.AddNamespace("itunes","http://www.itunes.com/dtds/podcast-1.0.dtd");
Title = channelNode?.SelectSingleNode("title")?.InnerText ?? "";
var linkNode = channelNode?.SelectSingleNode("link");
Link = linkNode != null ? new Uri(linkNode.InnerText) : null;
@ -27,6 +32,7 @@ public class RSSChannel : IDisposable
Description = channelNode.SelectSingleNode("description")?.InnerText ?? "";
Language = channelNode.SelectSingleNode("language")?.InnerText ?? "";
Copyright = channelNode.SelectSingleNode("copyright")?.InnerText ?? "";
Author = channelNode.SelectSingleNode("itunes:author", namespaceManager)?.InnerText ?? Copyright;
XmlNode thumbNode = channelNode.SelectSingleNode("image/url");
if (thumbNode is not null)
@ -42,6 +48,12 @@ public class RSSChannel : IDisposable
albumDocument.AppendChild(albumNode);
albumNode.AppendChild(titleNode);
XmlNode albumArtistCredits = albumDocument.CreateElement("albumArtistCredits");
XmlNode artist = albumDocument.CreateElement("artist");
artist.InnerText = Author;
albumArtistCredits.AppendChild(artist);
albumNode.AppendChild(albumArtistCredits);
XmlNode plotNode = albumDocument.CreateElement("plot");
plotNode.InnerText = Description;
albumNode.AppendChild(plotNode);
@ -68,10 +80,10 @@ public class RSSChannel : IDisposable
foreach (XmlNode itemNode in channelNode.SelectNodes("item"))
{
var i = new RSSItem(this, itemNode);
_items.Add(i.PubDate.ToFileTime(), i);
_items.Add(i);
}
foreach (var rssItem in _items.Values)
foreach (var rssItem in _items.ToArray().Reverse())
{
if (rssItem.Number == 0)
{

View File

@ -2,6 +2,7 @@ using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Xml;
using ln.tagorganizer;
namespace ln.podget;

View File

@ -31,6 +31,7 @@ public class XMLFeed
XmlDocument xmlDocument = new XmlDocument();
xmlDocument.Load(taskGet.Result.Content.ReadAsStream());
Initialize(xmlDocument);
}

View File

@ -19,4 +19,8 @@
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ln.pathhelper\ln.pathhelper.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,37 @@
namespace ln.tagorganizer;
public class FileMatcher
{
public static IEnumerable<string> ResolveWildcards(string wildcardPath)
{
wildcardPath = Path.GetFullPath(wildcardPath);
string[] wildcardPathTokens = wildcardPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
return ResolveWildcards(Path.GetPathRoot(wildcardPath), wildcardPathTokens);
}
public static IEnumerable<string> ResolveWildcards(string searchDirectory, string[] wildcardPathTokens)
{
if (wildcardPathTokens.Length != 0)
{
if (wildcardPathTokens.Length == 1)
{
foreach (var fileName in Directory.GetFiles(searchDirectory, wildcardPathTokens[0]))
yield return fileName;
}
foreach (var directoryName in Directory.EnumerateDirectories(searchDirectory, wildcardPathTokens[0]))
{
if (wildcardPathTokens.Length > 1)
{
foreach (var matchedName in ResolveWildcards(Path.Combine(searchDirectory, directoryName),
wildcardPathTokens[1..]))
yield return matchedName;
}
else
{
yield return directoryName;
}
}
}
}
}

View File

@ -0,0 +1,153 @@
using System.Security.Cryptography;
using ln.type;
using TagLib;
using File = System.IO.File;
namespace ln.tagorganizer;
public class MediaFile : IDisposable
{
public static MediaFile Create(string filename)
{
try
{
TagLib.File tagFile = TagLib.File.Create(filename);
return new MediaFile(filename);
}
catch (UnsupportedFormatException)
{
return null;
}
}
public string FileName { get; private set; }
public string Extension => Path.GetExtension(FileName);
public string BaseName => Path.GetFileNameWithoutExtension(FileName);
public string? DirectoryName => Path.GetDirectoryName(FileName);
public string HashCacheName => GetHashCacheName(FileName);
public MediaFile(string filename) :this(filename, null)
{ }
private MediaFile(string filename, TagLib.File? tagFile)
{
FileName = filename;
if (!File.Exists(FileName))
throw new FileNotFoundException();
_tagFile = tagFile;
if (!LoadTagFile())
throw new UnsupportedFormatException();
}
public TagLib.Tag Tag => _tagFile.Tag;
private TagLib.File _tagFile;
private bool LoadTagFile()
{
if (_tagFile is null)
{
try
{
_tagFile = TagLib.File.Create(FileName);
}
catch (UnsupportedFormatException)
{
return false;
}
}
return true;
}
private void UnloadTagFile()
{
_tagFile?.Dispose();
_tagFile = null;
}
private byte[] _hash;
public byte[] Hash
{
get
{
if (_hash is null)
ComputeFileHash();
return _hash;
}
}
public string HashString => Hash.ToHexString();
public void Move(string newFileName)
{
UnloadTagFile();
File.Move(FileName, newFileName);
if (File.Exists(HashCacheName))
{
try
{
File.Move(HashCacheName, GetHashCacheName(newFileName));
}
catch (Exception e) { }
}
RemoveDirectoryIfEmpty(Path.GetDirectoryName(FileName));
FileName = newFileName;
LoadTagFile();
}
public MediaFile Copy(string newFileName)
{
File.Copy(FileName, newFileName);
if (File.Exists(HashCacheName))
{
try
{
File.Copy(HashCacheName, GetHashCacheName(newFileName));
}
catch (Exception e) { }
}
return new MediaFile(newFileName);
}
private void RemoveDirectoryIfEmpty(string directoryName)
{
if (Directory.GetFileSystemEntries(directoryName).Length == 0)
Directory.Delete(directoryName);
}
private void ComputeFileHash()
{
if (File.Exists(HashCacheName) && (File.GetLastWriteTime(HashCacheName) > File.GetLastWriteTime(FileName)))
{
_hash = File.ReadAllBytes(HashCacheName);
if (_hash.Length == 32)
return;
}
SHA256 sha256 = SHA256.Create();
using (FileStream fs = new FileStream(FileName, FileMode.Open))
_hash = sha256.ComputeHash(fs);
File.WriteAllBytes(HashCacheName, _hash);
}
private static string GetHashCacheName(string filename)
{
string directoryName = Path.GetDirectoryName(filename);
string baseName = Path.GetFileName(filename);
string hashName = Path.Combine(directoryName, ".hash." + baseName);
return hashName;
}
public override bool Equals(object? obj) => (obj is MediaFile other) && other.FileName.Equals(FileName);
public override int GetHashCode() => Hash.GetHashCode();
public void Dispose()
{
_tagFile?.Dispose();
}
}

View File

@ -0,0 +1,215 @@
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using ln.type;
using TagLib;
using File = System.IO.File;
namespace ln.tagorganizer;
static class Extensions
{
public static bool IsNullOrEmpty(this string s) => (s is null) || string.Empty.Equals(s);
}
public class TagOrganizer
{
public string LibraryPath { get; }
public string? DuplicatesPath { get; set; }
public bool CopyFiles { get; set; }
private Dictionary<string, string> _files = new Dictionary<string, string>();
private Dictionary<string, List<string>?> _duplicates = new Dictionary<string, List<string>?>();
private HashSet<string> _supportedExtensions = new HashSet<string>();
public TagOrganizer(string libraryPath)
{
LibraryPath = Path.GetFullPath(libraryPath);
if (!Directory.Exists(LibraryPath))
throw new DirectoryNotFoundException(LibraryPath);
DuplicatesPath = Path.Combine(LibraryPath, "duplicates");
foreach (var fileType in TagLib.FileTypes.AvailableTypes.Values)
{
foreach (var supportedMimeType in fileType.GetCustomAttributes<SupportedMimeType>())
{
if (supportedMimeType.Extension is not null)
_supportedExtensions.Add("." + supportedMimeType.Extension);
}
}
}
private string BuildTrackName(MediaFile mediaFile, string? suffix = null)
{
StringBuilder trackNameBuilder = new StringBuilder();
if (mediaFile.Tag.Disc > 0)
trackNameBuilder.AppendFormat("Disc {0:D2} - ", mediaFile.Tag.Disc);
if (mediaFile.Tag.Track > 0)
trackNameBuilder.AppendFormat("{0:D2} - ", mediaFile.Tag.Track);
trackNameBuilder.AppendFormat("{0}{2}{1}", mediaFile.Tag.Title, mediaFile.Extension, suffix ?? "");
return PathHelper.GetValidPathElement(trackNameBuilder.ToString());
}
public bool ReorganizeFile(MediaFile mediaFile)
{
if (mediaFile.Tag.JoinedAlbumArtists.IsNullOrEmpty() && mediaFile.Tag.JoinedPerformers.IsNullOrEmpty() && mediaFile.Tag.Album.IsNullOrEmpty())
return false;
string? artist = mediaFile.Tag.JoinedAlbumArtists ?? mediaFile.Tag.JoinedPerformers;
string albumFolderName = artist;
if (artist is )
mediaFile.Tag.Album is null ? : (artist is null ? mediaFile.Tag.Album : artist + " - " + mediaFile.Tag.Album);
string albumDirectoryName = Path.Combine(
LibraryPath,
albumFolderName
);
string trackFileName = BuildTrackName(mediaFile);
string targetPath = Path.Combine(
albumDirectoryName,
trackFileName
);
if (!mediaFile.FileName.Equals(targetPath))
{
if (File.Exists(targetPath))
{
albumDirectoryName = Path.Combine(
DuplicatesPath,
albumFolderName
);
int n = 0;
do
{
n++;
trackFileName = BuildTrackName(mediaFile, $"[{n}]");
targetPath = Path.Combine(
albumDirectoryName,
trackFileName
);
} while (File.Exists(targetPath));
}
Console.WriteLine($"Moving to: {targetPath}");
if (!Directory.Exists(albumDirectoryName))
Directory.CreateDirectory(albumDirectoryName);
if (CopyFiles)
mediaFile.Copy(targetPath);
else
{
mediaFile.Move(targetPath);
}
}
return true;
}
public void ScanDirectory(string directoryName)
{
if (DuplicatesPath.Equals(directoryName))
return;
Console.WriteLine($"Directory: {directoryName}");
foreach (var enumeratedFile in Directory.GetFiles(directoryName))
ScanFile(enumeratedFile);
if (Directory.Exists(directoryName)) // Directory may have been removed by ScanFile()
{
var subDirectories = Directory.GetDirectories(directoryName);
if (subDirectories.Length == 0)
{
if (Directory.GetFiles(directoryName).Length == 0)
Directory.Delete(directoryName);
}
else
{
foreach (var enumeratedDirectory in subDirectories)
ScanDirectory(enumeratedDirectory);
}
}
}
public void ScanFile(string fileName)
{
try
{
if ((Path.GetFileName(fileName)[0] == '.') || (!_supportedExtensions.Contains(Path.GetExtension(fileName))))
return;
MediaFile mediaFile = MediaFile.Create(fileName);
if (mediaFile is null)
return;
if (_files.ContainsKey(mediaFile.HashString))
{
if (!CopyFiles)
File.Delete(fileName);
}
else
ReorganizeFile(mediaFile);
}
catch (Exception e)
{
throw new ApplicationException($"while working on {fileName}", e);
}
}
public void Scan(string pathName)
{
if (Directory.Exists(pathName))
ScanDirectory(pathName);
else
ScanFile(pathName);
}
public void Scan(IEnumerable<string> pathNames)
{
foreach (var pathName in pathNames)
Scan(pathName);
}
public IEnumerable<string> ExpandWildcards(string[] wildcardPaths)
{
foreach (var wildcardPath in wildcardPaths)
{
foreach (var resolveWildcard in FileMatcher.ResolveWildcards(wildcardPath))
{
yield return resolveWildcard;
}
}
}
static void Main(string[] args, bool copy, string libraryPath = ".", string? duplicatesPath = null)
{
try
{
var app = new TagOrganizer(libraryPath)
{
CopyFiles = copy,
};
if (duplicatesPath is not null)
app.DuplicatesPath = duplicatesPath;
if (args.Length > 0)
app.Scan(app.ExpandWildcards(args));
else
app.ScanDirectory(libraryPath);
}
catch (Exception e)
{
Console.Error.WriteLine($"Exception: {e.ToString()}");
}
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ln.type" Version="0.1.9" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
<PackageReference Include="System.CommandLine.DragonFruit" Version="0.4.0-alpha.22272.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ln.pathhelper\ln.pathhelper.csproj" />
</ItemGroup>
</Project>