diff --git a/BaseDomain/Results/Result.cs b/BaseDomain/Results/Result.cs index 22fe714..db6fa23 100644 --- a/BaseDomain/Results/Result.cs +++ b/BaseDomain/Results/Result.cs @@ -29,5 +29,6 @@ namespace BaseDomain.Results public static Result Success() => new(true, Error.None); public static Result Failure(Error error) => new(false, Error.None); + public static Result Failure(Error error) => new(false, Error.None); } } diff --git a/BaseDomain/Results/ResultT.cs b/BaseDomain/Results/ResultT.cs index 0eaf098..3e10f7b 100644 --- a/BaseDomain/Results/ResultT.cs +++ b/BaseDomain/Results/ResultT.cs @@ -30,5 +30,9 @@ namespace BaseDomain.Results return new Result(value, true, Error.None); } + public static Result Failure(string v) + { + return new Result(default, false, new Error(ErrorTypeEnum.Failure, v)); + } } } diff --git a/Postall.Domain/IChannelRepository.cs b/Postall.Domain/Contracts/Repositories/IChannelRepository.cs similarity index 98% rename from Postall.Domain/IChannelRepository.cs rename to Postall.Domain/Contracts/Repositories/IChannelRepository.cs index 07b9cd4..481df0c 100644 --- a/Postall.Domain/IChannelRepository.cs +++ b/Postall.Domain/Contracts/Repositories/IChannelRepository.cs @@ -6,7 +6,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; -namespace Postall.Domain +namespace Postall.Domain.Contracts.Repositories { /// /// Interface para repositório de canais do YouTube no MongoDB diff --git a/Postall.Domain/Contracts/Repositories/IVideoRepository.cs b/Postall.Domain/Contracts/Repositories/IVideoRepository.cs new file mode 100644 index 0000000..b6f951e --- /dev/null +++ b/Postall.Domain/Contracts/Repositories/IVideoRepository.cs @@ -0,0 +1,23 @@ +using Postall.Domain.Entities; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Postall.Domain +{ + public interface IVideoRepository + { + Task> GetAllAsync(); + Task GetByIdAsync(string id); + Task GetByVideoIdAsync(string videoId); + Task> GetByUserIdAsync(string userId); + Task> GetByChannelIdAsync(string channelId); + Task> GetByUserIdAndChannelIdAsync(string userId, string channelId); + Task GetByUserIdAndVideoIdAsync(string userId, string videoId); + Task AddAsync(VideoData videoData); + Task> AddManyAsync(IEnumerable videos); + Task UpdateAsync(VideoData videoData); + Task DeleteAsync(string id); + Task DeleteByVideoIdAsync(string videoId); + Task> SearchAsync(string searchTerm); + } +} \ No newline at end of file diff --git a/Postall.Domain/Dtos/VideoResponse.cs b/Postall.Domain/Dtos/VideoResponse.cs new file mode 100644 index 0000000..da0e14f --- /dev/null +++ b/Postall.Domain/Dtos/VideoResponse.cs @@ -0,0 +1,65 @@ +using Postall.Domain.Entities; +using System; + +namespace Postall.Domain.Dtos +{ + public class VideoResponse + { + public string Id { get; set; } + public string VideoId { get; set; } + public string ChannelId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string ThumbnailUrl { get; set; } + public DateTime PublishedAt { get; set; } + public ulong ViewCount { get; set; } + public ulong LikeCount { get; set; } + public ulong DislikeCount { get; set; } + public ulong CommentCount { get; set; } + } + + public static class VideoDataExtensions + { + public static VideoResponse ToVideoResponse(this VideoData videoData) + { + if (videoData == null) + return null; + + return new VideoResponse + { + Id = videoData.Id, + VideoId = videoData.VideoId, + ChannelId = videoData.ChannelId, + Title = videoData.Title, + Description = videoData.Description, + ThumbnailUrl = videoData.ThumbnailUrl, + PublishedAt = videoData.PublishedAt, + ViewCount = videoData.ViewCount, + LikeCount = videoData.LikeCount, + DislikeCount = videoData.DislikeCount, + CommentCount = videoData.CommentCount + }; + } + + public static VideoData ToVideoData(this VideoResponse videoResponse) + { + if (videoResponse == null) + return null; + + return new VideoData + { + Id = videoResponse.Id, + VideoId = videoResponse.VideoId, + ChannelId = videoResponse.ChannelId, + Title = videoResponse.Title, + Description = videoResponse.Description, + ThumbnailUrl = videoResponse.ThumbnailUrl, + PublishedAt = videoResponse.PublishedAt, + ViewCount = videoResponse.ViewCount, + LikeCount = videoResponse.LikeCount, + DislikeCount = videoResponse.DislikeCount, + CommentCount = videoResponse.CommentCount + }; + } + } +} \ No newline at end of file diff --git a/Postall.Domain/Entities/VideosData.cs b/Postall.Domain/Entities/VideosData.cs new file mode 100644 index 0000000..9831068 --- /dev/null +++ b/Postall.Domain/Entities/VideosData.cs @@ -0,0 +1,27 @@ +using System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace Postall.Domain.Entities +{ + public class VideoData + { + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } + + public string UserId { get; set; } + public string ChannelId { get; set; } + public string VideoId { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string ThumbnailUrl { get; set; } + public DateTime PublishedAt { get; set; } + public ulong ViewCount { get; set; } + public ulong LikeCount { get; set; } + public ulong DislikeCount { get; set; } + public ulong CommentCount { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } + } +} \ No newline at end of file diff --git a/Postall.Domain/Services/ChannelVideoService.cs b/Postall.Domain/Services/ChannelVideoService.cs index f11342e..2504262 100644 --- a/Postall.Domain/Services/ChannelVideoService.cs +++ b/Postall.Domain/Services/ChannelVideoService.cs @@ -1,7 +1,7 @@ using BaseDomain.Results; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; -using Postall.Domain; +using Postall.Domain.Contracts.Repositories; using Postall.Domain.Dtos; using Postall.Domain.Entities; using Postall.Domain.Services.Contracts; diff --git a/Postall.Domain/Services/Contracts/IVideoService.cs b/Postall.Domain/Services/Contracts/IVideoService.cs index 543595b..027a4f3 100644 --- a/Postall.Domain/Services/Contracts/IVideoService.cs +++ b/Postall.Domain/Services/Contracts/IVideoService.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; namespace Postall.Domain.Services.Contracts { - public interface IVideoService + public interface IYouTubeServiceChannel { Task> GetUserChannelVideosAsync(int maxResults = 10); diff --git a/Postall.Domain/Services/Contracts/IVideoYoutubeService.cs b/Postall.Domain/Services/Contracts/IVideoYoutubeService.cs new file mode 100644 index 0000000..0e13aa6 --- /dev/null +++ b/Postall.Domain/Services/Contracts/IVideoYoutubeService.cs @@ -0,0 +1,14 @@ +using BaseDomain.Results; +using Postall.Domain.Dtos; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Postall.Domain.Services.Contracts +{ + public interface IVideoYoutubeService + { + Task> GetVideoDetailsAsync(string videoId); + Task>> GetChannelVideosAsync(string channelId, int maxResults = 50); + Task>> SearchVideosAsync(string query, int maxResults = 10); + } +} \ No newline at end of file diff --git a/Postall.Domain/Services/Contracts/IYouTubeServiceChannel.cs b/Postall.Domain/Services/Contracts/IYouTubeServiceChannel.cs new file mode 100644 index 0000000..ef97c7d --- /dev/null +++ b/Postall.Domain/Services/Contracts/IYouTubeServiceChannel.cs @@ -0,0 +1,19 @@ +using BaseDomain.Results; +using Postall.Domain.Dtos; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Postall.Domain.Services.Contracts +{ + public interface IVideoService + { + Task>> GetUserVideosAsync(); + Task>> GetVideosByChannelIdAsync(string channelId); + Task> GetVideoDetailsAsync(string videoId); + Task>> GetChannelVideosFromYouTubeAsync(string channelId, int maxResults = 10); + Task> AddVideoAsync(string videoId, string channelId); + Task> AddVideosAsync(List videoIds, string channelId); + Task> RemoveVideoAsync(string videoId); + Task>> SearchVideosAsync(string query, int maxResults = 10); + } +} \ No newline at end of file diff --git a/Postall.Domain/Services/VideoService .cs b/Postall.Domain/Services/VideoService .cs new file mode 100644 index 0000000..628da97 --- /dev/null +++ b/Postall.Domain/Services/VideoService .cs @@ -0,0 +1,308 @@ +using BaseDomain.Results; +using Microsoft.AspNetCore.Http; +using Postall.Domain; +using Postall.Domain.Contracts.Repositories; +using Postall.Domain.Dtos; +using Postall.Domain.Entities; +using Postall.Domain.Services.Contracts; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Postall.Infra.Services +{ + public class VideoService : IVideoService + { + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IVideoRepository _videoRepository; + private readonly IChannelRepository _channelRepository; + private readonly IVideoYoutubeService _videoYoutubeService; + + public VideoService( + IHttpContextAccessor httpContextAccessor, + IVideoRepository videoRepository, + IChannelRepository channelRepository, + IVideoYoutubeService videoYoutubeService) + { + _httpContextAccessor = httpContextAccessor; + _videoRepository = videoRepository; + _channelRepository = channelRepository; + _videoYoutubeService = videoYoutubeService; + } + + /// + /// Obtém o ID do usuário atual a partir das claims + /// + private string GetCurrentUserId() + { + var user = _httpContextAccessor.HttpContext.User; + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(userId)) + { + var email = user.FindFirst(ClaimTypes.Email)?.Value; + if (!string.IsNullOrEmpty(email)) + { + userId = Convert.ToBase64String( + System.Text.Encoding.UTF8.GetBytes(email) + ).Replace("/", "_").Replace("+", "-").Replace("=", ""); + } + } + + return userId; + } + + /// + /// Obtém todos os vídeos do usuário atual + /// + public async Task>> GetUserVideosAsync() + { + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return new List(); + + var videos = await _videoRepository.GetByUserIdAsync(userId); + return videos.Select(v => v.ToVideoResponse()).ToList(); + } + + /// + /// Obtém os vídeos de um canal específico para o usuário atual + /// + public async Task>> GetVideosByChannelIdAsync(string channelId) + { + if (string.IsNullOrEmpty(channelId)) + return new List(); + + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return new List(); + + var videos = await _videoRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + return videos.Select(v => v.ToVideoResponse()).ToList(); + } + + /// + /// Obtém os detalhes de um vídeo específico + /// + public async Task> GetVideoDetailsAsync(string videoId) + { + if (string.IsNullOrEmpty(videoId)) + return Result.Failure("ID do vídeo inválido"); + + var videoDetails = await _videoYoutubeService.GetVideoDetailsAsync(videoId); + return videoDetails; + } + + /// + /// Obtém os vídeos de um canal diretamente do YouTube + /// + public async Task>> GetChannelVideosFromYouTubeAsync(string channelId, int maxResults = 10) + { + if (string.IsNullOrEmpty(channelId)) + return Result>.Failure("ID do canal inválido"); + + // Verifica se o canal existe para o usuário + var userId = GetCurrentUserId(); + var channel = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + + if (channel == null) + return Result>.Failure("Canal não encontrado para este usuário"); + + // Obtém os vídeos que o usuário já adicionou para este canal + var userVideos = await _videoRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + var userVideoIds = userVideos.Select(v => v.VideoId).ToHashSet(); + + // Busca os vídeos do canal no YouTube + var channelVideos = await _videoYoutubeService.GetChannelVideosAsync(channelId, maxResults); + + if (!channelVideos.IsSuccess) + return channelVideos; + + // Marca os vídeos que já foram adicionados pelo usuário + foreach (var video in channelVideos.Value) + { + if (userVideoIds.Contains(video.VideoId)) + { + // Esse vídeo já foi adicionado pelo usuário + video.Id = userVideos.First(v => v.VideoId == video.VideoId).Id; + } + } + + return channelVideos; + } + + /// + /// Adiciona um vídeo à lista do usuário + /// + public async Task> AddVideoAsync(string videoId, string channelId) + { + if (string.IsNullOrEmpty(videoId)) + return Result.Failure("ID do vídeo inválido"); + + if (string.IsNullOrEmpty(channelId)) + return Result.Failure("ID do canal inválido"); + + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return Result.Failure("Usuário não identificado"); + + // Verifica se o canal existe para o usuário + var channel = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + + if (channel == null) + return Result.Failure("Canal não encontrado para este usuário"); + + // Verifica se o vídeo já existe para o usuário + var existingVideo = await _videoRepository.GetByUserIdAndVideoIdAsync(userId, videoId); + + if (existingVideo != null) + return true; // Vídeo já existe, retorna sucesso + + try + { + // Obtém os detalhes do vídeo do YouTube + var videoDetails = await _videoYoutubeService.GetVideoDetailsAsync(videoId); + + if (!videoDetails.IsSuccess) + return Result.Failure(videoDetails.Error); + + // Cria o objeto de dados do vídeo + var videoData = new VideoData + { + UserId = userId, + ChannelId = channelId, + VideoId = videoId, + Title = videoDetails.Value.Title, + Description = videoDetails.Value.Description, + ThumbnailUrl = videoDetails.Value.ThumbnailUrl, + PublishedAt = videoDetails.Value.PublishedAt, + ViewCount = videoDetails.Value.ViewCount, + LikeCount = videoDetails.Value.LikeCount, + DislikeCount = videoDetails.Value.DislikeCount, + CommentCount = videoDetails.Value.CommentCount, + CreatedAt = DateTime.UtcNow + }; + + // Adiciona o vídeo ao repositório + await _videoRepository.AddAsync(videoData); + + return true; + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + /// + /// Adiciona múltiplos vídeos à lista do usuário + /// + public async Task> AddVideosAsync(List videoIds, string channelId) + { + if (videoIds == null || !videoIds.Any()) + return Result.Failure("Lista de IDs de vídeos inválida"); + + if (string.IsNullOrEmpty(channelId)) + return Result.Failure("ID do canal inválido"); + + var userId = GetCurrentUserId(); + + if (string.IsNullOrEmpty(userId)) + return Result.Failure("Usuário não identificado"); + + // Verifica se o canal existe para o usuário + var channel = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId); + + if (channel == null) + return Result.Failure("Canal não encontrado para este usuário"); + + // Obtém os vídeos que o usuário já adicionou + var existingVideos = await _videoRepository.GetByUserIdAsync(userId); + var existingVideoIds = existingVideos.Select(v => v.VideoId).ToHashSet(); + + // Filtra os vídeos que ainda não foram adicionados + var newVideoIds = videoIds.Where(id => !existingVideoIds.Contains(id)).ToList(); + + if (newVideoIds.Count == 0) + return true; // Todos os vídeos já existem + + try + { + var videoDataList = new List(); + + // Obtém os detalhes de cada vídeo do YouTube e cria os objetos de dados + foreach (var videoId in newVideoIds) + { + var videoDetails = await _videoYoutubeService.GetVideoDetailsAsync(videoId); + + if (videoDetails.IsSuccess) + { + videoDataList.Add(new VideoData + { + UserId = userId, + ChannelId = channelId, + VideoId = videoId, + Title = videoDetails.Value.Title, + Description = videoDetails.Value.Description, + ThumbnailUrl = videoDetails.Value.ThumbnailUrl, + PublishedAt = videoDetails.Value.PublishedAt, + ViewCount = videoDetails.Value.ViewCount, + LikeCount = videoDetails.Value.LikeCount, + DislikeCount = videoDetails.Value.DislikeCount, + CommentCount = videoDetails.Value.CommentCount, + CreatedAt = DateTime.UtcNow + }); + } + } + + // Adiciona os vídeos ao repositório + if (videoDataList.Any()) + { + await _videoRepository.AddManyAsync(videoDataList); + } + + return true; + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + /// + /// Remove um vídeo da lista do usuário + /// + public async Task> RemoveVideoAsync(string videoId) + { + if (string.IsNullOrEmpty(videoId)) + return Result.Failure("ID do vídeo inválido"); + + try + { + await _videoRepository.DeleteAsync(videoId); + return true; + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + /// + /// Busca vídeos no YouTube pelo título ou descrição + /// + public async Task>> SearchVideosAsync(string query, int maxResults = 10) + { + if (string.IsNullOrEmpty(query) || query.Length < 3) + return new List(); + + var results = await _videoYoutubeService.SearchVideosAsync(query, maxResults); + return results; + } + } +} \ No newline at end of file diff --git a/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs b/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs index f2b8390..33368e1 100644 --- a/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs +++ b/Postall.Infra.MongoDB/Extensions/ServiceRepositoryExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Postall.Domain; +using Postall.Domain.Contracts.Repositories; using Postall.Domain.Services.Contracts; using Postall.Infra.MongoDB.Repositories; using Postall.Infra.Services; @@ -16,6 +17,7 @@ namespace Postall.Infra.MongoDB.Extensions public static IServiceCollection AddRepositories(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs b/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs index 733119b..f812456 100644 --- a/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs +++ b/Postall.Infra.MongoDB/Repositories/ChannelRepository.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; using MongoDB.Bson; using MongoDB.Driver; -using Postall.Domain; +using Postall.Domain.Contracts.Repositories; using Postall.Domain.Dtos; using Postall.Domain.Entities; using Postall.Infra.MongoDB.Settings; diff --git a/Postall.Infra.MongoDB/Repositories/VideoRepository.cs b/Postall.Infra.MongoDB/Repositories/VideoRepository.cs new file mode 100644 index 0000000..05333db --- /dev/null +++ b/Postall.Infra.MongoDB/Repositories/VideoRepository.cs @@ -0,0 +1,253 @@ +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using Postall.Domain; +using Postall.Domain.Entities; +using Postall.Infra.MongoDB.Settings; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Postall.Infra.MongoDB.Repositories +{ + public class VideoRepository : IVideoRepository + { + private readonly IMongoCollection _videosCollection; + + public VideoRepository(IOptions mongoDbSettings) + { + var client = new MongoClient(mongoDbSettings.Value.ConnectionString); + var database = client.GetDatabase(mongoDbSettings.Value.DatabaseName); + _videosCollection = database.GetCollection(mongoDbSettings.Value.VideosCollectionName); + + // Cria índice composto para videoId e userId + var indexKeysDefinition = Builders.IndexKeys + .Ascending(v => v.VideoId) + .Ascending(v => v.UserId); + _videosCollection.Indexes.CreateOne(new CreateIndexModel(indexKeysDefinition, new CreateIndexOptions { Unique = true })); + + // Cria índice para buscas por texto + var textIndexDefinition = Builders.IndexKeys + .Text(v => v.Title) + .Text(v => v.Description); + _videosCollection.Indexes.CreateOne(new CreateIndexModel(textIndexDefinition)); + + // Cria índice para buscas por channelId + var channelIndexDefinition = Builders.IndexKeys + .Ascending(v => v.ChannelId); + _videosCollection.Indexes.CreateOne(new CreateIndexModel(channelIndexDefinition)); + + // Cria índice para userId + channelId + var userChannelIndexDefinition = Builders.IndexKeys + .Ascending(v => v.UserId) + .Ascending(v => v.ChannelId); + _videosCollection.Indexes.CreateOne(new CreateIndexModel(userChannelIndexDefinition)); + } + + /// + /// Obtém todos os vídeos + /// + public async Task> GetAllAsync() + { + return await _videosCollection.Find(v => true).ToListAsync(); + } + + /// + /// Obtém um vídeo pelo ID do MongoDB + /// + public async Task GetByIdAsync(string id) + { + if (!ObjectId.TryParse(id, out _)) + return null; + + return await _videosCollection.Find(v => v.Id == id).FirstOrDefaultAsync(); + } + + /// + /// Obtém um vídeo pelo ID do YouTube + /// + public async Task GetByVideoIdAsync(string videoId) + { + return await _videosCollection.Find(v => v.VideoId == videoId).FirstOrDefaultAsync(); + } + + /// + /// Obtém os vídeos pelo ID do usuário + /// + public async Task> GetByUserIdAsync(string userId) + { + return await _videosCollection.Find(v => v.UserId == userId).ToListAsync(); + } + + /// + /// Obtém os vídeos pelo ID do canal + /// + public async Task> GetByChannelIdAsync(string channelId) + { + return await _videosCollection.Find(v => v.ChannelId == channelId).ToListAsync(); + } + + /// + /// Obtém os vídeos pelo ID do usuário e ID do canal + /// + public async Task> GetByUserIdAndChannelIdAsync(string userId, string channelId) + { + return await _videosCollection.Find(v => v.UserId == userId && v.ChannelId == channelId).ToListAsync(); + } + + /// + /// Obtém um vídeo pelo ID do usuário e ID do vídeo + /// + public async Task GetByUserIdAndVideoIdAsync(string userId, string videoId) + { + return await _videosCollection.Find(v => v.UserId == userId && v.VideoId == videoId).FirstOrDefaultAsync(); + } + + /// + /// Adiciona um novo vídeo + /// + public async Task AddAsync(VideoData videoData) + { + var existingVideo = await GetByUserIdAndVideoIdAsync(videoData.UserId, videoData.VideoId); + if (existingVideo != null) + return existingVideo; + + if (string.IsNullOrEmpty(videoData.Id) || !ObjectId.TryParse(videoData.Id, out _)) + { + videoData.Id = ObjectId.GenerateNewId().ToString(); + } + + videoData.CreatedAt = DateTime.UtcNow; + + try + { + await _videosCollection.InsertOneAsync(videoData); + } + catch (MongoWriteException ex) + { + if (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return await GetByUserIdAndVideoIdAsync(videoData.UserId, videoData.VideoId); + } + else + { + throw; + } + } + return videoData; + } + + /// + /// Adiciona vários vídeos de uma vez + /// + public async Task> AddManyAsync(IEnumerable videos) + { + if (videos == null || !videos.Any()) + return new List(); + + var videosList = videos.ToList(); + var now = DateTime.UtcNow; + + // Gera novos IDs para o MongoDB se necessário + foreach (var videoData in videosList) + { + if (string.IsNullOrEmpty(videoData.Id) || !ObjectId.TryParse(videoData.Id, out _)) + { + videoData.Id = ObjectId.GenerateNewId().ToString(); + } + videoData.CreatedAt = now; + } + + // Verifica vídeos existentes pelo ID do YouTube e usuário + var userIds = videosList.Select(v => v.UserId).Distinct().ToList(); + var videoIds = videosList.Select(v => v.VideoId).ToList(); + + var filter = Builders.Filter.And( + Builders.Filter.In(v => v.UserId, userIds), + Builders.Filter.In(v => v.VideoId, videoIds) + ); + + var existingVideos = await _videosCollection.Find(filter).ToListAsync(); + + // Filtra apenas os vídeos que ainda não foram adicionados + var existingPairs = existingVideos + .Select(v => $"{v.UserId}:{v.VideoId}") + .ToHashSet(); + + var newVideos = videosList + .Where(v => !existingPairs.Contains($"{v.UserId}:{v.VideoId}")) + .ToList(); + + if (newVideos.Any()) + { + try + { + await _videosCollection.InsertManyAsync(newVideos); + } + catch (MongoBulkWriteException) + { + // Em caso de erro, adiciona um por um + foreach (var video in newVideos) + { + try { await AddAsync(video); } + catch { /* Ignora erros individuais */ } + } + } + } + + return newVideos; + } + + /// + /// Atualiza um vídeo existente + /// + public async Task UpdateAsync(VideoData videoData) + { + if (string.IsNullOrEmpty(videoData.Id) || !ObjectId.TryParse(videoData.Id, out _)) + return false; + + videoData.UpdatedAt = DateTime.UtcNow; + + var result = await _videosCollection.ReplaceOneAsync( + v => v.Id == videoData.Id, + videoData, + new ReplaceOptions { IsUpsert = false }); + + return result.IsAcknowledged && result.ModifiedCount > 0; + } + + /// + /// Remove um vídeo pelo ID do MongoDB + /// + public async Task DeleteAsync(string id) + { + if (!ObjectId.TryParse(id, out _)) + return false; + + var result = await _videosCollection.DeleteOneAsync(v => v.Id == id); + return result.IsAcknowledged && result.DeletedCount > 0; + } + + /// + /// Remove um vídeo pelo ID do YouTube + /// + public async Task DeleteByVideoIdAsync(string videoId) + { + var result = await _videosCollection.DeleteOneAsync(v => v.VideoId == videoId); + return result.IsAcknowledged && result.DeletedCount > 0; + } + + /// + /// Busca vídeos com base em um termo de pesquisa no título ou descrição + /// + public async Task> SearchAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) + return await GetAllAsync(); + + var filter = Builders.Filter.Text(searchTerm); + return await _videosCollection.Find(filter).ToListAsync(); + } + } +} \ No newline at end of file diff --git a/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs b/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs index cbb4c07..aaf4efe 100644 --- a/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs +++ b/Postall.Infra.MongoDB/Settings/MongoDbSetting.cs @@ -14,5 +14,6 @@ namespace Postall.Infra.MongoDB.Settings public string ConnectionString { get; set; } public string DatabaseName { get; set; } public string ChannelsCollectionName { get; set; } + public string VideosCollectionName { get; set; } } } diff --git a/Postall.Infra/Extensions/ServiceCollectionExtensions.cs b/Postall.Infra/Extensions/ServiceCollectionExtensions.cs index 10f2850..cc7d4ae 100644 --- a/Postall.Infra/Extensions/ServiceCollectionExtensions.cs +++ b/Postall.Infra/Extensions/ServiceCollectionExtensions.cs @@ -11,12 +11,16 @@ namespace BaseDomain.Extensions { services.AddHttpContextAccessor(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + services.AddScoped(); + return services; } } diff --git a/Postall.Infra/Services/FacebookTokenService.cs b/Postall.Infra/Services/FacebookTokenService.cs index 4ad5741..19e110c 100644 --- a/Postall.Infra/Services/FacebookTokenService.cs +++ b/Postall.Infra/Services/FacebookTokenService.cs @@ -18,10 +18,17 @@ namespace Postall.Infra.Services private readonly IConfiguration _config; private readonly HttpClient _httpClient; + public FacebookTokenService(IConfiguration configuration, IMongoCollection tokens, IHttpClientFactory httpClientFactory) + { + _config = configuration; + _tokens = tokens; + _httpClient = httpClientFactory.CreateClient("Facebook"); + } + public async Task GetLongLivedToken(string shortLivedToken) { - var appId = _config["Authentication:Facebook:AppId"]; - var appSecret = _config["Authentication:Facebook:AppSecret"]; + var appId = _config.GetSection("Authentication:Facebook:AppId").Value; + var appSecret = _config.GetSection("Authentication:Facebook:AppSecret").Value; var response = await _httpClient.GetFromJsonAsync( $"https://graph.facebook.com/oauth/access_token?" + diff --git a/Postall.Infra/Services/VideoYoutubeService .cs b/Postall.Infra/Services/VideoYoutubeService .cs new file mode 100644 index 0000000..4e10c32 --- /dev/null +++ b/Postall.Infra/Services/VideoYoutubeService .cs @@ -0,0 +1,134 @@ +using BaseDomain.Results; +using Microsoft.Extensions.Configuration; +using Postall.Domain.Dtos; +using Postall.Domain.Services.Contracts; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Postall.Infra.Services +{ + public class VideoYoutubeService : IVideoYoutubeService + { + private readonly IConfiguration _configuration; + private readonly string _apiKey; + + public VideoYoutubeService(IConfiguration configuration) + { + _configuration = configuration; + _apiKey = _configuration["YouTube:ApiKey"]; + } + + /// + /// Busca os detalhes de um vídeo na API do YouTube + /// + public async Task> GetVideoDetailsAsync(string videoId) + { + try + { + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + var videosRequest = youtubeService.Videos.List("snippet,statistics"); + videosRequest.Id = videoId; + + var response = await videosRequest.ExecuteAsync(); + + if (response.Items.Count == 0) + return Result.Failure($"Vídeo não encontrado: {videoId}"); + + var video = response.Items[0]; + var snippet = video.Snippet; + var statistics = video.Statistics; + + return new VideoResponse + { + VideoId = video.Id, + ChannelId = snippet.ChannelId, + Title = snippet.Title, + Description = snippet.Description, + ThumbnailUrl = snippet.Thumbnails.Medium?.Url ?? snippet.Thumbnails.Default__.Url, + PublishedAt = snippet.PublishedAt.GetValueOrDefault(), + ViewCount = statistics.ViewCount.GetValueOrDefault(), + LikeCount = statistics.LikeCount.GetValueOrDefault(), + DislikeCount = statistics.DislikeCount.GetValueOrDefault(), + CommentCount = statistics.CommentCount.GetValueOrDefault() + }; + } + catch (Exception ex) + { + return Result.Failure(ex.Message); + } + } + + /// + /// Busca vídeos de um canal na API do YouTube + /// + public async Task>> GetChannelVideosAsync(string channelId, int maxResults = 50) + { + try + { + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + var searchRequest = youtubeService.Search.List("snippet"); + searchRequest.ChannelId = channelId; + searchRequest.Type = "video"; + searchRequest.Order = Google.Apis.YouTube.v3.SearchResource.ListRequest.OrderEnum.Date; + searchRequest.MaxResults = maxResults; + + var searchResponse = await searchRequest.ExecuteAsync(); + var videos = new List(); + + foreach (var item in searchResponse.Items) + { + // Para cada resultado, obter os detalhes completos do vídeo + var videoResult = await GetVideoDetailsAsync(item.Id.VideoId); + if (videoResult.IsSuccess) + { + videos.Add(videoResult.Value); + } + } + + return videos; + } + catch (Exception ex) + { + return Result>.Failure(ex.Message); + } + } + + /// + /// Busca vídeos pelo título ou descrição na API do YouTube + /// + public async Task>> SearchVideosAsync(string query, int maxResults = 10) + { + try + { + var youtubeService = new YouTubeBaseServiceWrapper(_apiKey); + + var searchRequest = youtubeService.Search.List("snippet"); + searchRequest.Q = query; + searchRequest.Type = "video"; + searchRequest.MaxResults = maxResults; + + var searchResponse = await searchRequest.ExecuteAsync(); + var videos = new List(); + + foreach (var item in searchResponse.Items) + { + // Para cada resultado, obter os detalhes completos do vídeo + var videoResult = await GetVideoDetailsAsync(item.Id.VideoId); + if (videoResult.IsSuccess) + { + videos.Add(videoResult.Value); + } + } + + return videos; + } + catch (Exception ex) + { + return Result>.Failure(ex.Message); + } + } + } +} \ No newline at end of file diff --git a/Postall.Infra/Services/YouTubeServiceVideo.cs b/Postall.Infra/Services/YouTubeServiceChannel.cs similarity index 98% rename from Postall.Infra/Services/YouTubeServiceVideo.cs rename to Postall.Infra/Services/YouTubeServiceChannel.cs index 8557d40..b6f1e6a 100644 --- a/Postall.Infra/Services/YouTubeServiceVideo.cs +++ b/Postall.Infra/Services/YouTubeServiceChannel.cs @@ -8,7 +8,7 @@ using System.Security.Claims; namespace Postall.Infra.Services { - public class YouTubeServiceVideo: IVideoService + public class YouTubeServiceChannel: IYouTubeServiceChannel { private readonly IConfiguration _configuration; private readonly IHttpContextAccessor _httpContextAccessor; @@ -16,7 +16,7 @@ namespace Postall.Infra.Services private readonly string _clientId; private readonly string _clientSecret; - public YouTubeServiceVideo(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) + public YouTubeServiceChannel(IConfiguration configuration, IHttpContextAccessor httpContextAccessor) { _configuration = configuration; _httpContextAccessor = httpContextAccessor; diff --git a/Postall/Controllers/VideosController.cs b/Postall/Controllers/VideosController.cs index 319525e..0f297e6 100644 --- a/Postall/Controllers/VideosController.cs +++ b/Postall/Controllers/VideosController.cs @@ -1,75 +1,232 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Postall.Domain.Services.Contracts; using Postall.Models; -using System.Security.Claims; -using System.Threading.Tasks; +using System; using System.Collections.Generic; -using Postall.Models; +using System.Linq; +using System.Threading.Tasks; namespace Postall.Controllers { [Authorize] public class VideosController : Controller { - // Simulação de dados para exemplo - em produção, estes dados viriam de uma API ou banco de dados - private List GetSampleVideos() + private readonly IVideoService _videoService; + private readonly IChannelService _channelService; + + public VideosController(IVideoService videoService, IChannelService channelService) { - return new List + _videoService = videoService; + _channelService = channelService; + } + + /// + /// Exibe a página principal com os vídeos organizados por canal + /// + public async Task Index() + { + try { - new VideoViewModel + // Obtém os canais do usuário + var userChannelsResult = await _channelService.GetUserChannelsAsync(); + + if (!userChannelsResult.IsSuccess || !userChannelsResult.Value.Any()) { - Id = "video1", - Title = "Como usar o PostAll - Tutorial", - Description = "Aprenda a usar todas as funcionalidades do PostAll para gerenciar suas redes sociais.", - ThumbnailUrl = "https://i.ytimg.com/vi/sample1/maxresdefault.jpg", - PublishedAt = DateTime.Now.AddDays(-2) - }, - new VideoViewModel - { - Id = "video2", - Title = "Estratégias de Marketing Digital para 2024", - Description = "Conheça as melhores estratégias para alavancar seu negócio nas redes sociais em 2024.", - ThumbnailUrl = "https://i.ytimg.com/vi/sample2/maxresdefault.jpg", - PublishedAt = DateTime.Now.AddDays(-5) - }, - new VideoViewModel - { - Id = "video3", - Title = "Análise de Métricas nas Redes Sociais", - Description = "Aprenda a interpretar as métricas das suas redes sociais e tomar decisões baseadas em dados.", - ThumbnailUrl = "https://i.ytimg.com/vi/sample3/maxresdefault.jpg", - PublishedAt = DateTime.Now.AddDays(-7) + return View(new List()); } - }; - } - public IActionResult Index() - { - var userVideos = GetSampleVideos(); - return View(userVideos); - } + var channelsWithVideos = new List(); - [HttpGet] - public IActionResult GetChannelVideos() - { - // Em um cenário real, você buscaria os vídeos da API do YouTube - var channelVideos = GetSampleVideos().OrderByDescending(v => v.PublishedAt).Take(10).ToList(); - return PartialView("_ChannelVideosPartial", channelVideos); - } + // Para cada canal, obtém os vídeos associados + foreach (var channel in userChannelsResult.Value) + { + var channelVideosResult = await _videoService.GetVideosByChannelIdAsync(channel.ChannelId); - [HttpPost] - public IActionResult AddVideos(string[] selectedVideos) - { - if (selectedVideos == null || selectedVideos.Length == 0) + var channelVideos = new ChannelVideosViewModel + { + Channel = channel, + Videos = channelVideosResult.IsSuccess + ? channelVideosResult.Value.Select(v => new VideoViewModel + { + Id = v.Id, + VideoId = v.VideoId, + ChannelId = v.ChannelId, + Title = v.Title, + Description = v.Description, + ThumbnailUrl = v.ThumbnailUrl, + PublishedAt = v.PublishedAt, + ViewCount = v.ViewCount, + LikeCount = v.LikeCount, + DislikeCount = v.DislikeCount, + CommentCount = v.CommentCount + }).ToList() + : new List() + }; + + channelsWithVideos.Add(channelVideos); + } + + return View(channelsWithVideos); + } + catch (Exception ex) { + TempData["Error"] = $"Erro ao carregar vídeos: {ex.Message}"; + return View(new List()); + } + } + + /// + /// Exibe os vídeos disponíveis de um canal para serem adicionados + /// + [HttpGet] + public async Task GetChannelVideos(string channelId) + { + try + { + var channelVideosResult = await _videoService.GetChannelVideosFromYouTubeAsync(channelId); + + if (!channelVideosResult.IsSuccess) + { + + return PartialView("_ChannelVideos", new List()); + } + + var videosViewModel = channelVideosResult.Value.Select(v => new VideoViewModel + { + Id = v.Id, + VideoId = v.VideoId, + ChannelId = v.ChannelId, + Title = v.Title, + Description = v.Description, + ThumbnailUrl = v.ThumbnailUrl, + PublishedAt = v.PublishedAt, + ViewCount = v.ViewCount, + LikeCount = v.LikeCount, + DislikeCount = v.DislikeCount, + CommentCount = v.CommentCount + }).ToList(); + + return PartialView("_ChannelVideos", videosViewModel); + } + catch (Exception ex) + { + return Json(new { success = false, message = ex.Message }); + } + } + + /// + /// Adiciona vários vídeos à lista do usuário + /// + [HttpPost] + public async Task AddVideos(string channelId, List selectedVideos) + { + if (string.IsNullOrEmpty(channelId)) + { + TempData["Error"] = "ID do canal é obrigatório"; return RedirectToAction("Index"); } - // Em um cenário real, você salvaria esses IDs no banco de dados - // Aqui apenas redirecionamos de volta para o Index + if (selectedVideos == null || !selectedVideos.Any()) + { + TempData["Error"] = "Selecione pelo menos um vídeo para adicionar"; + return RedirectToAction("Index"); + } + + try + { + var result = await _videoService.AddVideosAsync(selectedVideos, channelId); + + if (result.IsSuccess) + { + TempData["Message"] = "Vídeos adicionados com sucesso!"; + } + else + { + TempData["Error"] = $"Erro ao adicionar vídeos: {result.Error.Description}"; + } + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao adicionar vídeos: {ex.Message}"; + } - TempData["Message"] = $"{selectedVideos.Length} vídeo(s) adicionado(s) com sucesso!"; return RedirectToAction("Index"); } + + /// + /// Remove um vídeo da lista do usuário + /// + [HttpPost] + public async Task Remove(string id, string channelId) + { + if (string.IsNullOrEmpty(id)) + { + TempData["Error"] = "ID do vídeo é obrigatório"; + return RedirectToAction("Index"); + } + + try + { + var result = await _videoService.RemoveVideoAsync(id); + + if (result.IsSuccess) + { + TempData["Message"] = "Vídeo removido com sucesso!"; + } + else + { + TempData["Error"] = $"Erro ao remover vídeo: {result.Error.Description}"; + } + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao remover vídeo: {ex.Message}"; + } + + return RedirectToAction("Index"); + } + + /// + /// Busca vídeos no YouTube + /// + [HttpGet] + public async Task Search(string query, string channelId) + { + if (string.IsNullOrEmpty(query) || query.Length < 3) + { + return Json(new { success = false, message = "A consulta deve ter pelo menos 3 caracteres" }); + } + + try + { + var results = await _videoService.SearchVideosAsync(query); + + if (results.IsSuccess) + { + var videosViewModel = results.Value.Select(v => new VideoViewModel + { + VideoId = v.VideoId, + ChannelId = channelId, // Usado para saber a qual canal adicionar + Title = v.Title, + Description = v.Description, + ThumbnailUrl = v.ThumbnailUrl, + PublishedAt = v.PublishedAt, + ViewCount = v.ViewCount, + LikeCount = v.LikeCount, + DislikeCount = v.DislikeCount, + CommentCount = v.CommentCount + }).ToList(); + + return Json(new { success = true, data = videosViewModel }); + } + + return Json(new { success = false, message = "Erro ao buscar vídeos" }); + } + catch (Exception ex) + { + return Json(new { success = false, message = ex.Message }); + } + } } } \ No newline at end of file diff --git a/Postall/Models/ChannelVideosViewModel.cs b/Postall/Models/ChannelVideosViewModel.cs new file mode 100644 index 0000000..4eaff32 --- /dev/null +++ b/Postall/Models/ChannelVideosViewModel.cs @@ -0,0 +1,11 @@ +using Postall.Domain.Dtos; +using System.Collections.Generic; + +namespace Postall.Models +{ + public class ChannelVideosViewModel + { + public ChannelResponse Channel { get; set; } + public List Videos { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Postall/Models/VideoViewModel.cs b/Postall/Models/VideoViewModel.cs index c2f6c0a..e144c76 100644 --- a/Postall/Models/VideoViewModel.cs +++ b/Postall/Models/VideoViewModel.cs @@ -2,20 +2,19 @@ namespace Postall.Models { - public class VideoViewModel { public string Id { get; set; } + public string VideoId { get; set; } + public string ChannelId { get; set; } public string Title { get; set; } public string Description { get; set; } public string ThumbnailUrl { get; set; } public DateTime PublishedAt { get; set; } - public bool IsSelected { get; set; } - - // Propriedades adicionais que podem ser úteis no futuro - public string ChannelTitle { get; set; } - public string ChannelId { get; set; } - public string VideoUrl => $"https://www.youtube.com/watch?v={Id}"; + public string VideoUrl => $"https://www.youtube.com/watch?v={VideoId}"; + public ulong ViewCount { get; set; } + public ulong LikeCount { get; set; } + public ulong DislikeCount { get; set; } + public ulong CommentCount { get; set; } } -} - +} \ No newline at end of file diff --git a/Postall/Views/Videos/Index.cshtml b/Postall/Views/Videos/Index.cshtml index 751f882..f426381 100644 --- a/Postall/Views/Videos/Index.cshtml +++ b/Postall/Views/Videos/Index.cshtml @@ -1,18 +1,87 @@ -@model List +@model List @{ ViewData["Title"] = "Gerenciador de Vídeos"; } + +

Meus Vídeos

-

Gerencie seus vídeos do YouTube

+

Gerencie seus vídeos do YouTube por canal

- + + Gerenciar Canais +
@@ -26,65 +95,122 @@
} -
- @if (Model != null && Model.Any()) - { - foreach (var video in Model) + @if (TempData["Error"] != null) + { + + } + + @if (Model == null || !Model.Any()) + { +
+ Você não possui nenhum canal com vídeos. + Clique aqui para adicionar um canal primeiro. +
+ } + else + { +
+ @foreach (var channelVideos in Model) { -
-
-
-
-
@video.Title
- +
+
+ +
-
-
- @video.Title + @if (channelVideos.Videos != null && channelVideos.Videos.Any()) + { +
+ @foreach (var video in channelVideos.Videos) + { +
+
+
+
+
@video.Title
+ +
+
+
+
+
+ @video.Title +
+
+
+

@video.Description

+ Ler mais +
+

Publicado em: @video.PublishedAt.ToString("dd/MM/yyyy")

+
+
+
+

@video.Title

+ @video.Description +
+
+
+ +
+
+
+ }
-
-

@(video.Description?.Length > 100 ? video.Description.Substring(0, 100) + "..." : video.Description)

-

Publicado em: @video.PublishedAt.ToString("dd/MM/yyyy")

+ } + else + { +
+ Este canal não possui vídeos adicionados. + Clique em "Adicionar Vídeos" para começar.
-
-
-
- + }
} - } - else - { -
-
- Você ainda não possui vídeos. Clique em "Adicionar Vídeos" para começar. -
-
- } -
+
+ }
@@ -92,7 +218,7 @@