Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5fc9487c | ||
|
|
8ad727d870 | ||
|
|
a2ffbb3efe |
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.0.5.0</Version>
|
<Version>0.0.6.0</Version>
|
||||||
<AssemblyVersion>0.0.5.0</AssemblyVersion>
|
<AssemblyVersion>0.0.6.0</AssemblyVersion>
|
||||||
<FileVersion>0.0.5.0</FileVersion>
|
<FileVersion>0.0.6.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public class WatchedStateSyncTests
|
|||||||
|
|
||||||
public Mock<IGroupService> Groups { get; } = new();
|
public Mock<IGroupService> Groups { get; } = new();
|
||||||
|
|
||||||
public List<(User Member, bool Played, int PlayCount)> Saves { get; } = new();
|
public List<Save> Saves { get; } = new();
|
||||||
|
|
||||||
public WatchedStateSyncService Service { get; private set; } = null!;
|
public WatchedStateSyncService Service { get; private set; } = null!;
|
||||||
|
|
||||||
@@ -34,7 +34,9 @@ public class WatchedStateSyncTests
|
|||||||
SharedGroup? group,
|
SharedGroup? group,
|
||||||
IReadOnlyList<User> members,
|
IReadOnlyList<User> members,
|
||||||
bool memberAlreadyPlayed = false,
|
bool memberAlreadyPlayed = false,
|
||||||
int memberPlayCount = 0)
|
int memberPlayCount = 0,
|
||||||
|
DateTime? memberLastPlayedDate = null,
|
||||||
|
long memberPositionTicks = 0)
|
||||||
{
|
{
|
||||||
var h = new Harness();
|
var h = new Harness();
|
||||||
|
|
||||||
@@ -46,7 +48,9 @@ public class WatchedStateSyncTests
|
|||||||
{
|
{
|
||||||
Key = "k",
|
Key = "k",
|
||||||
Played = memberAlreadyPlayed,
|
Played = memberAlreadyPlayed,
|
||||||
PlayCount = memberPlayCount
|
PlayCount = memberPlayCount,
|
||||||
|
LastPlayedDate = memberLastPlayedDate,
|
||||||
|
PlaybackPositionTicks = memberPositionTicks
|
||||||
});
|
});
|
||||||
|
|
||||||
h.UserData.Setup(m => m.SaveUserData(
|
h.UserData.Setup(m => m.SaveUserData(
|
||||||
@@ -56,7 +60,8 @@ public class WatchedStateSyncTests
|
|||||||
It.IsAny<UserDataSaveReason>(),
|
It.IsAny<UserDataSaveReason>(),
|
||||||
It.IsAny<CancellationToken>()))
|
It.IsAny<CancellationToken>()))
|
||||||
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>(
|
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>(
|
||||||
(u, _, d, _, _) => h.Saves.Add((u, d.Played, d.PlayCount)));
|
(u, _, d, _, _) => h.Saves.Add(
|
||||||
|
new Save(u, d.Played, d.PlayCount, d.LastPlayedDate, d.PlaybackPositionTicks)));
|
||||||
|
|
||||||
h.Service = new WatchedStateSyncService(
|
h.Service = new WatchedStateSyncService(
|
||||||
h.UserData.Object,
|
h.UserData.Object,
|
||||||
@@ -70,7 +75,7 @@ public class WatchedStateSyncTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raises UserDataSaved as the server would, by starting the service so it subscribes.
|
/// Raises UserDataSaved as the server would, by starting the service so it subscribes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Raise(Guid userId, bool played, UserDataSaveReason reason)
|
public void Raise(Guid userId, bool played, UserDataSaveReason reason, DateTime? lastPlayedDate = null)
|
||||||
{
|
{
|
||||||
Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
|
Service.StartAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
@@ -80,7 +85,7 @@ public class WatchedStateSyncTests
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Item = new Folder { Name = "Some Item" },
|
Item = new Folder { Name = "Some Item" },
|
||||||
UserData = new UserItemData { Key = "k", Played = played },
|
UserData = new UserItemData { Key = "k", Played = played, LastPlayedDate = lastPlayedDate },
|
||||||
SaveReason = reason
|
SaveReason = reason
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +93,14 @@ public class WatchedStateSyncTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A snapshot of one member row as it was handed to SaveUserData. The service reuses the
|
||||||
|
/// object it got from GetUserData, so the fields are copied rather than the reference kept.
|
||||||
|
/// </summary>
|
||||||
|
private sealed record Save(User Member, bool Played, int PlayCount, DateTime? LastPlayedDate, long PositionTicks);
|
||||||
|
|
||||||
|
private static readonly DateTime SharedWatchedAt = new(2026, 9, 18, 21, 30, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
private static User MakeUser(string name) => new(name, "Prov", "ResetProv");
|
||||||
|
|
||||||
private static SharedGroup MakeGroup(bool syncUnwatched = true, bool syncPlayCount = false)
|
private static SharedGroup MakeGroup(bool syncUnwatched = true, bool syncPlayCount = false)
|
||||||
@@ -126,11 +139,10 @@ public class WatchedStateSyncTests
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(UserDataSaveReason.PlaybackStart)]
|
[InlineData(UserDataSaveReason.PlaybackStart)]
|
||||||
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
|
||||||
[InlineData(UserDataSaveReason.UpdateUserRating)]
|
[InlineData(UserDataSaveReason.UpdateUserRating)]
|
||||||
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
|
public void IrrelevantSaveReasons_AreIgnored(UserDataSaveReason reason)
|
||||||
{
|
{
|
||||||
// UserDataSaved fires constantly during playback; only watched-state changes matter.
|
// Neither of these ever carries a watched-state change, whatever the flag says.
|
||||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||||
|
|
||||||
h.Raise(SharedId, true, reason);
|
h.Raise(SharedId, true, reason);
|
||||||
@@ -138,6 +150,100 @@ public class WatchedStateSyncTests
|
|||||||
Assert.Empty(h.Saves);
|
Assert.Empty(h.Saves);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||||
|
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||||
|
[InlineData(UserDataSaveReason.UpdateUserData)]
|
||||||
|
public void PlaybackDerivedReasons_SyncWatched(UserDataSaveReason reason)
|
||||||
|
{
|
||||||
|
// Progress crosses the completion threshold before the stop arrives, and some clients
|
||||||
|
// never send a stop at all, so the tick has to land on the progress tick too.
|
||||||
|
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||||
|
|
||||||
|
h.Raise(SharedId, true, reason);
|
||||||
|
|
||||||
|
Assert.Single(h.Saves);
|
||||||
|
Assert.True(h.Saves[0].Played);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||||
|
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||||
|
[InlineData(UserDataSaveReason.UpdateUserData)]
|
||||||
|
public void PlaybackDerivedReasons_NeverSyncUnwatched(UserDataSaveReason reason)
|
||||||
|
{
|
||||||
|
// PlaybackFinished fires on every stop, not just on completion, and on 10.11 PlaybackStart
|
||||||
|
// resets Played to false first. Stopping halfway through on the shared account must not
|
||||||
|
// clear what a member watched on their own - even with Sync unwatched on.
|
||||||
|
var h = Harness.Create(MakeGroup(syncUnwatched: true), [MakeUser("alice")], memberAlreadyPlayed: true);
|
||||||
|
|
||||||
|
h.Raise(SharedId, false, reason);
|
||||||
|
|
||||||
|
Assert.Empty(h.Saves);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Played_WritesWhatMarkPlayedWrites()
|
||||||
|
{
|
||||||
|
// Next Up is driven by LastPlayedDate on the member's own row, and a stale resume position
|
||||||
|
// would keep the item in Continue Watching, so the tick alone is not enough.
|
||||||
|
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberPositionTicks: 12_345);
|
||||||
|
|
||||||
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
|
||||||
|
|
||||||
|
var save = Assert.Single(h.Saves);
|
||||||
|
Assert.True(save.Played);
|
||||||
|
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
|
||||||
|
Assert.Equal(0, save.PositionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Played_FallsBackToNowWhenSharedAccountHasNoDate()
|
||||||
|
{
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
var h = Harness.Create(MakeGroup(), [MakeUser("alice")]);
|
||||||
|
|
||||||
|
h.Raise(SharedId, true, UserDataSaveReason.TogglePlayed);
|
||||||
|
|
||||||
|
var save = Assert.Single(h.Saves);
|
||||||
|
Assert.NotNull(save.LastPlayedDate);
|
||||||
|
Assert.InRange(save.LastPlayedDate!.Value, before, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Played_RepairsAnAlreadyTickedRowThatHasNoDate()
|
||||||
|
{
|
||||||
|
// Rows synced by earlier versions have the tick but no date. They are not "already in
|
||||||
|
// sync": without a date the member's Next Up never moves.
|
||||||
|
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true, memberLastPlayedDate: null);
|
||||||
|
|
||||||
|
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished, SharedWatchedAt);
|
||||||
|
|
||||||
|
var save = Assert.Single(h.Saves);
|
||||||
|
Assert.True(save.Played);
|
||||||
|
Assert.Equal(SharedWatchedAt, save.LastPlayedDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unwatched_WritesWhatMarkUnplayedWrites()
|
||||||
|
{
|
||||||
|
var h = Harness.Create(
|
||||||
|
MakeGroup(syncUnwatched: true),
|
||||||
|
[MakeUser("alice")],
|
||||||
|
memberAlreadyPlayed: true,
|
||||||
|
memberPlayCount: 3,
|
||||||
|
memberLastPlayedDate: SharedWatchedAt,
|
||||||
|
memberPositionTicks: 999);
|
||||||
|
|
||||||
|
h.Raise(SharedId, false, UserDataSaveReason.TogglePlayed);
|
||||||
|
|
||||||
|
var save = Assert.Single(h.Saves);
|
||||||
|
Assert.False(save.Played);
|
||||||
|
Assert.Null(save.LastPlayedDate);
|
||||||
|
Assert.Equal(0, save.PositionTicks);
|
||||||
|
Assert.Equal(3, save.PlayCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Unwatched_PropagatesWhenSyncUnwatchedEnabled()
|
public void Unwatched_PropagatesWhenSyncUnwatchedEnabled()
|
||||||
{
|
{
|
||||||
@@ -159,13 +265,20 @@ public class WatchedStateSyncTests
|
|||||||
Assert.Empty(h.Saves);
|
Assert.Empty(h.Saves);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public void RedundantWrites_AreSuppressed()
|
[InlineData(UserDataSaveReason.PlaybackFinished)]
|
||||||
|
[InlineData(UserDataSaveReason.PlaybackProgress)]
|
||||||
|
public void RedundantWrites_AreSuppressed(UserDataSaveReason reason)
|
||||||
{
|
{
|
||||||
// The member already matches the shared account, so there is nothing to write.
|
// The member already matches the shared account, so there is nothing to write. This is
|
||||||
var h = Harness.Create(MakeGroup(), [MakeUser("alice")], memberAlreadyPlayed: true);
|
// what keeps the stream of progress ticks after the completion threshold write-free.
|
||||||
|
var h = Harness.Create(
|
||||||
|
MakeGroup(),
|
||||||
|
[MakeUser("alice")],
|
||||||
|
memberAlreadyPlayed: true,
|
||||||
|
memberLastPlayedDate: SharedWatchedAt);
|
||||||
|
|
||||||
h.Raise(SharedId, true, UserDataSaveReason.PlaybackFinished);
|
h.Raise(SharedId, true, reason);
|
||||||
|
|
||||||
Assert.Empty(h.Saves);
|
Assert.Empty(h.Saves);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,9 +70,17 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|||||||
/// Mirrors a shared account's played state onto its members.
|
/// Mirrors a shared account's played state onto its members.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
|
/// No loop guard is needed. Writing to a member raises this event again with that member's id,
|
||||||
/// which is not a shared account id, so the handler returns immediately. The
|
/// which is not a shared account id, so the handler returns immediately. The
|
||||||
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
|
/// <c>Played</c> equality check below suppresses redundant writes on top of that.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A member is written the same way Jellyfin's own <c>BaseItem.MarkPlayed</c> and
|
||||||
|
/// <c>MarkUnplayed</c> write, not just the <c>Played</c> flag. Next Up is driven entirely by
|
||||||
|
/// <c>LastPlayedDate</c> on the member's own row, so a tick without a date leaves the member's
|
||||||
|
/// Next Up stuck; and a stale resume position would keep the item in Continue Watching.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
private void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -81,11 +89,8 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserDataSaved fires constantly during playback (progress ticks); only act on the reasons
|
var played = e.UserData.Played;
|
||||||
// that actually represent a change in watched state.
|
if (!IsWatchedStateChange(e.SaveReason, played))
|
||||||
if (e.SaveReason is not (UserDataSaveReason.PlaybackFinished
|
|
||||||
or UserDataSaveReason.TogglePlayed
|
|
||||||
or UserDataSaveReason.Import))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,7 +101,6 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var played = e.UserData.Played;
|
|
||||||
if (!played && !group.SyncUnwatched)
|
if (!played && !group.SyncUnwatched)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -107,16 +111,36 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = _userDataManager.GetUserData(member, e.Item);
|
var data = _userDataManager.GetUserData(member, e.Item);
|
||||||
if (data is null || data.Played == played)
|
if (data is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.Played = played;
|
// Rows written by earlier versions carry the tick but no date; give those a date
|
||||||
|
// the next time the item syncs rather than skipping them as already in sync.
|
||||||
if (group.SyncPlayCount && played && data.PlayCount < 1)
|
if (data.Played == played && (!played || data.LastPlayedDate.HasValue))
|
||||||
{
|
{
|
||||||
data.PlayCount = 1;
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (played)
|
||||||
|
{
|
||||||
|
data.Played = true;
|
||||||
|
data.PlaybackPositionTicks = 0;
|
||||||
|
data.LastPlayedDate = e.UserData.LastPlayedDate ?? DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (group.SyncPlayCount && data.PlayCount < 1)
|
||||||
|
{
|
||||||
|
data.PlayCount = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Same as Jellyfin's MarkUnplayed, except the play count is left alone: it is
|
||||||
|
// documented as never decreasing.
|
||||||
|
data.Played = false;
|
||||||
|
data.PlaybackPositionTicks = 0;
|
||||||
|
data.LastPlayedDate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
_userDataManager.SaveUserData(
|
_userDataManager.SaveUserData(
|
||||||
@@ -144,4 +168,42 @@ public sealed class WatchedStateSyncService : IHostedService, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decides whether a save represents a change in watched state worth mirroring.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="UserDataSaveReason.TogglePlayed"/> and <see cref="UserDataSaveReason.Import"/>
|
||||||
|
/// are explicit: someone set the flag, so whatever it says is mirrored, unwatched included.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The rest only ever mean "watched" when <paramref name="played"/> is true. Jellyfin raises
|
||||||
|
/// <see cref="UserDataSaveReason.PlaybackFinished"/> on every stop, not just on completion, so
|
||||||
|
/// a stop before the completion threshold leaves <c>Played</c> false without anybody having
|
||||||
|
/// marked anything unwatched - and on 10.11 <c>PlaybackStart</c> resets it to false as well.
|
||||||
|
/// Mirroring that would clear members' own watched state. Likewise
|
||||||
|
/// <see cref="UserDataSaveReason.UpdateUserData"/> is a partial update (a favourite toggle
|
||||||
|
/// arrives with the same reason) where a false flag need not mean a change at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="UserDataSaveReason.PlaybackProgress"/> is included so the tick lands as soon as
|
||||||
|
/// the completion threshold is crossed, and still lands for clients that never report a stop.
|
||||||
|
/// The equality check in the handler keeps the remaining progress ticks write-free.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="reason">Why the user data was saved.</param>
|
||||||
|
/// <param name="played">The played flag on the saved user data.</param>
|
||||||
|
/// <returns><c>true</c> if the save should be mirrored to members.</returns>
|
||||||
|
private static bool IsWatchedStateChange(UserDataSaveReason reason, bool played)
|
||||||
|
{
|
||||||
|
return reason switch
|
||||||
|
{
|
||||||
|
UserDataSaveReason.TogglePlayed or UserDataSaveReason.Import => true,
|
||||||
|
UserDataSaveReason.PlaybackFinished
|
||||||
|
or UserDataSaveReason.PlaybackProgress
|
||||||
|
or UserDataSaveReason.UpdateUserData => played,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,9 +141,21 @@ Two consequences worth knowing:
|
|||||||
|
|
||||||
### Watched-state sync
|
### Watched-state sync
|
||||||
|
|
||||||
The plugin subscribes to `UserDataSaved` and filters tightly: only `PlaybackFinished`,
|
The plugin subscribes to `UserDataSaved` and decides per save reason what it means:
|
||||||
`TogglePlayed` and `Import` are acted on, so the constant stream of progress updates during playback
|
|
||||||
is ignored.
|
- `TogglePlayed` and `Import` are explicit - someone set the flag - so whatever it says is mirrored,
|
||||||
|
unwatched included (subject to *Sync unwatched*).
|
||||||
|
- `PlaybackFinished`, `PlaybackProgress` and `UpdateUserData` only ever mirror *watched*. Jellyfin
|
||||||
|
raises `PlaybackFinished` on every stop, not just on completion, so a stop halfway through leaves
|
||||||
|
`Played` false without anyone having marked anything unwatched; propagating that would wipe what a
|
||||||
|
member watched on their own. Acting on progress too means the tick lands the moment the completion
|
||||||
|
threshold is crossed, and still lands for clients that never report a stop.
|
||||||
|
- Everything else (`PlaybackStart`, `UpdateUserRating`) is ignored.
|
||||||
|
|
||||||
|
A member is written the way Jellyfin's own *mark played* writes - `Played`, `LastPlayedDate` and a
|
||||||
|
cleared resume position - not just the flag. *Next Up* is computed from `LastPlayedDate` on the
|
||||||
|
member's own row, so a bare tick would leave their Next Up stuck on the wrong episode. Members whose
|
||||||
|
row already matches are skipped, which keeps the progress ticks after the threshold write-free.
|
||||||
|
|
||||||
No feedback loop is possible: writing to a member raises the event again with *that member's* ID,
|
No feedback loop is possible: writing to a member raises the event again with *that member's* ID,
|
||||||
which is not a shared account, so the handler stops immediately.
|
which is not a shared account, so the handler stops immediately.
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: "Watched Together"
|
name: "Watched Together"
|
||||||
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
guid: "aa3288a0-e8c1-43e2-8045-8c3411142a5b"
|
||||||
version: "0.0.5.0"
|
version: "0.0.6.0"
|
||||||
# The plugin ships one package per Jellyfin generation. These defaults describe the 10.11 package;
|
# The plugin ships one package per Jellyfin generation. These defaults describe the 10.11 package;
|
||||||
# scripts/package.sh stamps targetAbi/framework for whichever generation it is asked to build.
|
# scripts/package.sh stamps targetAbi/framework for whichever generation it is asked to build.
|
||||||
targetAbi: "10.11.0.0"
|
targetAbi: "10.11.0.0"
|
||||||
|
|||||||
@@ -7,6 +7,22 @@
|
|||||||
"owner": "dtourolle",
|
"owner": "dtourolle",
|
||||||
"category": "General",
|
"category": "General",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.0.5.12",
|
||||||
|
"changelog": "Release 0.0.5.12 (Jellyfin 12)",
|
||||||
|
"targetAbi": "12.0.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.5/watched-together_0.0.5.12.zip",
|
||||||
|
"checksum": "a1058821e7e218a2426699e9459ae6ae",
|
||||||
|
"timestamp": "2026-09-11T17:32:25Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.0.5.11",
|
||||||
|
"changelog": "Release 0.0.5.11 (Jellyfin 10.11)",
|
||||||
|
"targetAbi": "10.11.0.0",
|
||||||
|
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/WatchedTogether/releases/download/v0.0.5/watched-together_0.0.5.11.zip",
|
||||||
|
"checksum": "0cbc0bf4352142fe7ccf303cfc31c896",
|
||||||
|
"timestamp": "2026-09-11T17:32:25Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.0.4.0",
|
"version": "0.0.4.0",
|
||||||
"changelog": "Release 0.0.4.0",
|
"changelog": "Release 0.0.4.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user