Compare commits

...

22 Commits

Author SHA1 Message Date
Mbucari
74c76a7414
Enhance bug report template formatting and instructions
Updated the bug report template to improve clarity and formatting, including default log file locations for various platforms.
2025-09-04 12:46:22 -06:00
Mbucari
17a0c21453
Document xHE-AAC conformance errors for Audible
Added notes on xHE-AAC conformance errors related to Audible files.
2025-09-04 12:24:46 -06:00
rmcrackan
fc9c9dfe48
Merge pull request #1360 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-5
Bump actions/setup-dotnet from 4 to 5
2025-09-03 21:43:10 -04:00
dependabot[bot]
d5f0e39981
Bump actions/setup-dotnet from 4 to 5
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 5.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 00:05:35 +00:00
rmcrackan
0f6493f4af
Merge pull request #1353 from rmcrackan/rmcrackan-patch-2
Update NamingTemplates.md
2025-08-27 09:30:09 -04:00
rmcrackan
454b490a06
Update NamingTemplates.md 2025-08-27 09:28:45 -04:00
rmcrackan
ffea2648aa
Merge pull request #1352 from rmcrackan/rmcrackan-patch-1
Update NamingTemplates.md
2025-08-27 09:22:20 -04:00
rmcrackan
1ac967500c
Update NamingTemplates.md
Better documentation for inverted tags
2025-08-27 08:57:20 -04:00
rmcrackan
ed5afe5d0f update dependencies 2025-08-20 12:07:53 -04:00
rmcrackan
ab075d0bef
Merge pull request #1343 from MajorTanya/patch-1
Place examples in their own line
2025-08-20 11:49:46 -04:00
MajorTanya
7fb1adb41b
Place examples in their own line
Markdown collapses single line breaks, so this change makes it so the examples will have their own lines.
2025-08-20 17:26:48 +02:00
rmcrackan
9735a8391c incr ver 2025-08-20 08:39:10 -04:00
rmcrackan
dbdfdbc536
Merge pull request #1342 from Mbucari/master
Added new <has PROPERTY-><-has> conditional tag
2025-08-20 08:37:11 -04:00
Michael Bucari-Tovo
3b86fc405f Add <has-> template tag 2025-08-19 18:41:31 -06:00
MBucari
4ea7f04921 Preserve space between series order numbers 2025-08-17 13:40:37 -06:00
MBucari
5b59b442ab Add last downloaded sample rate, bitrate and codec name to search engine. 2025-08-17 13:07:24 -06:00
rmcrackan
b5d9c0a27a Incr ver 2025-08-17 10:06:12 -04:00
rmcrackan
f5cbf89e13
Merge pull request #1337 from Mbucari/master
Fix linux crpto and series order parsing
2025-08-17 09:57:43 -04:00
rmcrackan
00dc9e020d
Update bug_report.md 2025-08-17 09:55:26 -04:00
MBucari
bfa0e4d338 Parse floats with invariant culture 2025-08-16 16:39:36 -06:00
Mbucari
5ceda408da Use managed RSASSA-PSS with SHA-1
OpenSSL (the underlying RSA implementation on Linux) has deprecated SHA-1 signing. Used a managed implementation so that it does not error.
2025-08-16 16:28:33 -06:00
Mbucari
716b1923a4
Update FrequentlyAskedQuestions.md 2025-08-15 12:25:03 -06:00
27 changed files with 421 additions and 59 deletions

View File

@ -6,10 +6,14 @@ labels: bug
assignees: ''
---
**Describe the bug**
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
___
## Describe the bug
A clear and concise description of what the bug is.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@ -17,14 +21,23 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
## Log Files
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.

View File

@ -43,7 +43,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:

View File

@ -44,7 +44,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:

View File

@ -58,6 +58,11 @@ This is a proprietary codec created by the [Fraunhofer Institute for Integrated
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
## Dolby Atmos
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.

View File

@ -37,9 +37,9 @@ Self-hosting online:
## Q: I'm having trouble playing my non-spatial audiobook, how can I fix this?
**A:** If you enabled the [Use Widevine DRM](AudioFileFormats.md#use-widevine-drm) option in settings, the audiobook is most likely being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options:
**A:** If you enabled the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings, then the audiobook is being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options:
1. Use a media player which supports the xHE-AAC codec. [See an incomplete list of media players which support xHE-AAC](AudioFileFormats.md#supported-media-players).
2. Disable [Use Widevine DRM](AudioFileFormats.md#use-widevine-drm) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support.
2. Disable the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support.
## Q: I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this?

View File

@ -81,17 +81,39 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|\<has PROPERTY-\>...\<-has\>|Only include if the PROPERTY has a value (i.e. not null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|Inverted Tag|Description|Type|
|-|-|-|
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
This example will add a number if the `<series#\>` tag has a value:
`<has series#><series#><-has>`
This example will put non-series books in a "Standalones" folder:
`<!if series->Standalones/<-if series>`
And this example will customize the title based on whether the book has a subtitle:
`<audible title><has audible subtitle->-<audible subtitle><-has>`
# Tag Formatters
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.

View File

@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.5.1.1</Version>
<Version>12.5.3.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.4.1" />
<PackageReference Include="AudibleApi" Version="9.4.5.1" />
<PackageReference Include="Google.Protobuf" Version="3.32.0" />
</ItemGroup>

View File

@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Numerics;
using System.Security.Cryptography;
#nullable enable
@ -56,18 +57,99 @@ internal class Device
public byte[] SignMessage(byte[] message)
{
using var sha1 = SHA1.Create();
var digestion = sha1.ComputeHash(message);
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
var digestion = SHA1.HashData(message);
return PssSha1Signer.SignHash(CdmKey, digestion);
}
public bool VerifyMessage(byte[] message, byte[] signature)
{
using var sha1 = SHA1.Create();
var digestion = sha1.ComputeHash(message);
var digestion = SHA1.HashData(message);
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
}
public byte[] DecryptSessionKey(byte[] sessionKey)
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
/// <summary>
/// Completely managed implementation of RSASSA-PSS using SHA-1.
/// https://github.com/bcgit/bc-csharp/blob/master/crypto/src/crypto/signers/PssSigner.cs
///
/// Absolutely nobody anywhere should use this RSASSA-PSS implementation in anything where they care about security at all. We completely skipped the random salt part of it because libation doesn't need security; it only needs to satisfy Audible server's challenge-response requirements.
/// </summary>
private static class PssSha1Signer
{
private const int Sha1DigestSize = 20;
private const int Trailer = 0xBC;
public static byte[] SignHash(RSA rsa, ReadOnlySpan<byte> hash)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(hash.Length, Sha1DigestSize);
var parameters = rsa.ExportParameters(true);
var Modulus = new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true);
var Exponent = new BigInteger(parameters.D, isUnsigned: true, isBigEndian: true);
var emBits = rsa.KeySize - 1;
var block = new byte[(emBits + 7) / 8];
var firstByteMask = (byte)(0xFFU >> ((block.Length * 8) - emBits));
Span<byte> mDash = new byte[8 + 2 * Sha1DigestSize];
hash.CopyTo(mDash.Slice(8));
var h = SHA1.HashData(mDash);
block[^(2 * (Sha1DigestSize + 1))] = 1;
byte[] dbMask = MaskGeneratorFunction1(h, 0, h.Length, block.Length - Sha1DigestSize - 1);
for (int i = 0; i != dbMask.Length; i++)
block[i] ^= dbMask[i];
h.CopyTo(block, block.Length - Sha1DigestSize - 1);
block[0] &= firstByteMask;
block[^1] = Trailer;
var input = new BigInteger(block, isUnsigned: true, isBigEndian: true);
var result = BigInteger.ModPow(input, Exponent, Modulus);
return result.ToByteArray(isUnsigned: true, isBigEndian: true);
}
private static byte[] MaskGeneratorFunction1(byte[] Z, int zOff, int zLen, int length)
{
byte[] mask = new byte[length];
byte[] hashBuf = new byte[Sha1DigestSize];
byte[] C = new byte[4];
int counter = 0;
using var sha = SHA1.Create();
for (; counter < (length / Sha1DigestSize); counter++)
{
ItoOSP(counter, C);
sha.TransformBlock(Z, zOff, zLen, null, 0);
sha.TransformFinalBlock(C, 0, C.Length);
sha.Hash!.CopyTo(mask, counter * Sha1DigestSize);
}
if ((counter * Sha1DigestSize) < length)
{
ItoOSP(counter, C);
sha.TransformBlock(Z, zOff, zLen, null, 0);
sha.TransformFinalBlock(C, 0, C.Length);
Array.Copy(sha.Hash!, 0, mask, counter * Sha1DigestSize, mask.Length - (counter * Sha1DigestSize));
}
return mask;
}
private static void ItoOSP(int i, byte[] sp)
{
sp[0] = (byte)((uint)i >> 24);
sp[1] = (byte)((uint)i >> 16);
sp[2] = (byte)((uint)i >> 8);
sp[3] = (byte)((uint)i >> 0);
}
}
}

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>

View File

@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Polly" Version="8.6.2" />
</ItemGroup>

View File

@ -22,6 +22,8 @@ internal interface IClosingPropertyTag : IPropertyTag
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
}
public delegate bool Conditional<T>(ITemplateTag templateTag, T value, string condition);
public class ConditionalTagCollection<TClass> : TagCollection
{
public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
@ -32,21 +34,49 @@ public class ConditionalTagCollection<TClass> : TagCollection
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
{
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
var target = propertyGetter.Target is null ? null : Expression.Constant(propertyGetter.Target);
var expr = Expression.Call(target, propertyGetter.Method, Parameter);
AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
}
/// <summary>
/// Register a conditional tag.
/// </summary>
/// <param name="conditional">A <see cref="Conditional{TClass}"/> to get the condition's <see cref="bool"/> value</param>
public void Add(ITemplateTag templateTag, Conditional<TClass> conditional)
{
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, conditional));
}
private class ConditionalTag : TagBase, IClosingPropertyTag
{
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
private Func<string?, Expression> CreateConditionExpression { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
: base(templateTag, conditionExpression)
{
NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options);
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}->", options);
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
CreateConditionExpression = _ => conditionExpression;
}
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, Conditional<TClass> conditional)
: base(templateTag, Expression.Constant(false))
{
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}(?:\s+?(.*?)\s*?)?->", options);
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
var target = conditional.Target is null ? null : Expression.Constant(conditional.Target);
CreateConditionExpression = condition
=> Expression.Call(
conditional.Target is null ? null : Expression.Constant(conditional.Target),
conditional.Method,
Expression.Constant(templateTag),
parameter,
Expression.Constant(condition));
}
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
@ -64,6 +94,13 @@ public class ConditionalTagCollection<TClass> : TagCollection
return false;
}
protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ValueExpression) : ValueExpression;
protected override Expression GetTagExpression(string exactName, string[] extraData)
{
if (extraData.Length is not (1 or 2) || extraData[0] is not ("!" or "") || extraData.Length == 2 && string.IsNullOrWhiteSpace(extraData[1]))
return Expression.Constant(false);
var getBool = extraData.Length == 2 ? CreateConditionExpression(extraData[1]) : CreateConditionExpression(null);
return extraData[0] == "!" ? Expression.Not(getBool) : getBool;
}
}
}

View File

@ -30,7 +30,7 @@ public class NamingTemplate
/// Invoke the <see cref="NamingTemplate"/>
/// </summary>
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
public TemplatePart Evaluate(params object[] propertyClasses)
public TemplatePart Evaluate(params object?[] propertyClasses)
{
if (templateToString is null)
throw new InvalidOperationException();
@ -38,8 +38,8 @@ public class NamingTemplate
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
// First parameter is "this", so ignore it.
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
object[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i.GetType(), (_, i) => i).ToArray();
object?[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i?.GetType(), (_, i) => i).ToArray();
if (args.Length != delegateArgTypes.Count())
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");

View File

@ -1,6 +1,7 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
@ -109,6 +110,25 @@ public class PropertyTagCollection<TClass> : TagCollection
catch { return null; }
}
/// <summary>
/// Try to get the default (unformatted) value of a property tag.
/// </summary>
/// <param name="tagName">Name of the tag value to get</param>
/// <param name="object">The property class from which the tag's value is read</param>
/// <param name="value"><paramref name="tagName"/>'s string value if it is in this collection, otherwise null</param>
/// <returns>True if the <paramref name="tagName"/> is in this collection, otherwise false</returns>
public bool TryGetValue(string tagName, TClass @object, [NotNullWhen(true)] out string? value)
{
value = null;
if (!StartsWith($"<{tagName}>", out var exactName, out var propertyTag, out var valueExpression))
return false;
var func = Expression.Lambda<Func<TClass, string>>(valueExpression, Parameter).Compile();
value = func(@object);
return true;
}
private class PropertyTag<TPropertyValue> : TagBase
{
public override Regex NameMatcher { get; }
@ -138,8 +158,13 @@ public class PropertyTagCollection<TClass> : TagCollection
expVal);
}
protected override Expression GetTagExpression(string exactName, string formatString)
protected override Expression GetTagExpression(string exactName, string[] extraData)
{
if (extraData.Length is not (0 or 1))
return Expression.Constant(exactName);
string formatString = extraData.Length == 1 ? extraData[0] : "";
Expression toStringExpression
= !ReturnType.IsValueType
? Expression.Condition(

View File

@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
@ -42,8 +43,8 @@ internal abstract class TagBase : IPropertyTag
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
protected abstract Expression GetTagExpression(string exactName, string formatter);
/// <param name="extraData">Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options</param>
protected abstract Expression GetTagExpression(string exactName, string[] extraData);
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
{
@ -51,7 +52,7 @@ internal abstract class TagBase : IPropertyTag
if (match.Success)
{
exactName = match.Value;
propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : "");
propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).Select(v => v.Value.Trim()).ToArray());
return true;
}

View File

@ -18,7 +18,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'s TClass type.</summary>
internal ParameterExpression Parameter { get; }
protected RegexOptions Options { get; } = RegexOptions.Compiled;
private List<IPropertyTag> PropertyTags { get; } = new();
internal List<IPropertyTag> PropertyTags { get; } = new();
protected TagCollection(Type classType, bool caseSensative = true)
{

View File

@ -48,13 +48,13 @@
</Grid>
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="NUMBER FIELDS" />
<TextBlock Text="STRING FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding StringUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding StringFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="STRING FIELDS" />
<TextBlock Text="NUMBER FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding NumberUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding NumberFields}"/>
</Grid>

View File

@ -0,0 +1,15 @@
using AaxDecrypter;
#nullable enable
namespace LibationFileManager.Templates;
public class CombinedDto
{
public LibraryBookDto LibraryBook { get; }
public MultiConvertFileProperties? MultiConvert { get; }
public CombinedDto(LibraryBookDto libraryBook, MultiConvertFileProperties? multiConvert = null)
{
LibraryBook = libraryBook;
MultiConvert = multiConvert;
}
}

View File

@ -28,7 +28,7 @@ public class SeriesOrder : IFormattable
while (TryParseNumber(order, out var value, out var range))
{
var prefix = order[..range.Start.Value];
if(!string.IsNullOrWhiteSpace(prefix))
if(!string.IsNullOrEmpty(prefix))
parts.Add(prefix);
parts.Add(value);
@ -36,7 +36,7 @@ public class SeriesOrder : IFormattable
order = order[range.End.Value..];
}
if (!string.IsNullOrWhiteSpace(order))
if (!string.IsNullOrEmpty(order))
parts.Add(order);
return new(parts.ToArray());
@ -74,7 +74,7 @@ public class SeriesOrder : IFormattable
continue;
var substring = numString[s..e];
if (float.TryParse(substring, out value))
if (float.TryParse(substring, System.Globalization.CultureInfo.InvariantCulture, out value))
{
range = new Range(s, e);
return true;

View File

@ -56,5 +56,6 @@ namespace LibationFileManager.Templates
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<if podcastparent-><-if podcastparent>", "<if podcastparent->...<-if podcastparent>");
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<has -><-has>", "<has PROPERTY->...<-has>");
}
}

View File

@ -111,7 +111,7 @@ namespace LibationFileManager.Templates
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps).Select(p => p.Value));
return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps, new CombinedDto(libraryBookDto, multiChapProps)).Select(p => p.Value));
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
@ -138,11 +138,11 @@ namespace LibationFileManager.Templates
protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => replacements.ReplaceFilenameChars(p.Value));
private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, params object[] dtos)
private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, LibraryBookDto lbDto, MultiConvertFileProperties? multiDto = null)
{
fileExtension = FileUtility.GetStandardizedExtension(fileExtension);
var parts = NamingTemplate.Evaluate(dtos).ToList();
var parts = NamingTemplate.Evaluate(lbDto, multiDto, new CombinedDto(lbDto, multiDto)).ToList();
var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements));
//Remove 1 character from the end of the longest filename part until
@ -323,6 +323,35 @@ namespace LibationFileManager.Templates
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent },
};
private static readonly ConditionalTagCollection<CombinedDto> combinedConditionalTags = new()
{
{ TemplateTags.Has, HasValue}
};
private static bool HasValue(ITemplateTag tag, CombinedDto dtos, string condition)
{
foreach (var c in chapterPropertyTags.OfType<PropertyTagCollection<LibraryBookDto>>().Append(filePropertyTags).Append(audioFilePropertyTags))
{
if (c.TryGetValue(condition, dtos.LibraryBook, out var value))
{
return !string.IsNullOrWhiteSpace(value);
}
}
if (dtos.MultiConvert is null)
return false;
foreach (var c in chapterPropertyTags.OfType<PropertyTagCollection<MultiConvertFileProperties>>())
{
if (c.TryGetValue(condition, dtos.MultiConvert, out var value))
{
return !string.IsNullOrWhiteSpace(value);
}
}
return false;
}
private static readonly ConditionalTagCollection<LibraryBookDto> folderConditionalTags = new()
{
{ TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent }
@ -388,7 +417,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags];
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags, combinedConditionalTags];
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
@ -407,7 +436,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags];
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, combinedConditionalTags];
}
public class ChapterFileTemplate : Templates, ITemplate
@ -416,7 +445,7 @@ namespace LibationFileManager.Templates
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; }
= chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags);
= chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags).Append(combinedConditionalTags);
public override IEnumerable<string> Warnings
=> NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
@ -429,7 +458,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Chapter Title Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? "";
public static string DefaultTemplate => "<ch#> - <title short>: <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags);
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags).Append(combinedConditionalTags);
protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => p.Value);

View File

@ -46,6 +46,7 @@ namespace LibationSearchEngine
{ FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() },
{ FieldType.String, lb => lb.Book.Locale, "Locale", "Region" },
{ FieldType.String, lb => lb.Account, "Account", "Email" },
{ FieldType.String, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.CodecString, "Codec", "DownloadedCodec" },
{ FieldType.Bool, lb => lb.Book.HasPdf().ToString(), "HasDownloads", "HasDownload", "Downloads" , "Download", "HasPDFs", "HasPDF" , "PDFs", "PDF" },
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" },
{ FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" },
@ -65,7 +66,9 @@ namespace LibationSearchEngine
{ FieldType.Number, lb => lb.Book.UserDefinedItem.Rating.OverallRating.ToLuceneString(), "UserRating", "MyRating" },
{ FieldType.Number, lb => lb.Book.DatePublished?.ToLuceneString() ?? "", nameof(Book.DatePublished) },
{ FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloaded.ToLuceneString(), nameof(UserDefinedItem.LastDownloaded), "LastDownload" },
{ FieldType.Number, lb => lb.DateAdded.ToLuceneString(), nameof(LibraryBook.DateAdded) }
{ FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.BitRate.ToLuceneString(), "Bitrate", "DownloadedBitrate" },
{ FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate.ToLuceneString(), "SampleRate", "DownloadedSampleRate" },
{ FieldType.Number, lb => lb.DateAdded.ToLuceneString(), nameof(LibraryBook.DateAdded) }
};
#endregion

View File

@ -42,7 +42,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.3.1" />
</ItemGroup>
<ItemGroup>

View File

@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
</ItemGroup>
</Project>

View File

@ -9,7 +9,7 @@ public static class AssertionExtensions
[StackTraceHidden]
public static void Be<T>(this T? value, T? expectedValue) where T : IEquatable<T>
=> Assert.AreEqual(value, expectedValue);
=> Assert.AreEqual(expectedValue, value);
[StackTraceHidden]
public static void BeNull<T>(this T? value) where T : class
@ -17,7 +17,7 @@ public static class AssertionExtensions
[StackTraceHidden]
public static void BeSameAs<T>(this T? value, T? otherValue)
=> Assert.AreSame(value, otherValue);
=> Assert.AreSame(otherValue, value);
[StackTraceHidden]
public static void BeFalse(this bool value)
@ -33,5 +33,5 @@ public static class AssertionExtensions
[StackTraceHidden]
public static void BeEquivalentTo<T>(this IEnumerable<T?>? value, IEnumerable<T?>? expectedValue)
=> CollectionAssert.AreEquivalent(value, expectedValue, EqualityComparer<T?>.Default);
=> CollectionAssert.AreEquivalent(expectedValue, value, EqualityComparer<T?>.Default);
}

View File

@ -15,6 +15,7 @@ namespace NamingTemplateTests
public string Item1 { get; set; }
public string Item2 { get; set; }
public string Item3 { get; set; }
public string NullItem { get; set; }
public int Int1 { get; set; }
public bool Condition { get; set; }
}
@ -25,6 +26,7 @@ namespace NamingTemplateTests
public string Item2 { get; set; }
public string Item3 { get; set; }
public string Item4 { get; set; }
public string NullItem { get; set; }
public bool Condition { get; set; }
}
class PropertyClass3
@ -33,6 +35,7 @@ namespace NamingTemplateTests
public string Item2 { get; set; }
public string Item3 { get; set; }
public string Item4 { get; set; }
public string NullItem { get; set; }
public ReferenceType RefType { get; set; }
public int? Int2 { get; set; }
public bool Condition { get; set; }
@ -49,41 +52,54 @@ namespace NamingTemplateTests
[TestClass]
public class GetPortionFilename
{
PropertyTagCollection<PropertyClass1> props1 = new()
static PropertyTagCollection<PropertyClass1> props1 = new()
{
{ new TemplateTag { TagName = "item1" }, i => i.Item1 },
{ new TemplateTag { TagName = "item2" }, i => i.Item2 },
{ new TemplateTag { TagName = "item3" }, i => i.Item3 }
{ new TemplateTag { TagName = "item3" }, i => i.Item3 },
{ new TemplateTag { TagName = "null_1" }, i => i.NullItem }
};
PropertyTagCollection<PropertyClass2> props2 = new()
static PropertyTagCollection<PropertyClass2> props2 = new()
{
{ new TemplateTag { TagName = "item1" }, i => i.Item1 },
{ new TemplateTag { TagName = "item2" }, i => i.Item2 },
{ new TemplateTag { TagName = "item3" }, i => i.Item3 },
{ new TemplateTag { TagName = "item4" }, i => i.Item4 },
{ new TemplateTag { TagName = "null_2" }, i => i.NullItem }
};
PropertyTagCollection<PropertyClass3> props3 = new(true, GetVal)
static PropertyTagCollection<PropertyClass3> props3 = new(true, GetVal)
{
{ new TemplateTag { TagName = "item3_1" }, i => i.Item1 },
{ new TemplateTag { TagName = "item3_2" }, i => i.Item2 },
{ new TemplateTag { TagName = "item3_3" }, i => i.Item3 },
{ new TemplateTag { TagName = "item3_4" }, i => i.Item4 },
{ new TemplateTag { TagName = "null_3" }, i => i.NullItem },
{ new TemplateTag { TagName = "reftype" }, i => i.RefType },
};
ConditionalTagCollection<PropertyClass1> conditional1 = new()
{
{ new TemplateTag { TagName = "ifc1" }, i => i.Condition },
{ new TemplateTag { TagName = "has1" }, HasValue }
};
ConditionalTagCollection<PropertyClass2> conditional2 = new()
{
{ new TemplateTag { TagName = "ifc2" }, i => i.Condition },
{ new TemplateTag { TagName = "has2" }, HasValue }
};
ConditionalTagCollection<PropertyClass3> conditional3 = new()
{
{ new TemplateTag { TagName = "ifc3" }, i => i.Condition },
{ new TemplateTag { TagName = "has3" }, HasValue }
};
private static bool HasValue(ITemplateTag templateTag, PropertyClass1 referenceType, string condition)
=> props1.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
private static bool HasValue(ITemplateTag templateTag, PropertyClass2 referenceType, string condition)
=> props2.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
private static bool HasValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition)
=> props3.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
PropertyClass1 propertyClass1 = new()
{
Item1 = "prop1_item1",
@ -123,6 +139,8 @@ namespace NamingTemplateTests
[DataRow("<ifc1-><ifc3-><item1><ifc2-><item4><-ifc2><item3_2><-ifc3><-ifc1>", "prop1_item1prop3_item2", 3)]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "", 3)]
[DataRow("<!ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "prop1_item1prop2_item4prop3_item2", 3)]
[DataRow("<!has1 null_1-><has2 item1-><has3 item3_2-><item1><item4><item3_2><-has3><-has2><-has1>", "prop1_item1prop2_item4prop3_item2", 3)]
[DataRow("<!has1 null_1->null_1 is null, <-has1><has2 item1-><item1><-has2><has3 item3_2-><item3_2><-has3>", "null_1 is null, prop1_item1prop3_item2", 2)]
public void test(string inStr, string outStr, int numTags)
{
var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 });
@ -136,8 +154,63 @@ namespace NamingTemplateTests
templateText.Should().Be(outStr);
}
[TestMethod]
[DataRow("<has1->true<-has1>", "" )]
[DataRow("<has2->true<-has2>", "" )]
[DataRow("<has3->true<-has3>", "" )]
[DataRow("<has4->true<-has4>", "<has4->true<-has4>")]
[DataRow("<has1 null_1->true<-has1>", "")]
[DataRow("<has2 null_2->true<-has2>", "")]
[DataRow("<has3 null_3->true<-has3>", "")]
[DataRow("<!has1 null_1->true<-has1>", "true")]
[DataRow("<!has2 null_2->true<-has2>", "true")]
[DataRow("<!has3 null_3->true<-has3>", "true")]
[DataRow("<has1 item1->true<-has1>", "true")]
[DataRow("<has2 item1->true<-has2>", "true")]
[DataRow("<has3 item3_1->true<-has3>", "true")]
[DataRow("<!has1 item1->true<-has1>", "")]
[DataRow("<!has2 item1->true<-has2>", "")]
[DataRow("<!has3 item3_1->true<-has3>", "")]
[DataRow("<has3 item3_1 ->true<-has3>", "true")]
public void Has_test(string inStr, string outStr)
{
var template = NamingTemplate.Parse(inStr, [props1, props2, props3, conditional1, conditional2, conditional3]);
template.Warnings.Should().HaveCount(1);
template.Errors.Should().HaveCount(0);
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
templateText.Should().Be(outStr);
}
[TestMethod]
[DataRow("<has3item3_1->true<-has3>", "<has3item3_1->true")]
[DataRow("< has3 item3_1->true<-has3>", "< has3 item3_1->true")]
[DataRow("<has3 item3_1- >true<-has3>", "<has3 item3_1- >true")]
[DataRow("<has3 item3_1 >true<-has3>", "<has3 item3_1 >true")]
[DataRow("<has3 item3_1>true<-has3>", "<has3 item3_1>true")]
[DataRow("<has3 item3_1->true<- has3>", "true<- has3>")]
[DataRow("<has3 item3_1->true< has3>", "true< has3>")]
[DataRow("<has3 item3_1->true<!has3>", "true<!has3>")]
[DataRow("<has3 item3_1->true<has3>", "true<has3>")]
[DataRow("<has3 item3_1->true<has3 >", "true<has3 >")]
[DataRow("<has3 item3_1->true< -has3>", "true< -has3>")]
public void Has_invalid(string inStr, string outStr)
{
var template = NamingTemplate.Parse(inStr, [props1, props2, props3, conditional1, conditional2, conditional3]);
template.Warnings.Should().HaveCount(2);
template.Errors.Should().HaveCount(0);
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
templateText.Should().Be(outStr);
}
[TestMethod]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new string[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })]
[DataRow("<has2-><has1-><has3-><item1><item4><item3_2><-has3><-has1><has2->", new string[] { "Missing <-has2> closing conditional.", "Missing <-has2> closing conditional." })]
[DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Should use tags. Eg: <title>" })]
[DataRow("<ifc1-><ifc3-><item1><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional." })]
[DataRow("<ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional.", "Should use tags. Eg: <title>" })]

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AaxDecrypter;
using AssertionHelper;
using FileManager;
using FileManager.NamingTemplate;
@ -52,8 +53,13 @@ namespace TemplatesTests
BitRate = 128,
SampleRate = 44100,
Channels = 2,
Language = "English"
};
Language = "English",
Subtitle = "An Audible Original Drama",
TitleWithSubtitle = "A Study in Scarlet: An Audible Original Drama",
Codec = "AAC-LC",
FileVersion = "1.0",
LibationVersion = "1.0.0",
};
}
[TestClass]
@ -373,6 +379,55 @@ namespace TemplatesTests
.Should().Be(expected);
}
[TestMethod]
[DataRow("<has id->true<-has>", "true")]
[DataRow("<has title->true<-has>", "true")]
[DataRow("<has title short->true<-has>", "true")]
[DataRow("<has audible title->true<-has>", "true")]
[DataRow("<has audible subtitle->true<-has>", "true")]
[DataRow("<has author->true<-has>", "true")]
[DataRow("<has first author->true<-has>", "true")]
[DataRow("<has narrator->true<-has>", "true")]
[DataRow("<has first narrator->true<-has>", "true")]
[DataRow("<has series->true<-has>", "true")]
[DataRow("<has first series->true<-has>", "true")]
[DataRow("<has series#->true<-has>", "true")]
[DataRow("<has bitrate->true<-has>", "true")]
[DataRow("<has samplerate->true<-has>", "true")]
[DataRow("<has channels->true<-has>", "true")]
[DataRow("<has codec->true<-has>", "true")]
[DataRow("<has file version->true<-has>", "true")]
[DataRow("<has libation version->true<-has>", "true")]
[DataRow("<has account->true<-has>", "true")]
[DataRow("<has account nickname->true<-has>", "true")]
[DataRow("<has locale->true<-has>", "true")]
[DataRow("<has year->true<-has>", "true")]
[DataRow("<has language->true<-has>", "true")]
[DataRow("<has language short->true<-has>", "true")]
[DataRow("<has file date->true<-has>", "true")]
[DataRow("<has pub date->true<-has>", "true")]
[DataRow("<has date added->true<-has>", "true")]
[DataRow("<has ch count->true<-has>", "true")]
[DataRow("<has ch title->true<-has>", "true")]
[DataRow("<has ch#->true<-has>", "true")]
[DataRow("<has ch# 0->true<-has>", "true")]
[DataRow("<has FAKE->true<-has>", "")]
public void HasValue_test(string template, string expected)
{
var bookDto = GetLibraryBook();
var multiDto = new MultiConvertFileProperties
{
PartsPosition = 1,
PartsTotal = 2,
Title = bookDto.Title,
};
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(bookDto, multiDto, "", "", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[TestMethod]
[DataRow("<series>", "Series A, Series B, Series C, Series D")]
@ -418,6 +473,7 @@ namespace TemplatesTests
[DataRow("<series#[F2]>", " f1g ", "f1.00g")]
[DataRow("<series#[]>", "1", "1")]
[DataRow("<series#>", "1", "1")]
[DataRow("<series#>", " 1 6 ", "1 6")]
public void SeriesOrder_formatters(string template, string seriesOrder, string expected)
{
var bookDto = GetLibraryBook();