Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,15 @@ phase plan these entries follow.
and why — with three commands for checking a finished recording, because the failure this
catches produces a file that plays perfectly and measures wrong.

- **The Metadata page now shows what a file's own bitrate and codec actually are, not just its
tags.** Repairing a library's metadata never said anything about the audio underneath it — a
file downloaded at 96 kbps and a lossless rip looked identical on the one page built to review a
folder of them. Each row now carries a line under its name — `MP3 · 320 kbps`, `FLAC ·
Lossless` — read straight from the file's own container properties, so it costs one more file
open per row and nothing else: no ffprobe, no decoding, no spectral analysis, no guessing beyond
what the header already claims. Only the one tier worth a second look, a lossy file under 128
kbps, gets the caution colour; everything else is background information.

### Fixed

- **One notch of the wheel jumped three tracks in the session list.** The list of what has been
Expand Down
6 changes: 6 additions & 0 deletions src/Offstream.App/Resources/Strings.fr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,12 @@
<data name="MetadataWillChange" xml:space="preserve">
<value>Sera modifié</value>
</data>
<data name="MetadataQualityLossless" xml:space="preserve">
<value>{0} · Sans perte</value>
</data>
<data name="MetadataQualityBitrate" xml:space="preserve">
<value>{0} · {1} kbit/s</value>
</data>
<data name="MetadataFieldTitle" xml:space="preserve">
<value>Titre</value>
</data>
Expand Down
8 changes: 8 additions & 0 deletions src/Offstream.App/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,14 @@
<value>Will change</value>
<comment>Marks a row whose save would alter the file, so those rows can be found without opening each one.</comment>
</data>
<data name="MetadataQualityLossless" xml:space="preserve">
<value>{0} · Lossless</value>
<comment>{0} is the codec name, e.g. "FLAC". Shown under a row for a lossless file.</comment>
</data>
<data name="MetadataQualityBitrate" xml:space="preserve">
<value>{0} · {1} kbps</value>
<comment>{0} is the codec name, e.g. "MP3"; {1} is the bitrate the file reports.</comment>
</data>
<data name="MetadataFieldTitle" xml:space="preserve">
<value>Title</value>
</data>
Expand Down
1 change: 1 addition & 0 deletions src/Offstream.App/Services/AppServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public static IServiceCollection AddOffstream(
// independent of the recording pipeline's: a scanner over the file system, a TagLib#
// store, and a provider chain rebuilt per run so signing in takes effect immediately.
services.AddSingleton<ILibraryTagStore, TagLibTagStore>();
services.AddSingleton<IAudioQualityReader, TagLibAudioQualityReader>();
services.AddSingleton<ILibraryScanner, LibraryScanner>();
services.AddSingleton<ILibraryTagWriter, LibraryTagWriter>();
services.AddSingleton<ILibraryMetadataChain, LibraryMetadataChain>();
Expand Down
60 changes: 60 additions & 0 deletions src/Offstream.App/ViewModels/LibraryTrackViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.IO;
using System.Text;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using Offstream.App.Resources;
Expand All @@ -27,6 +28,12 @@ namespace Offstream.App.ViewModels;
/// </remarks>
public sealed partial class LibraryTrackViewModel : ObservableValidator
{
private static readonly CompositeFormat QualityLosslessFormat =
CompositeFormat.Parse(Strings.MetadataQualityLossless);

private static readonly CompositeFormat QualityBitrateFormat =
CompositeFormat.Parse(Strings.MetadataQualityBitrate);

private readonly LibraryTrack _track;
private readonly string? _existingTitle;
private readonly string? _existingArtist;
Expand Down Expand Up @@ -87,6 +94,9 @@ public LibraryTrackViewModel(LibraryTrack track)

CoverArt = LoadCoverArt(track.Existing.AlbumArtImage);
ExistingCoverArt = CoverArt;

QualityLabel = FormatQuality(track.Quality, FileName);
IsQualityLow = track.Quality.Tier == AudioQualityTier.Low;
}

/// <summary>The file's own name, which is what identifies the row.</summary>
Expand Down Expand Up @@ -133,6 +143,21 @@ public LibraryTrackViewModel(LibraryTrack track)
/// <inheritdoc cref="CurrentTitle" />
public string CurrentCopyright { get; }

/// <summary>
/// What the file's own audio properties say, e.g. "MP3 · 128 kbps" or "FLAC · Lossless", or
/// null when the file reported nothing usable.
/// </summary>
public string? QualityLabel { get; }

/// <summary>Whether <see cref="QualityLabel"/> has anything to show.</summary>
public bool HasQuality => QualityLabel is not null;

/// <summary>
/// Whether the file is lossy at under 128 kbps — audibly compressed on most material, and the
/// one tier worth calling out rather than just stating.
/// </summary>
public bool IsQualityLow { get; }

/// <summary>The scanned track this row edits.</summary>
internal LibraryTrack Track => _track;

Expand Down Expand Up @@ -538,6 +563,41 @@ public void SeedMatchQuery()
MatchQuery = string.Join(' ', new[] { Artist, Title }.Where(part => !string.IsNullOrWhiteSpace(part)));
}

/// <summary>
/// Turns a container-property read into the one line the row shows. Deliberately just a
/// codec name and a number — no spectral analysis, no transcode detection, just what the
/// file's own header already says.
/// </summary>
private static string? FormatQuality(AudioQuality quality, string fileName)
{
var codec = CodecName(System.IO.Path.GetExtension(fileName), quality.Tier);

if (quality.Tier == AudioQualityTier.Lossless)
{
return string.Format(CultureInfo.CurrentCulture, QualityLosslessFormat, codec);
}

return quality.BitrateKbps is > 0
? string.Format(CultureInfo.CurrentCulture, QualityBitrateFormat, codec, quality.BitrateKbps)
: null;
}

/// <summary>A short codec name from the file's extension, which the scanner already filtered on.</summary>
/// <remarks>
/// <c>.m4a</c> is ambiguous by extension alone — Offstream only ever writes AAC into it, but a
/// file from elsewhere can be ALAC. <see cref="AudioQuality.Tier"/> already tells the two
/// apart, so a lossless <c>.m4a</c> is labelled ALAC rather than guessed wrong.
/// </remarks>
private static string CodecName(string extension, AudioQualityTier tier) => extension.ToUpperInvariant() switch
{
".M4A" => tier == AudioQualityTier.Lossless ? "ALAC" : "AAC",
".MP3" => "MP3",
".FLAC" => "FLAC",
".OPUS" => "Opus",
".OGG" => "Vorbis",
_ => extension.TrimStart('.').ToUpperInvariant(),
};

private static string Or(string? value, string fallback) =>
string.IsNullOrWhiteSpace(value) ? fallback : value;

Expand Down
22 changes: 22 additions & 0 deletions src/Offstream.App/Views/Pages/MetadataPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />

<!--
What the file's own header says about its audio — a codec name and a
bitrate, or "Lossless". Nothing decoded, nothing guessed: exactly what
TagLib# read off the container, so it costs one more file open per row
and no spectral analysis. Only a lossy file under 128 kbps gets the
caution colour; everything else is background information, not a warning.
-->
<TextBlock Text="{Binding QualityLabel, Mode=OneTime}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{Binding HasQuality, Converter={StaticResource BooleanToVisibility}}">
<TextBlock.Style>
<Style BasedOn="{StaticResource OffstreamHintStyle}" TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsQualityLow}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource SystemFillColorCautionBrush}" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>

<!--
The reason a row failed, wrapped rather than trimmed. It is the one
piece of text on the page the user can act on — a locked file, an
Expand Down
100 changes: 100 additions & 0 deletions src/Offstream.Core/Metadata/Library/AudioQuality.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace Offstream.Core.Metadata.Library;

/// <summary>A rough, at-a-glance read on how good a file's audio actually is.</summary>
public enum AudioQualityTier
{
/// <summary>The file could not be opened, or reported no audio properties at all.</summary>
Unknown,

/// <summary>Lossy, under 128 kbps — audibly compressed on most material.</summary>
Low,

/// <summary>Lossy, 128–255 kbps — the range most lossy libraries live in.</summary>
Medium,

/// <summary>Lossy, 256 kbps or higher — as good as a lossy codec gets.</summary>
High,

/// <summary>A lossless codec (FLAC, ALAC, WAV, …).</summary>
Lossless,
}

/// <summary>
/// What a file's own container reports about its audio, read once at scan time.
/// </summary>
/// <remarks>
/// Deliberately shallow: this is a container-property read, not a spectral analysis of the decoded
/// samples. It answers "what does the file claim" — which is enough to flag an obviously low
/// bitrate or confirm a lossless codec — and not "is that claim actually true", which would need
/// decoding the audio and is a different, heavier feature.
/// </remarks>
public readonly record struct AudioQuality(int? BitrateKbps, int SampleRateHz, int BitsPerSample)
{
/// <summary>No audio properties available, e.g. because the file could not be opened.</summary>
public static AudioQuality Unknown => default;

/// <summary>Whether the file reported anything at all.</summary>
public bool IsKnown => SampleRateHz > 0;

/// <summary>
/// Whether the container reports a bit depth. TagLib# only populates this for codecs that
/// have a fixed sample bit depth — FLAC, ALAC, WAV, and the like — and reports zero for MP3,
/// AAC, Opus and Vorbis, none of which have one. That makes it a reliable, free lossless flag
/// with no need to inspect the codec name.
/// </summary>
public bool IsLossless => BitsPerSample > 0;

/// <summary>The tier a quality badge would show.</summary>
public AudioQualityTier Tier =>
!IsKnown ? AudioQualityTier.Unknown
: IsLossless ? AudioQualityTier.Lossless
: BitrateKbps switch
{
>= 256 => AudioQualityTier.High,
>= 128 => AudioQualityTier.Medium,
_ => AudioQualityTier.Low,
};
}

/// <summary>Reads a file's own audio properties — bitrate, sample rate, bit depth.</summary>
public interface IAudioQualityReader
{
/// <summary>
/// Reads <paramref name="path"/>'s audio properties, or <see cref="AudioQuality.Unknown"/> if
/// the file could not be opened.
/// </summary>
AudioQuality Read(string path);
}

/// <summary>
/// The TagLib# implementation. Opens the file a second time, separately from
/// <see cref="ILibraryTagStore"/> — the two answer different questions from the same file, and
/// keeping them apart is what lets each be tested without the other.
/// </summary>
public sealed class TagLibAudioQualityReader : IAudioQualityReader
{
/// <inheritdoc />
public AudioQuality Read(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);

try
{
using var file = TagLib.File.Create(path);
var properties = file.Properties;

return new AudioQuality(
properties.AudioBitrate > 0 ? properties.AudioBitrate : null,
properties.AudioSampleRate,
properties.BitsPerSample);
}
catch (Exception ex) when (
ex is TagLib.CorruptFileException
or TagLib.UnsupportedFormatException
or IOException
or UnauthorizedAccessException)
{
return AudioQuality.Unknown;
}
}
}
7 changes: 5 additions & 2 deletions src/Offstream.Core/Metadata/Library/LibraryScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ public interface ILibraryScanner
/// way is what lets the interesting cases — a nested folder, a file with no tags, a file that
/// cannot be opened — be tested without writing a valid MP3 for each one.
/// </remarks>
public sealed class LibraryScanner(IFileSystem fileSystem, ILibraryTagStore tagStore) : ILibraryScanner
public sealed class LibraryScanner(
IFileSystem fileSystem,
ILibraryTagStore tagStore,
IAudioQualityReader qualityReader) : ILibraryScanner
{
/// <summary>
/// Containers with a tag format worth offering to edit.
Expand Down Expand Up @@ -90,7 +93,7 @@ private LibraryScan Scan(string directory, CancellationToken cancellationToken)

try
{
tracks.Add(new LibraryTrack(path, ReadOrInferTags(path)));
tracks.Add(new LibraryTrack(path, ReadOrInferTags(path), qualityReader.Read(path)));
}
catch (LibraryTagException ex)
{
Expand Down
11 changes: 10 additions & 1 deletion src/Offstream.Core/Metadata/Library/LibraryTrack.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ public sealed class LibraryTrack
/// <summary>Creates a scanned track from what the file already carries.</summary>
/// <param name="path">Full path to the audio file.</param>
/// <param name="existing">Tags read from the file, or parsed from its name when it has none.</param>
public LibraryTrack(string path, Track existing)
/// <param name="quality">
/// What the file's own container reports about its audio. Defaults to
/// <see cref="AudioQuality.Unknown"/> so every existing caller that has no quality to offer —
/// chiefly tests — keeps compiling unchanged.
/// </param>
public LibraryTrack(string path, Track existing, AudioQuality quality = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(existing);

Path = path;
FileName = System.IO.Path.GetFileName(path);
Existing = existing;
Quality = quality;

// A copy, not the same instance: the suggestion is enriched in place and the row needs
// the original beside it to show what would change. Sharing one Track would make every
Expand All @@ -45,6 +51,9 @@ public LibraryTrack(string path, Track existing)
/// <summary>What the file carries today. Never mutated.</summary>
public Track Existing { get; }

/// <summary>What the file's own container reports about its audio.</summary>
public AudioQuality Quality { get; }

/// <summary>What would be written, once a provider or the user has had a say.</summary>
public Track Suggested { get; }

Expand Down
33 changes: 33 additions & 0 deletions tests/Offstream.Core.Tests/Metadata/Library/AudioQualityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Offstream.Core.Metadata.Library;
using Xunit;

namespace Offstream.Core.Tests.Metadata.Library;

/// <summary>The tier a badge would show, from nothing but the numbers a container reports.</summary>
public sealed class AudioQualityTests
{
/// <summary>No sample rate at all means the file said nothing worth showing.</summary>
[Fact]
public void Tier_OfTheUnknownValueIsUnknown() =>
Assert.Equal(AudioQualityTier.Unknown, AudioQuality.Unknown.Tier);

/// <summary>A bit depth is what marks a codec lossless, whatever its bitrate happens to be.</summary>
[Fact]
public void Tier_OfAnythingWithABitDepthIsLossless() =>
Assert.Equal(AudioQualityTier.Lossless, new AudioQuality(BitrateKbps: 1411, SampleRateHz: 44100, BitsPerSample: 16).Tier);

[Theory]
[InlineData(96, AudioQualityTier.Low)]
[InlineData(127, AudioQualityTier.Low)]
[InlineData(128, AudioQualityTier.Medium)]
[InlineData(255, AudioQualityTier.Medium)]
[InlineData(256, AudioQualityTier.High)]
[InlineData(320, AudioQualityTier.High)]
public void Tier_OfALossyBitrateFollowsTheThresholds(int bitrateKbps, AudioQualityTier expected) =>
Assert.Equal(expected, new AudioQuality(bitrateKbps, SampleRateHz: 44100, BitsPerSample: 0).Tier);

/// <summary>A known sample rate with no bitrate at all still resolves rather than throwing.</summary>
[Fact]
public void Tier_OfAKnownFileWithNoBitrateIsLow() =>
Assert.Equal(AudioQualityTier.Low, new AudioQuality(BitrateKbps: null, SampleRateHz: 44100, BitsPerSample: 0).Tier);
}
Loading
Loading