Skip to content
Merged
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ phase plan these entries follow.

## [Unreleased]

### Added

- **Offstream can tell Spotify to move on from a track it already has.** Keeping the file on disk
was only ever half the answer: Offstream declined to record the song and Spotify played it to
nobody for three minutes, so working through a playlist that is mostly recorded meant sitting
through the parts that already are. "Also tell Spotify to move on", beside the existing-file
setting on the Advanced page, sends a skip through the Windows media transport controls — the
same command as the keyboard's next-track key, so it needs no account, no scope and no Premium
subscription, and it reaches Spotify while it is minimised to the tray. Off by default, greyed
out under the two policies that record the file again, and asked exactly once per track, because
Spotify goes on reporting the outgoing song for a moment after it takes the command and a second
skip would land on a song nobody had recorded. It is asked twice per track, though: once
immediately, and again when the metadata lookup lands, since a template built on `{album}`,
`{year}` or `{track}` names a different file before the lookup and would otherwise never match
anything already in the library. The song already under way when recording starts is never
skipped — pressing record does not mean "start rearranging what is playing", and the media
session reports the *previous* track for a few hundred milliseconds when playback begins from a
stopped Spotify, so acting on the first thing a session sees would fire the command at the song
the user had just started. It also stops after fifty tracks in a row and says so: a queue
Offstream already has, left on repeat, has no other reason to ever stop skipping. Recording
something new puts the budget back.

### Fixed

- **A recording that is thrown away no longer keeps looking its track up.** Resume Spotify on a
Expand Down
1 change: 1 addition & 0 deletions docs/MODERNIZATION-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,7 @@ error paths and the scope list.
- **A failing SMTC is treated as a silent one.** It is a system service Offstream does not control, so a fault costs the better metadata and never the recording — logged once per transition rather than per poll, since this runs several times a second. Cancellation is exempt: that is the session stopping, and it propagates.
- **Advertisements are detected on the same two rules as the title path** — the placeholder title, or playing with no artist attached — because Spotify announces them the same way to both. A paused session is never an ad: the placeholder lingers after playback stops, and treating it as one would suppress the next real track.
- **The WinRT call is kept free of decisions.** Everything decidable lives behind `ISmtcSessions` in pure code; `WindowsSmtcSessions` only talks to the system. The session manager is fetched once and kept — `RequestAsync` is a cross-process call and this is polled several times a second. Spotify is matched by a loose app-id substring, which covers both the desktop (`Spotify.exe`) and Store (`SpotifyAB.SpotifyMusic_…!Spotify`) identities.
- **The media session is also a way to talk *to* Spotify, and that turned "keep the one on disk" into a complete answer (2026-08-28).** Declining to record a track the user already has left Spotify playing it to nobody for three minutes; SMTC carries the same skip the keyboard's next-track key sends, so Offstream can move the queue on. `IPlaybackControl` is deliberately a second interface rather than a method on `ISmtcSessions` — reading what is playing and changing what plays are different privileges, and a session handed no implementation simply never skips. **The Web API was the wrong route** even though `POST /me/player/next` exists: it wants `user-modify-playback-state`, a signed-in account and a Premium subscription, and a third scope on a consent screen this project holds to two. Three things it had to get right, each a distinct failure: the ask is **once per track, marked before the call** rather than compared afterwards, because Spotify keeps reporting the outgoing track for a moment after it accepts — the same staleness that costs a new recording its first few hundred milliseconds — and a per-observation decision skips twice, the second one past a song nobody has recorded; it happens at **two checkpoints**, since the early existing-file check is the one the finding below shows cannot see an enriched path, so wired only there it would never fire for exactly the libraries organised well enough to want it; it is **armed only from the second track a session admits**, because pressing record is not an instruction to rearrange what is already playing — and because the media session's opening report is the *previous* track with the play state already true, so a session acting on the first thing it sees fires the command at the song the user has just started; and it **stops after fifty in a row**, because a fully-recorded queue on repeat has no other terminating condition, with the budget restored only by a recording actually reaching the library — a recording merely *starting* is not evidence, or a template that matches only after enrichment resets the cap on every track and never reaches it.
- **Long paths go through the `\\?\` prefix, because Offstream does not write the file — ffmpeg does.** A `longPathAware` manifest plus the machine's `LongPathsEnabled` switch opts *a process* in, and ffmpeg is a separate process with its own manifest and no interest in ours. The prefix travels with the path through `ArgumentList` into whatever ffmpeg hands to `CreateFile`. Verified rather than assumed, since the feature rests on it: ffmpeg 8.1 wrote an MP3 to a 298-character destination through a prefixed path, on a machine with the registry switch **off**.
- **The prefix disables normalisation, which is the trap.** Windows stops resolving `.` and `..`, stops converting `/` to `\`, and stops trimming trailing dots and spaces — a merely untidy path becomes one the filesystem rejects. So it is applied only to fully-qualified paths, normalised first, and only when the path is long enough to need it.
- **Extended paths raise the total length, never the component length.** The 260 budget divided across template levels became a 32767 budget dividing into per-level allowances of thousands of characters, which renders folder names NTFS refuses outright — trading "truncated at 260" for "cannot be written at all". The per-level allowance is clamped to 255. This is the part that looks like it goes away and does not.
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 @@ -325,6 +325,12 @@
<data name="AdvancedExistingDuplicate" xml:space="preserve">
<value>Enregistrer le nouveau à côté</value>
</data>
<data name="AdvancedSkipAlreadyRecorded" xml:space="preserve">
<value>Demander aussi à Spotify de passer au suivant</value>
</data>
<data name="AdvancedSkipAlreadyRecordedHint" xml:space="preserve">
<value>Uniquement lorsque le réglage ci-dessus conserve le fichier sur le disque.</value>
</data>
<data name="AdvancedDetectionSection" xml:space="preserve">
<value>Détection</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 @@ -417,6 +417,14 @@
<value>Save the new one alongside it</value>
<comment>Existing-file policy: keep both, numbering the newcomer.</comment>
</data>
<data name="AdvancedSkipAlreadyRecorded" xml:space="preserve">
<value>Also tell Spotify to move on</value>
<comment>Checkbox beside the existing-file policy: send a skip-next command when the track playing is already in the library.</comment>
</data>
<data name="AdvancedSkipAlreadyRecordedHint" xml:space="preserve">
<value>Only while the setting above keeps the file on disk.</value>
<comment>Explains why the skip checkbox is greyed out under the Overwrite and Duplicate policies.</comment>
</data>
<data name="AdvancedDetectionSection" xml:space="preserve">
<value>Detection</value>
<comment>Advanced page: heading over what counts as a track worth recording. The predecessor called these "spy options"; plan §0 renames them.</comment>
Expand Down
10 changes: 8 additions & 2 deletions src/Offstream.App/Services/RecordingSessionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ public RecordingSession Create(OffstreamSettings settings, IProgress<RecordingPr
// the media session survives that, and hands over separate artist and title fields
// rather than one string to split. See PreferredTrackSource for what "prefers" means
// and when it hands back.
// One instance, used twice: it caches the session manager, and RequestAsync is a
// cross-process call worth making once. Reading tracks and sending a skip both go
// through the same Spotify session it has already found.
var mediaSessions = new WindowsSmtcSessions();

var detector = new PreferredTrackSource(
new SmtcTrackSource(new WindowsSmtcSessions()),
new SmtcTrackSource(mediaSessions),
new SpotifyTrackDetector(_processManager, new SpotifyPlaybackProbe(_processManager)));

return new RecordingSession(
Expand All @@ -97,7 +102,8 @@ public RecordingSession Create(OffstreamSettings settings, IProgress<RecordingPr
CreateEncoder(settings),
_fileSystem,
CreateEnricher(settings),
progress);
progress,
playback: mediaSessions);
}
catch
{
Expand Down
18 changes: 18 additions & 0 deletions src/Offstream.App/ViewModels/AdvancedViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,12 @@ public sealed partial class AdvancedViewModel : ObservableValidator
private string _fileCounter = "1";

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSkipAlreadyRecorded))]
private ExistingFilePolicy _existingFilePolicy;

[ObservableProperty]
private bool _skipAlreadyRecordedTracks;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TemplatePreview))]
private bool _isTimerEnabled;
Expand Down Expand Up @@ -142,6 +146,16 @@ public AdvancedViewModel(SettingsDocument document, IFileSystem fileSystem)
/// <summary>What to do when the destination file already exists.</summary>
public IReadOnlyList<ChoiceOption<ExistingFilePolicy>> Policies { get; }

/// <summary>
/// Whether skipping past a recorded track can do anything under the chosen policy.
/// </summary>
/// <remarks>
/// Overwrite and Duplicate both write the file again, so there is nothing to skip past. The
/// checkbox greys out rather than disappearing, so the setting is still findable — and the
/// core reads the two together anyway, since a hand-edited settings file can disagree.
/// </remarks>
public bool CanSkipAlreadyRecorded => ExistingFilePolicy == ExistingFilePolicy.Skip;

/// <summary>UI languages, plus following Windows.</summary>
public IReadOnlyList<ChoiceOption<string?>> Languages { get; }

Expand Down Expand Up @@ -223,6 +237,7 @@ private void Load()
Template = settings.Output.Template;
FileCounter = settings.Output.CurrentFileCounter.ToString(CultureInfo.CurrentCulture);
ExistingFilePolicy = settings.Output.ExistingFilePolicy;
SkipAlreadyRecordedTracks = settings.Output.SkipAlreadyRecordedTracks;
MuteAds = settings.Recording.MuteAds;
RecordEverything = settings.Recording.RecordEverything;
RecordAds = settings.Recording.RecordAds;
Expand Down Expand Up @@ -318,6 +333,8 @@ .. FileNameTemplate.KnownTokens.Select(token =>

partial void OnExistingFilePolicyChanged(ExistingFilePolicy value) => Persist();

partial void OnSkipAlreadyRecordedTracksChanged(bool value) => Persist();

partial void OnIsTimerEnabledChanged(bool value) => Persist();

partial void OnTimerChanged(string value) => Persist();
Expand Down Expand Up @@ -355,6 +372,7 @@ private void Persist()
{
Template = Template.Trim(),
ExistingFilePolicy = ExistingFilePolicy,
SkipAlreadyRecordedTracks = SkipAlreadyRecordedTracks,
CurrentFileCounter = int.Parse(FileCounter, CultureInfo.CurrentCulture),
},
Recording = settings.Recording with
Expand Down
73 changes: 66 additions & 7 deletions src/Offstream.App/Views/Pages/AdvancedPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,19 @@
<ContentControl Content="{x:Static res:Strings.AdvancedNamingSection}" Style="{StaticResource OffstreamGroupHeaderStyle}" />

<Grid>
<!--
The right-hand column is Auto, not a second star, and that is load-bearing
now that something sits beside the existing-file dropdown. The counter
field there is hidden for every template that does not use {count} — but a
star column reserves its half of the card whether the thing in it is
visible or not, so the left column had 450 units to fit a dropdown, a
switch and its label into, and the label was cut off mid-word. Auto gives a
collapsed field no width at all and a visible one exactly what it asks for.
-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
Expand Down Expand Up @@ -236,20 +245,70 @@

<StackPanel Grid.Row="1" Grid.Column="0" Style="{StaticResource Field}">
<TextBlock Style="{StaticResource OffstreamFieldLabelStyle}" Text="{x:Static res:Strings.AdvancedExistingLabel}" />
<ComboBox AutomationProperties.AutomationId="AdvancedExistingFilePolicy"
AutomationProperties.Name="{x:Static res:Strings.AdvancedExistingLabel}"
DisplayMemberPath="Name"
ItemsSource="{Binding Policies, Mode=OneTime}"
SelectedValue="{Binding ExistingFilePolicy, Mode=TwoWay}"
SelectedValuePath="Value" />

<!--
The switch sits beside the dropdown rather than under it, for two
reasons. It qualifies that one choice — it only does anything while
the policy is the one that keeps the file on disk — so it belongs on
the same line rather than reading as the next setting down. And this
card has no vertical room to give: the page has to fit the window's
minimum height without scrolling, and a second row here pushed the
bottom of the one below off the end of the window.

Disabled rather than hidden under the other two policies, as with the
advertisements switch — a setting that vanishes looks like one that
never existed.
-->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>

<ComboBox Grid.Column="0"
Width="240"
AutomationProperties.AutomationId="AdvancedExistingFilePolicy"
AutomationProperties.Name="{x:Static res:Strings.AdvancedExistingLabel}"
DisplayMemberPath="Name"
ItemsSource="{Binding Policies, Mode=OneTime}"
SelectedValue="{Binding ExistingFilePolicy, Mode=TwoWay}"
SelectedValuePath="Value" />

<StackPanel Grid.Column="1"
Margin="16,0,0,0"
VerticalAlignment="Center"
IsEnabled="{Binding CanSkipAlreadyRecorded, Mode=OneWay}"
Orientation="Horizontal">
<ui:ToggleSwitch VerticalAlignment="Center"
AutomationProperties.AutomationId="AdvancedSkipAlreadyRecorded"
AutomationProperties.Name="{x:Static res:Strings.AdvancedSkipAlreadyRecorded}"
IsChecked="{Binding SkipAlreadyRecordedTracks, Mode=TwoWay}" />

<!--
Ellipsised with a tooltip, like every other switch label: the
room left beside a dropdown is whatever the window has spare,
and wrapping here would put the second row back.
-->
<StackPanel Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock Style="{StaticResource SwitchRowLabel}" Text="{x:Static res:Strings.AdvancedSkipAlreadyRecorded}" />
<TextBlock Style="{StaticResource SwitchRowHint}" Text="{x:Static res:Strings.AdvancedSkipAlreadyRecordedHint}" />
</StackPanel>
</StackPanel>
</Grid>
</StackPanel>

<!--
Only shown when the template actually uses {count}. A "next number" field
beside a template that never numbers anything is a setting with no effect.

Its width is pinned because the column above is Auto: the validation
message wraps, and a wrapping TextBlock asks an Auto column for its whole
unwrapped length, which would take back the room the switch beside the
dropdown needs.
-->
<StackPanel Grid.Row="1"
Grid.Column="2"
Width="260"
Style="{StaticResource Field}"
Visibility="{Binding UsesCounter, Mode=OneWay, Converter={StaticResource BooleanToVisibility}}">
<TextBlock Style="{StaticResource OffstreamFieldLabelStyle}" Text="{x:Static res:Strings.AdvancedCounterLabel}" />
Expand Down
Loading
Loading