Skip to content
Draft
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
3 changes: 2 additions & 1 deletion src/Cove.Api/Services/StashMigrationService.Scenes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ await _db.Set<BaseFileEntity>()
StringComparer.Ordinal);
var seenFileKeys = new HashSet<string>(StringComparer.Ordinal);
var skippedDuplicateFiles = 0;
var engagementUserId = await GetRequiredImportEngagementUserIdAsync(ct);

foreach (var row in sceneRows)
{
Expand Down Expand Up @@ -279,7 +280,7 @@ await _db.Set<BaseFileEntity>()
OrderIndex = g.Index,
Kind = GroupItemKind.Video,
}).ToList(),
PlayHistory = viewHistory.Select(d => new ScenePlayHistory { PlayedAt = d }).ToList(),
PlayHistory = viewHistory.Select(d => new ScenePlayHistory { UserId = engagementUserId, PlayedAt = d }).ToList(),
RemoteIds = sceneStashIds.GetValueOrDefault(row.Id, [])
.Select(s => new SceneRemoteId { Endpoint = s.Ep, RemoteId = s.Rid }).ToList(),
};
Expand Down
67 changes: 67 additions & 0 deletions src/Cove.ApiTests/Infrastructure/CoveClient.Videos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,73 @@ public Task<VideoAggregate> AggregateVideosAsync(
request,
cancellationToken);

public Task RecordVideoPlayAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(HttpMethod.Post, $"/api/videos/{video.Id}/play", new { }, cancellationToken);

public Task DeleteVideoPlayAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(HttpMethod.Delete, $"/api/videos/{video.Id}/play", new { }, cancellationToken);

public Task ResetVideoPlayAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(HttpMethod.Post, $"/api/videos/{video.Id}/play/reset", new { }, cancellationToken);

public Task<int> AddHistoricalVideoLikeAsync(
VideoDto video,
DateTime at,
CancellationToken cancellationToken = default)
=> SendAsync<int>(HttpMethod.Post, $"/api/videos/{video.Id}/like/historical", new HistoricalLikeDto(at), cancellationToken);

public Task DeleteHistoricalVideoLikeAsync(
VideoDto video,
DateTime at,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(
HttpMethod.Delete,
$"/api/videos/{video.Id}/like/history?at={Uri.EscapeDataString(at.ToUniversalTime().ToString("O"))}",
new { },
cancellationToken);

public Task DecrementVideoLikeAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(HttpMethod.Delete, $"/api/videos/{video.Id}/like", new { }, cancellationToken);

public Task ResetVideoLikeAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(HttpMethod.Post, $"/api/videos/{video.Id}/like/reset", new { }, cancellationToken);

public Task<int?> SetVideoRatingViaVideoAsync(
VideoDto video,
int? rating,
string aspect = "overall",
CancellationToken cancellationToken = default)
=> SendAsync<int?>(HttpMethod.Post, $"/api/videos/{video.Id}/rating", new VideoRatingDto(rating, aspect), cancellationToken);

public Task<EntityRatingsDto> GetVideoRatingsViaVideoAsync(
VideoDto video,
CancellationToken cancellationToken = default)
=> SendAsync<EntityRatingsDto>(
HttpMethod.Get,
WithCacheNonce($"/api/videos/{video.Id}/ratings"),
payload: null,
cancellationToken);

public Task ClearVideoRatingViaVideoAsync(
VideoDto video,
string aspect = "overall",
CancellationToken cancellationToken = default)
=> SendForNoContentAsync(
HttpMethod.Delete,
$"/api/videos/{video.Id}/rating?aspect={Uri.EscapeDataString(aspect)}",
new { },
cancellationToken);

public async Task<int> DestroyVideosAsync(
BatchDeleteDto request,
CancellationToken cancellationToken = default)
Expand Down
14 changes: 2 additions & 12 deletions src/Cove.ApiTests/Infrastructure/EndpointCoverageProgress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ public sealed record EndpointCoverageException(ApiEndpointId Endpoint, string Re

public static class EndpointCoverageProgress
{
public const int ExpectedMappedEndpoints = 268;
public const int ExpectedMappedEndpoints = 278;

public const int ExpectedTemporarilyUnmappedEndpoints = 236;
public const int ExpectedTemporarilyUnmappedEndpoints = 226;

public static IReadOnlySet<ApiEndpointId> TemporarilyUnmapped { get; } =
TemporaryUnmappedEndpointText
Expand Down Expand Up @@ -42,10 +42,6 @@ public static class EndpointCoverageProgress
DELETE /api/texts/{id:int}/like/history
DELETE /api/users/{id:int}
DELETE /api/users/{id:int}/external-links/{linkid:int}
DELETE /api/videos/{id:int}/like
DELETE /api/videos/{id:int}/like/history
DELETE /api/videos/{id:int}/play
DELETE /api/videos/{id:int}/rating
DELETE /api/videos/{videoid:int}/detections/{id:int}
GET /api/ai-runs/{id:int}
GET /api/audios/{id:int}/history
Expand Down Expand Up @@ -112,7 +108,6 @@ public static class EndpointCoverageProgress
GET /api/users/{id:int}
GET /api/users/{id:int}/external-links
GET /api/videos/{id:int}/metadata-server/search
GET /api/videos/{id:int}/ratings
GET /api/videos/{videoid:int}/detections/{id:int}
HEAD /api/stream/video/{videoid:int}/preview
POST /api/ai-data/purge
Expand Down Expand Up @@ -229,14 +224,9 @@ public static class EndpointCoverageProgress
POST /api/videos/{id:int}/activity/reset
POST /api/videos/{id:int}/cover/from-frame
POST /api/videos/{id:int}/generate-screenshot
POST /api/videos/{id:int}/like/historical
POST /api/videos/{id:int}/like/reset
POST /api/videos/{id:int}/metadata-server/import
POST /api/videos/{id:int}/metadata-server/submit-draft
POST /api/videos/{id:int}/metadata-server/submit-fingerprints
POST /api/videos/{id:int}/play
POST /api/videos/{id:int}/play/reset
POST /api/videos/{id:int}/rating
POST /api/videos/{id:int}/rescan
PUT /api/auth/me/ui-preferences
PUT /api/content-rules/{id:int}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using System.Globalization;
using Cove.ApiTests.Infrastructure;
using Xunit.Abstractions;

namespace Cove.ApiTests.Tests.Interactions;

[Collection(ApiTestLane1Collection.Name)]
public sealed class VideoEngagementLifecycleApiTests(
ITestOutputHelper output,
CoveApiTestFixture fixture) : ApiTest(output, fixture)
{
[Fact]
[CoversEndpoint("POST", "/api/videos/{id:int}/play")]
[CoversEndpoint("DELETE", "/api/videos/{id:int}/play")]
[CoversEndpoint("POST", "/api/videos/{id:int}/play/reset")]
public async Task GivenTwoMembersPlayVideo_WhenOneDeletesAndResetsOwnHistory_ThenOtherMemberHistoryRemains()
{
var owner = AsUser();
var eva = AsUser(ApiTestUsers.Eva);
var anthony = AsUser(ApiTestUsers.Anthony);
var video = await owner.CreateVideoAsync($"Video play lifecycle {Guid.NewGuid():N}");

await eva.RecordVideoPlayAsync(video);
await eva.RecordVideoPlayAsync(video);
await anthony.RecordVideoPlayAsync(video);
await anthony.RecordVideoPlayAsync(video);
var evaBeforeDelete = await eva.GetVideoHistoryAsync(video);
var anthonyBeforeDelete = await anthony.GetVideoHistoryAsync(video);
await eva.DeleteVideoPlayAsync(video);
var evaAfterDelete = await eva.GetVideoHistoryAsync(video);
var anthonyAfterDelete = await anthony.GetVideoHistoryAsync(video);
await eva.ResetVideoPlayAsync(video);

var evaHistory = await eva.GetVideoHistoryAsync(video);
var anthonyHistory = await anthony.GetVideoHistoryAsync(video);
var ownerHistory = await owner.GetVideoHistoryAsync(video);
var evaEngagement = await eva.GetVideoEngagementAsync(video);
var anthonyEngagement = await anthony.GetVideoEngagementAsync(video);
var ownerEngagement = await owner.GetVideoEngagementAsync(video);

evaBeforeDelete.PlayHistory.Should().HaveCount(2);
anthonyBeforeDelete.PlayHistory.Should().HaveCount(2);
evaAfterDelete.PlayHistory.Should().ContainSingle();
anthonyAfterDelete.PlayHistory.Should().HaveCount(2);
evaHistory.PlayHistory.Should().BeEmpty();
evaEngagement.PlayCount.Should().Be(0);
anthonyHistory.PlayHistory.Should().HaveCount(2);
anthonyEngagement.PlayCount.Should().Be(2);
ownerHistory.PlayHistory.Should().BeEmpty();
ownerEngagement.PlayCount.Should().Be(0);
}

[Fact]
[CoversEndpoint("POST", "/api/videos/{id:int}/like/historical")]
[CoversEndpoint("DELETE", "/api/videos/{id:int}/like/history")]
[CoversEndpoint("DELETE", "/api/videos/{id:int}/like")]
[CoversEndpoint("POST", "/api/videos/{id:int}/like/reset")]
public async Task GivenMemberLikeHistory_WhenHistoricalAndCurrentLikesAreRemoved_ThenOtherMemberLikesRemain()
{
var owner = AsUser();
var eva = AsUser(ApiTestUsers.Eva);
var anthony = AsUser(ApiTestUsers.Anthony);
var video = await owner.CreateVideoAsync($"Video like lifecycle {Guid.NewGuid():N}");
var now = DateTime.UtcNow;
var historicalAt = new DateTime(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second, DateTimeKind.Utc).AddDays(-1);

(await eva.AddHistoricalVideoLikeAsync(video, historicalAt)).Should().Be(1);
var evaHistorical = await eva.GetVideoHistoryAsync(video);
await anthony.IncrementVideoLikeAsync(video);
await eva.DeleteHistoricalVideoLikeAsync(video, historicalAt);
var evaAfterHistoricalDelete = await eva.GetVideoHistoryAsync(video);
var anthonyAfterHistoricalDelete = await anthony.GetVideoHistoryAsync(video);
var evaEngagementAfterHistoricalDelete = await eva.GetVideoEngagementAsync(video);
var anthonyEngagementAfterHistoricalDelete = await anthony.GetVideoEngagementAsync(video);
await eva.IncrementVideoLikeAsync(video);
await eva.IncrementVideoLikeAsync(video);
var evaBeforeDecrement = await eva.GetVideoHistoryAsync(video);
var evaEngagementBeforeDecrement = await eva.GetVideoEngagementAsync(video);
await eva.DecrementVideoLikeAsync(video);
var evaAfterDecrement = await eva.GetVideoHistoryAsync(video);
var evaEngagementAfterDecrement = await eva.GetVideoEngagementAsync(video);
await eva.ResetVideoLikeAsync(video);

var evaHistory = await eva.GetVideoHistoryAsync(video);
var anthonyHistory = await anthony.GetVideoHistoryAsync(video);
var ownerHistory = await owner.GetVideoHistoryAsync(video);
var evaEngagement = await eva.GetVideoEngagementAsync(video);
var anthonyEngagement = await anthony.GetVideoEngagementAsync(video);
var ownerEngagement = await owner.GetVideoEngagementAsync(video);

evaHistorical.LikeHistory.Should().ContainSingle();
DateTime.Parse(
evaHistorical.LikeHistory.Single(),
CultureInfo.InvariantCulture,
DateTimeStyles.RoundtripKind)
.Should().Be(historicalAt);
evaAfterHistoricalDelete.LikeHistory.Should().BeEmpty();
evaEngagementAfterHistoricalDelete.LikeCount.Should().Be(0);
anthonyAfterHistoricalDelete.LikeHistory.Should().ContainSingle();
anthonyEngagementAfterHistoricalDelete.LikeCount.Should().Be(1);
evaBeforeDecrement.LikeHistory.Should().HaveCount(2);
evaEngagementBeforeDecrement.LikeCount.Should().Be(2);
evaAfterDecrement.LikeHistory.Should().ContainSingle();
evaEngagementAfterDecrement.LikeCount.Should().Be(1);
evaHistory.LikeHistory.Should().BeEmpty();
evaEngagement.LikeCount.Should().Be(0);
anthonyHistory.LikeHistory.Should().ContainSingle();
anthonyEngagement.LikeCount.Should().Be(1);
ownerHistory.LikeHistory.Should().BeEmpty();
ownerEngagement.LikeCount.Should().Be(0);
}

[Fact]
[CoversEndpoint("POST", "/api/videos/{id:int}/rating")]
[CoversEndpoint("GET", "/api/videos/{id:int}/ratings")]
[CoversEndpoint("DELETE", "/api/videos/{id:int}/rating")]
public async Task GivenMemberAspectRatings_WhenSetReadAndCleared_ThenResponsesAndFreshReadsAreUserScoped()
{
var owner = AsUser();
var eva = AsUser(ApiTestUsers.Eva);
var anthony = AsUser(ApiTestUsers.Anthony);
var video = await owner.CreateVideoAsync($"Video rating lifecycle {Guid.NewGuid():N}");

var overallResponse = await eva.SetVideoRatingViaVideoAsync(video, 82, "overall");
var contentResponse = await eva.SetVideoRatingViaVideoAsync(video, 46, "content");
var anthonyOverallResponse = await anthony.SetVideoRatingViaVideoAsync(video, 71, "overall");
var anthonyQualityResponse = await anthony.SetVideoRatingViaVideoAsync(video, 64, "video_quality");
var evaBeforeClear = await eva.GetVideoRatingsViaVideoAsync(video);
var anthonyBeforeClear = await anthony.GetVideoRatingsViaVideoAsync(video);
var ownerBeforeClear = await owner.GetVideoRatingsViaVideoAsync(video);
await eva.ClearVideoRatingViaVideoAsync(video, "content");
var evaAfterClear = await eva.GetVideoRatingsViaVideoAsync(video);
var anthonyAfterClear = await anthony.GetVideoRatingsViaVideoAsync(video);

overallResponse.Should().Be(82);
contentResponse.Should().Be(82, "the legacy scalar response represents the overall rating");
anthonyOverallResponse.Should().Be(71);
anthonyQualityResponse.Should().Be(71, "the legacy scalar response represents the overall rating");
evaBeforeClear.HostId.Should().Be(video.Id);
evaBeforeClear.Ratings.Should().BeEquivalentTo(new Dictionary<string, int>
{
["content"] = 46,
["overall"] = 82,
});
anthonyBeforeClear.HostId.Should().Be(video.Id);
anthonyBeforeClear.Ratings.Should().BeEquivalentTo(new Dictionary<string, int>
{
["overall"] = 71,
["video_quality"] = 64,
});
ownerBeforeClear.HostId.Should().Be(video.Id);
ownerBeforeClear.Ratings.Should().BeEmpty();
evaAfterClear.HostId.Should().Be(video.Id);
evaAfterClear.Ratings.Should().BeEquivalentTo(new Dictionary<string, int>
{
["overall"] = 82,
});
anthonyAfterClear.HostId.Should().Be(video.Id);
anthonyAfterClear.Ratings.Should().BeEquivalentTo(anthonyBeforeClear.Ratings);
}
}
2 changes: 1 addition & 1 deletion src/Cove.ApiTests/controller-coverage-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"scope": "src/Cove.Api/Controllers/**/*.cs",
"minimum": {
"coveredLines": 8067,
"coveredLines": 8108,
"totalLines": 14069
},
"targetLineRate": 0.9
Expand Down
5 changes: 4 additions & 1 deletion src/Cove.Core/Entities/Video.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Cove.Core.Entities.Auth;

namespace Cove.Core.Entities;

public class Video : BaseEntity
Expand Down Expand Up @@ -99,7 +101,8 @@ public class VideoPlayHistory
{
public int Id { get; set; }
public int VideoId { get; set; }
public int? UserId { get; set; }
public DateTime PlayedAt { get; set; }
public Video? Video { get; set; }
public User? User { get; set; }
}

8 changes: 5 additions & 3 deletions src/Cove.Data/CoveContext.Authorization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,11 @@ private void ConfigureAuthorizationFilters(ModelBuilder modelBuilder)
modelBuilder.Entity<VideoPlayHistory>().HasQueryFilter(entry =>
AuthorizationFiltersBypassed
? true
: !RequiresVideoReadScopeEvaluation
? CanReadVideos
: CanReadEntitySql(AuthorizationFiltersBypassed, CanReadVideos, CanReadVideosByRule, CurrentRoleNames, CurrentShareLinkId, EntityKinds.Video, entry.VideoId));
: CurrentUserId != null
&& entry.UserId == CurrentUserId
&& (!RequiresVideoReadScopeEvaluation
? CanReadVideos
: CanReadEntitySql(AuthorizationFiltersBypassed, CanReadVideos, CanReadVideosByRule, CurrentRoleNames, CurrentShareLinkId, EntityKinds.Video, entry.VideoId)));

modelBuilder.Entity<PerformerUrl>().HasQueryFilter(link =>
AuthorizationFiltersBypassed
Expand Down
10 changes: 9 additions & 1 deletion src/Cove.Data/CoveContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.ToTable("tag_aliases");
entity.Property(alias => alias.NamespaceKey).IsRequired();
});
modelBuilder.Entity<VideoPlayHistory>().ToTable("video_play_history");
modelBuilder.Entity<VideoPlayHistory>(entity =>
{
entity.ToTable("video_play_history");
entity.HasIndex(history => new { history.UserId, history.VideoId, history.PlayedAt });
entity.HasOne(history => history.User)
.WithMany()
.HasForeignKey(history => history.UserId)
.OnDelete(DeleteBehavior.Cascade);
});

modelBuilder.Entity<VideoFile>()
.HasMany(v => v.Captions)
Expand Down
Loading
Loading