using System.Linq.Expressions; using LBPUnion.ProjectLighthouse.Database; using LBPUnion.ProjectLighthouse.Extensions; using LBPUnion.ProjectLighthouse.Filter.Filters.Activity; using LBPUnion.ProjectLighthouse.Helpers; using LBPUnion.ProjectLighthouse.Logging; using LBPUnion.ProjectLighthouse.StorableLists.Stores; using LBPUnion.ProjectLighthouse.Types.Activity; using LBPUnion.ProjectLighthouse.Types.Entities.Token; using LBPUnion.ProjectLighthouse.Types.Levels; using LBPUnion.ProjectLighthouse.Types.Logging; using LBPUnion.ProjectLighthouse.Types.Serialization.Activity; using LBPUnion.ProjectLighthouse.Types.Users; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers; [ApiController] [Authorize] [Route("LITTLEBIGPLANETPS3_XML/stream")] [Produces("text/xml")] public class ActivityController : ControllerBase { private readonly DatabaseContext database; public ActivityController(DatabaseContext database) { this.database = database; } private async Task> GetFilters ( IQueryable dtoQuery, GameTokenEntity token, bool excludeNews, bool excludeMyLevels, bool excludeFriends, bool excludeFavouriteUsers, bool excludeMyself, bool excludeMyPlaylists = true ) { dtoQuery = token.GameVersion == GameVersion.LittleBigPlanetVita ? dtoQuery.Where(dto => dto.TargetSlotGameVersion == null || dto.TargetSlotGameVersion == token.GameVersion) : dtoQuery.Where(dto => dto.TargetSlotGameVersion == null || dto.TargetSlotGameVersion <= token.GameVersion); Expression> predicate = PredicateExtensions.False(); List favouriteUsers = await this.database.HeartedProfiles.Where(hp => hp.UserId == token.UserId) .Select(hp => hp.HeartedUserId) .ToListAsync(); List? friendIds = UserFriendStore.GetUserFriendData(token.UserId)?.FriendIds; friendIds ??= []; // This is how lbp3 does its filtering GameStreamFilter? filter = await this.DeserializeBody(); if (filter?.Sources != null) { foreach (GameStreamFilterEventSource filterSource in filter.Sources.Where(filterSource => filterSource.SourceType != null && filterSource.Types?.Count != 0)) { EventType[] types = filterSource.Types?.ToArray() ?? Array.Empty(); EventTypeFilter eventFilter = new(types); predicate = filterSource.SourceType switch { "MyLevels" => predicate.Or(new MyLevelActivityFilter(token.UserId, eventFilter).GetPredicate()), "FavouriteUsers" => predicate.Or( new IncludeUserIdFilter(favouriteUsers, eventFilter).GetPredicate()), "Friends" => predicate.Or(new IncludeUserIdFilter(friendIds, eventFilter).GetPredicate()), _ => predicate, }; } } Expression> newsPredicate = !excludeNews ? new IncludeNewsFilter().GetPredicate() : new ExcludeNewsFilter().GetPredicate(); predicate = predicate.Or(newsPredicate); if (!excludeMyLevels) { predicate = predicate.Or(dto => dto.TargetSlotCreatorId == token.UserId); } List includedUserIds = []; if (!excludeFriends) { includedUserIds.AddRange(friendIds); } if (!excludeFavouriteUsers) { includedUserIds.AddRange(favouriteUsers); } if (!excludeMyself) { includedUserIds.Add(token.UserId); } predicate = predicate.Or(dto => includedUserIds.Contains(dto.Activity.UserId)); if (!excludeMyPlaylists && !excludeMyself && token.GameVersion == GameVersion.LittleBigPlanet3) { List creatorPlaylists = await this.database.Playlists.Where(p => p.CreatorId == token.UserId) .Select(p => p.PlaylistId) .ToListAsync(); predicate = predicate.Or(new PlaylistActivityFilter(creatorPlaylists).GetPredicate()); } else { predicate = predicate.And(dto => dto.Activity.Type != EventType.CreatePlaylist && dto.Activity.Type != EventType.HeartPlaylist && dto.Activity.Type != EventType.AddLevelToPlaylist); } dtoQuery = dtoQuery.Where(predicate); return dtoQuery; } public Task GetMostRecentEventTime(IQueryable activity, DateTime upperBound) { return activity.OrderByDescending(a => a.Activity.Timestamp) .Where(a => a.Activity.Timestamp < upperBound) .Select(a => a.Activity.Timestamp) .FirstOrDefaultAsync(); } private async Task<(DateTime Start, DateTime End)> GetTimeBounds (IQueryable activityQuery, long? startTime, long? endTime) { if (startTime is null or 0) startTime = TimeHelper.TimestampMillis; DateTime start = DateTimeExtensions.FromUnixTimeMilliseconds(startTime.Value); DateTime end; if (endTime == null) { end = await this.GetMostRecentEventTime(activityQuery, start); // If there is no recent event then set it to the the start if (end == DateTime.MinValue) end = start; end = end.Subtract(TimeSpan.FromDays(7)); } else { end = DateTimeExtensions.FromUnixTimeMilliseconds(endTime.Value); // Don't allow more than 7 days worth of activity in a single page if (start.Subtract(end).TotalDays > 7) { end = start.Subtract(TimeSpan.FromDays(7)); } } return (start, end); } private static DateTime GetOldestTime (IReadOnlyCollection> groups, DateTime defaultTimestamp) => groups.Count != 0 ? groups.Min(g => g.MinBy(a => a.Activity.Timestamp)?.Activity.Timestamp ?? defaultTimestamp) : defaultTimestamp; /// /// Speeds up serialization because many nested entities need to find Slots by id /// and since they use the Find() method they can benefit from having the entities /// already tracked by the context /// private async Task CacheEntities(IReadOnlyCollection groups) { List slotIds = groups.GetIds(ActivityGroupType.Level); List userIds = groups.GetIds(ActivityGroupType.User); List playlistIds = groups.GetIds(ActivityGroupType.Playlist); List newsIds = groups.GetIds(ActivityGroupType.News); // Cache target levels and users within DbContext if (slotIds.Count > 0) await this.database.Slots.Where(s => slotIds.Contains(s.SlotId)).LoadAsync(); if (userIds.Count > 0) await this.database.Users.Where(u => userIds.Contains(u.UserId)).LoadAsync(); if (playlistIds.Count > 0) await this.database.Playlists.Where(p => playlistIds.Contains(p.PlaylistId)).LoadAsync(); if (newsIds.Count > 0) await this.database.WebsiteAnnouncements.Where(a => newsIds.Contains(a.AnnouncementId)).LoadAsync(); } /// /// LBP3 uses a different grouping format that wants the actor to be the top level group and the events should be the subgroups /// [HttpPost] public async Task GlobalActivityLBP3 (long timestamp, bool excludeMyPlaylists, bool excludeNews, bool excludeMyself) { GameTokenEntity token = this.GetToken(); if (token.GameVersion != GameVersion.LittleBigPlanet3) return this.NotFound(); IQueryable activityEvents = await this.GetFilters( this.database.Activities.ToActivityDto(true, true), token, excludeNews, true, true, true, excludeMyself, excludeMyPlaylists); (DateTime Start, DateTime End) times = await this.GetTimeBounds(activityEvents, timestamp, null); // LBP3 is grouped by actorThenObject meaning it wants all events by a user grouped together rather than // all user events for a level or profile grouped together List> groups = await activityEvents .Where(dto => dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End) .ToActivityGroups(true) .ToListAsync(); List outerGroups = groups.ToOuterActivityGroups(true); long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds(); return this.Ok(GameStream.CreateFromGroups(token, outerGroups, times.Start.ToUnixTimeMilliseconds(), oldestTimestamp)); } [HttpGet] public async Task GlobalActivity ( long timestamp, long endTimestamp, bool excludeNews, bool excludeMyLevels, bool excludeFriends, bool excludeFavouriteUsers, bool excludeMyself ) { GameTokenEntity token = this.GetToken(); if (token.GameVersion is GameVersion.LittleBigPlanet1 or GameVersion.LittleBigPlanetPSP) return this.NotFound(); IQueryable activityEvents = await this.GetFilters(this.database.Activities.ToActivityDto(true), token, excludeNews, excludeMyLevels, excludeFriends, excludeFavouriteUsers, excludeMyself); (DateTime Start, DateTime End) times = await this.GetTimeBounds(activityEvents, timestamp, endTimestamp); List> groups = await activityEvents .Where(dto => dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End) .ToActivityGroups() .ToListAsync(); List outerGroups = groups.ToOuterActivityGroups(); long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds(); await this.CacheEntities(outerGroups); GameStream? gameStream = GameStream.CreateFromGroups(token, outerGroups, times.Start.ToUnixTimeMilliseconds(), oldestTimestamp); return this.Ok(gameStream); } #if DEBUG private static void PrintOuterGroups(List outerGroups) { foreach (OuterActivityGroup outer in outerGroups) { Logger.Debug(@$"Outer group key: {outer.Key}", LogArea.Activity); List> itemGroup = outer.Groups; foreach (IGrouping item in itemGroup) { Logger.Debug( @$" Inner group key: TargetId={item.Key.TargetId}, UserId={item.Key.UserId}, Type={item.Key.Type}", LogArea.Activity); foreach (ActivityDto activity in item) { Logger.Debug( @$" Activity: {activity.GroupType}, Timestamp: {activity.Activity.Timestamp}, UserId: {activity.Activity.UserId}, EventType: {activity.Activity.Type}, TargetId: {activity.TargetId}", LogArea.Activity); } } } } #endif [HttpGet("slot/{slotType}/{slotId:int}")] [HttpGet("user2/{username}")] public async Task LocalActivity(string? slotType, int slotId, string? username, long? timestamp) { GameTokenEntity token = this.GetToken(); if (token.GameVersion is GameVersion.LittleBigPlanet1 or GameVersion.LittleBigPlanetPSP) return this.NotFound(); if ((SlotHelper.IsTypeInvalid(slotType) || slotId == 0) == (username == null)) return this.BadRequest(); // User and Level activity will never contain news posts or MM pick events. IQueryable activityQuery = this.database.Activities.ToActivityDto() .Where(a => a.Activity.Type != EventType.NewsPost && a.Activity.Type != EventType.MMPickLevel); if (token.GameVersion != GameVersion.LittleBigPlanet3) { activityQuery = activityQuery.Where(a => a.Activity.Type != EventType.CreatePlaylist && a.Activity.Type != EventType.HeartPlaylist && a.Activity.Type != EventType.AddLevelToPlaylist); } bool isLevelActivity = username == null; // Slot activity if (isLevelActivity) { if (slotType == "developer") slotId = await SlotHelper.GetPlaceholderSlotId(this.database, slotId, SlotType.Developer); if (!await this.database.Slots.AnyAsync(s => s.SlotId == slotId)) return this.NotFound(); activityQuery = activityQuery.Where(dto => dto.TargetSlotId == slotId); } // User activity else { int userId = await this.database.Users.Where(u => u.Username == username) .Select(u => u.UserId) .FirstOrDefaultAsync(); if (userId == 0) return this.NotFound(); activityQuery = activityQuery.Where(dto => dto.Activity.UserId == userId); } (DateTime Start, DateTime End) times = await this.GetTimeBounds(activityQuery, timestamp, null); activityQuery = activityQuery.Where(dto => dto.Activity.Timestamp < times.Start && dto.Activity.Timestamp > times.End); List> groups = await activityQuery.ToActivityGroups().ToListAsync(); List outerGroups = groups.ToOuterActivityGroups(); long oldestTimestamp = GetOldestTime(groups, times.Start).ToUnixTimeMilliseconds(); await this.CacheEntities(outerGroups); return this.Ok(GameStream.CreateFromGroups(token, outerGroups, times.Start.ToUnixTimeMilliseconds(), oldestTimestamp, isLevelActivity)); } }