feat: channels com mongodb

This commit is contained in:
Ricardo Carneiro 2025-03-04 19:06:01 -03:00
parent 955a131fec
commit 4cca23cb35
41 changed files with 2057 additions and 67 deletions

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>

View File

@ -6,16 +6,16 @@ using System.Threading.Tasks;
namespace BaseDomain.Results
{
public enum ErrorTypeEnum
{
None = 0, //Erro vazio (para result)
Failure = 1, //Quando for uma falha que eu queira retornar no lugar de uma Exception
Validation = 2, //Quando for um problema de validação
Others = 3 //Inesperado ou sem categoria
}
public class Error
{
public enum ErrorTypeEnum
{
None = 0, //Erro vazio (para result)
Failure = 1, //Quando for uma falha que eu queira retornar no lugar de uma Exception
Validation = 2, //Quando for um problema de validação
Others = 3 //Inesperado ou sem categoria
}
public Error(ErrorTypeEnum error, string message, string description="")
{
ErrorType = error;

View File

@ -10,7 +10,7 @@ namespace BaseDomain.Results
{
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None)
if (isSuccess && error.ErrorType != ErrorTypeEnum.None)
{
throw new InvalidOperationException();
}

View File

@ -11,6 +11,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Postall.Domain", "Postall.D
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BaseDomain", "BaseDomain\BaseDomain.csproj", "{72324CE0-8009-4876-9CF9-9F2BA8288B87}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Postall.Infra.MongoDB", "Postall.Infra.MongoDB\Postall.Infra.MongoDB.csproj", "{2EFC97F7-84D3-4582-9202-B03B401D9E9F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -33,6 +35,10 @@ Global
{72324CE0-8009-4876-9CF9-9F2BA8288B87}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72324CE0-8009-4876-9CF9-9F2BA8288B87}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72324CE0-8009-4876-9CF9-9F2BA8288B87}.Release|Any CPU.Build.0 = Release|Any CPU
{2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2EFC97F7-84D3-4582-9202-B03B401D9E9F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -0,0 +1,26 @@
using BaseDomain.Results;
using Postall.Domain.Dtos;
using Postall.Domain.Services.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Application
{
public class ChannelApplicationService
{
private readonly IChannelService _channelService;
public ChannelApplicationService(IChannelService channelService)
{
_channelService = channelService;
}
public async Task<Result<List<ChannelResponse>>> GetUserChannelsAsync()
{
return await _channelService.GetUserChannelsAsync();
}
}
}

View File

@ -0,0 +1,44 @@
using Postall.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Dtos
{
public class ChannelResponse
{
public string Id { get; set; }
public string UserId { get; set; }
public string YoutubeId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public string ThumbnailUrl { get; set; }
public DateTime PublishedAt { get; set; }
public ulong SubscriberCount { get; set; }
public ulong VideoCount { get; set; }
// Propriedade para seleção de canais na interface
public bool IsSelected { get; set; }
// URL do canal no YouTube
public string ChannelUrl => $"https://www.youtube.com/channel/{Id}";
public ChannelData ToChannelData()
{
return new ChannelData
{
Id = Id,
UserId = UserId,
YoutubeId = YoutubeId,
Title = Title,
Description = Description,
ThumbnailUrl = ThumbnailUrl,
PublishedAt = PublishedAt,
SubscriberCount = SubscriberCount,
VideoCount = VideoCount
};
}
}
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Infra.Dtos
{
public class VideoItemResponse
{
public string Id { 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}";
}
}

View File

@ -0,0 +1,67 @@
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Bson;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Postall.Domain.Dtos;
namespace Postall.Domain.Entities
{
/// <summary>
/// Modelo de dados para armazenamento do canal no MongoDB
/// </summary>
public class ChannelData
{
[BsonId]
[BsonRepresentation(BsonType.String)]
public string Id { get; set; }
[BsonElement("userId")]
public string UserId { get; set; }
[BsonElement("channelId")]
public string ChannelId { get; set; }
[BsonElement("youtubeId")]
public string YoutubeId { get; set; }
[BsonElement("title")]
public string Title { get; set; }
[BsonElement("description")]
public string Description { get; set; }
[BsonElement("thumbnailUrl")]
public string ThumbnailUrl { get; set; }
[BsonElement("publishedAt")]
public DateTime PublishedAt { get; set; }
[BsonElement("subscriberCount")]
public ulong SubscriberCount { get; set; }
[BsonElement("videoCount")]
public ulong VideoCount { get; set; }
[BsonElement("isSelected")]
public bool IsSelected { get; set; }
[BsonIgnore]
public string ChannelUrl => $"https://www.youtube.com/channel/{YoutubeId}";
public ChannelResponse ToChannelResponse() => new ChannelResponse
{
Id = Id,
UserId = UserId,
YoutubeId = YoutubeId,
Title = Title,
Description = Description,
ThumbnailUrl = ThumbnailUrl,
PublishedAt = PublishedAt,
SubscriberCount = SubscriberCount,
VideoCount = VideoCount
};
}
}

View File

@ -0,0 +1,92 @@
using Postall.Domain.Dtos;
using Postall.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain
{
/// <summary>
/// Interface para repositório de canais do YouTube no MongoDB
/// </summary>
public interface IChannelRepository
{
/// <summary>
/// Obtém todos os canais
/// </summary>
Task<IEnumerable<ChannelData>> GetAllAsync();
/// <summary>
/// Obtém um canal pelo ID do MongoDB
/// </summary>
Task<ChannelData> GetByIdAsync(string id);
/// <summary>
/// Obtém os canais pelo ID do usuario do MongoDB
/// </summary>
Task<IList<ChannelData>> GetByUserIdAsync(string userId);
/// <summary>
/// Obtém os canais pelo ID do usuario e o channelId
/// </summary>
Task<ChannelData> GetByUserIdAndChannelIdAsync(string userId, string channelId);
/// <summary>
/// Obtém um canal pelo ID do YouTube
/// </summary>
Task<ChannelData> GetByYoutubeIdAsync(string youtubeId);
/// <summary>
/// Adiciona um novo canal
/// </summary>
Task<ChannelData> AddAsync(ChannelData ChannelData);
/// <summary>
/// Adiciona vários canais de uma vez
/// </summary>
Task<IEnumerable<ChannelData>> AddManyAsync(IEnumerable<ChannelData> channels);
/// <summary>
/// Atualiza um canal existente
/// </summary>
Task<bool> UpdateAsync(ChannelData ChannelData);
/// <summary>
/// Remove um canal pelo ID do MongoDB
/// </summary>
Task<bool> DeleteAsync(string id);
/// <summary>
/// Remove um canal pelo ID do YouTube
/// </summary>
Task<bool> DeleteByYoutubeIdAsync(string youtubeId);
/// <summary>
/// Busca canais com base em um termo de pesquisa no título ou descrição
/// </summary>
Task<IEnumerable<ChannelData>> SearchAsync(string searchTerm);
/// <summary>
/// Obtém canais selecionados (IsSelected = true)
/// </summary>
Task<IEnumerable<ChannelData>> GetSelectedAsync();
/// <summary>
/// Marca ou desmarca um canal como selecionado
/// </summary>
Task<bool> SetSelectedStatusAsync(string id, bool isSelected);
/// <summary>
/// Converte ChannelResponse para ChannelData
/// </summary>
ChannelData ConvertFromResponse(ChannelResponse channelResponse);
/// <summary>
/// Converte ChannelData para ChannelResponse
/// </summary>
ChannelResponse ConvertToResponse(ChannelData ChannelData);
}
}

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="libc.eventbus" Version="7.1.2" />
<PackageReference Include="MongoDB.Bson" Version="3.1.0" />
<PackageReference Include="MongoDB.Driver" Version="3.1.0" />
<PackageReference Include="Serilog" Version="4.0.2" />
@ -17,7 +18,11 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BaseDomain\BaseDomain.csproj" />
<ProjectReference Include="..\BaseDomain\BaseDomain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Events\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,171 @@
using BaseDomain.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Postall.Domain;
using Postall.Domain.Dtos;
using Postall.Domain.Entities;
using Postall.Domain.Services.Contracts;
using System.Security.Claims;
namespace Postall.Infra.Services
{
public class ChannelVideoService: IChannelService
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IChannelRepository _channelRepository;
private readonly IChannelYoutubeService _channelYoutubeService;
private readonly IConfiguration _configuration;
private readonly string _apiKey;
public ChannelVideoService(
IHttpContextAccessor httpContextAccessor,
IChannelRepository channelRepository,
IChannelYoutubeService channelYoutubeService
)
{
_httpContextAccessor = httpContextAccessor;
this._channelRepository = channelRepository;
this._channelYoutubeService = channelYoutubeService;
}
/// <summary>
/// Obtém o ID do usuário atual a partir das claims
/// </summary>
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;
}
/// <summary>
/// Obtém a lista de canais que o usuário tem permissão para listar
/// </summary>
public async Task<Result<List<ChannelResponse>>> GetUserChannelsAsync()
{
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId))
return new List<ChannelResponse>();
var channelList = await _channelRepository.GetByUserIdAsync(userId);
if (!channelList.Any())
{
var defaultChannels = new List<ChannelData>
{
new ChannelData { Id = "UC_x5XG1OV2P6uZZ5FSM9Ttw", Title = "Google Developers" },
new ChannelData { Id = "UCkw4JCwteGrDHIsyIIKo4tQ", Title = "Google" }
};
foreach (var channel in defaultChannels)
{
var channelDetails = await this.GetChannelDetailsAsync(channel.Id);
if (channelDetails.IsSuccess)
{
channelList.Add(channelDetails.Value.ToChannelData());
}
else
{
Console.WriteLine($"Erro ao buscar detalhes do canal {channel.Id}: {channelDetails.Error.Description}");
channelList.Add(new ChannelData
{
Id = channel.Id,
Title = channel.Title,
ThumbnailUrl = "/images/default-channel-thumb.jpg" // Imagem padrão
});
}
}
}
return channelList.Select(c => c.ToChannelResponse()).ToList();
}
/// <summary>
/// Adiciona um canal à lista do usuário
/// </summary>
public async Task<Result<bool>> AddChannelAsync(string channelId)
{
if (string.IsNullOrEmpty(channelId))
return false;
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId))
return false;
var channelItem = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId);
try
{
if (channelItem != null)
return false;
var channelDetails = await GetChannelDetailsAsync(channelId);
await _channelRepository.AddAsync(channelDetails.Value.ToChannelData());
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Erro ao adicionar canal {channelId}: {ex.Message}");
return false;
}
}
/// <summary>
/// Remove um canal da lista do usuário
/// </summary>
public async Task<Result<bool>> RemoveChannel(string channelId)
{
if (string.IsNullOrEmpty(channelId))
return false;
var userId = GetCurrentUserId();
var channelItem = await _channelRepository.GetByUserIdAndChannelIdAsync(userId, channelId);
if (channelItem == null)
return false;
await _channelRepository.DeleteAsync(channelId);
return true;
}
/// <summary>
/// Busca os detalhes de um canal na API do YouTube
/// </summary>
public async Task<Result<ChannelResponse>> GetChannelDetailsAsync(string channelId)
{
var channel = await _channelYoutubeService.GetChannelDetailsAsync(channelId);
return channel;
}
/// <summary>
/// Busca um canal pelo nome ou ID
/// </summary>
public async Task<Result<List<ChannelResponse>>> SearchChannelsAsync(string query, int maxResults = 5)
{
if (string.IsNullOrEmpty(query) || query.Length < 3)
return new List<ChannelResponse>();
var results = await _channelYoutubeService.SearchChannelsAsync(query, maxResults);
return results;
}
}
}

View File

@ -0,0 +1,23 @@
using BaseDomain.Results;
using Postall.Domain.Dtos;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Services.Contracts
{
public interface IChannelService
{
Task<Result<List<ChannelResponse>>> GetUserChannelsAsync();
Task<Result<bool>> AddChannelAsync(string channelId);
Task<Result<bool>> RemoveChannel(string channelId);
Task<Result<List<ChannelResponse>>> SearchChannelsAsync(string query, int maxResults = 5);
Task<Result<ChannelResponse>> GetChannelDetailsAsync(string channelId);
}
}

View File

@ -0,0 +1,17 @@
using BaseDomain.Results;
using Postall.Domain.Dtos;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Services.Contracts
{
public interface IChannelYoutubeService
{
Task<Result<List<ChannelResponse>>> SearchChannelsAsync(string query, int maxResults = 5);
Task<Result<ChannelResponse>> GetChannelDetailsAsync(string channelId);
}
}

View File

@ -4,7 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Services
namespace Postall.Domain.Services.Contracts
{
public interface IFacebookServices
{

View File

@ -0,0 +1,20 @@
using Postall.Infra.Dtos;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Domain.Services.Contracts
{
public interface IVideoService
{
Task<List<VideoItemResponse>> GetUserChannelVideosAsync(int maxResults = 10);
Task<List<VideoItemResponse>> GetChannelVideosAsync(string channelId, int maxResults = 10);
Task SaveVideoForUserAsync(string videoId);
Task RemoveVideoForUserAsync(string videoId);
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Postall.Domain;
using Postall.Domain.Services.Contracts;
using Postall.Infra.MongoDB.Repositories;
using Postall.Infra.Services;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Infra.MongoDB.Extensions
{
public static class ServiceRepositoryExtensions
{
public static IServiceCollection AddRepositories(this IServiceCollection services)
{
services.AddScoped<IChannelRepository, ChannelRepository>();
return services;
}
}
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0-preview.1.25080.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-preview.1.25080.5" />
<PackageReference Include="MongoDB.Driver" Version="3.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Postall.Domain\Postall.Domain.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,244 @@
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Driver;
using Postall.Domain;
using Postall.Domain.Dtos;
using Postall.Domain.Entities;
using Postall.Infra.MongoDB.Settings;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Channels;
using System.Threading.Tasks;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace Postall.Infra.MongoDB.Repositories
{
public class ChannelRepository : IChannelRepository
{
private readonly IMongoCollection<ChannelData> _channelsCollection;
public ChannelRepository(IOptions<MongoDbSettings> mongoDbSettings)
{
var client = new MongoClient(mongoDbSettings.Value.ConnectionString);
var database = client.GetDatabase(mongoDbSettings.Value.DatabaseName);
_channelsCollection = database.GetCollection<ChannelData>(mongoDbSettings.Value.ChannelsCollectionName);
var indexKeysDefinition = Builders<ChannelData>.IndexKeys.Ascending(c => c.YoutubeId);
_channelsCollection.Indexes.CreateOne(new CreateIndexModel<ChannelData>(indexKeysDefinition, new CreateIndexOptions { Unique = true }));
var textIndexDefinition = Builders<ChannelData>.IndexKeys
.Text(c => c.Title)
.Text(c => c.Description);
_channelsCollection.Indexes.CreateOne(new CreateIndexModel<ChannelData>(textIndexDefinition));
}
/// <summary>
/// Obtém todos os canais
/// </summary>
public async Task<IEnumerable<ChannelData>> GetAllAsync()
{
return await _channelsCollection.Find(c => true).ToListAsync();
}
/// <summary>
/// Obtém um canal pelo ID do MongoDB
/// </summary>
public async Task<ChannelData> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
return null;
return await _channelsCollection.Find(c => c.Id == id).FirstOrDefaultAsync();
}
public async Task<IList<ChannelData>> GetByUserIdAsync(string userId)
{
return await _channelsCollection.Find(c => c.UserId == userId).ToListAsync();
}
/// <summary>
/// Obtém os canais pelo ID do usuario e o channelId
/// </summary>
public async Task<ChannelData> GetByUserIdAndChannelIdAsync(string userId, string channelId)
{
return await _channelsCollection.Find(c => c.UserId == userId && c.ChannelId == channelId).FirstOrDefaultAsync();
}
/// <summary>
/// Obtém um canal pelo ID do YouTube
/// </summary>
public async Task<ChannelData> GetByYoutubeIdAsync(string youtubeId)
{
return await _channelsCollection.Find(c => c.YoutubeId == youtubeId).FirstOrDefaultAsync();
}
/// <summary>
/// Adiciona um novo canal
/// </summary>
public async Task<ChannelData> AddAsync(ChannelData ChannelData)
{
// Verifica se o canal já existe pelo ID do YouTube
var existingChannel = await GetByYoutubeIdAsync(ChannelData.YoutubeId);
if (existingChannel != null)
return null;
// Gera novo ID para o MongoDB se não for fornecido
if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _))
{
ChannelData.Id = ObjectId.GenerateNewId().ToString();
}
await _channelsCollection.InsertOneAsync(ChannelData);
return ChannelData;
}
/// <summary>
/// Adiciona vários canais de uma vez
/// </summary>
public async Task<IEnumerable<ChannelData>> AddManyAsync(IEnumerable<ChannelData> channels)
{
if (channels == null || !channels.Any())
return new List<ChannelData>();
var channelsList = channels.ToList();
// Gera novos IDs para o MongoDB se necessário
foreach (var ChannelData in channelsList)
{
if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _))
{
ChannelData.Id = ObjectId.GenerateNewId().ToString();
}
}
// Verifica canais existentes pelo ID do YouTube
var youtubeIds = channelsList.Select(c => c.YoutubeId).ToList();
var existingChannels = await _channelsCollection
.Find(c => youtubeIds.Contains(c.YoutubeId))
.ToListAsync();
var existingYoutubeIds = existingChannels.Select(c => c.YoutubeId).ToHashSet();
var newChannels = channelsList.Where(c => !existingYoutubeIds.Contains(c.YoutubeId)).ToList();
if (newChannels.Any())
await _channelsCollection.InsertManyAsync(newChannels);
return newChannels;
}
/// <summary>
/// Atualiza um canal existente
/// </summary>
public async Task<bool> UpdateAsync(ChannelData ChannelData)
{
if (string.IsNullOrEmpty(ChannelData.Id) || !ObjectId.TryParse(ChannelData.Id, out _))
return false;
var result = await _channelsCollection.ReplaceOneAsync(
c => c.Id == ChannelData.Id,
ChannelData,
new ReplaceOptions { IsUpsert = false });
return result.IsAcknowledged && result.ModifiedCount > 0;
}
/// <summary>
/// Remove um canal pelo ID do MongoDB
/// </summary>
public async Task<bool> DeleteAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
return false;
var result = await _channelsCollection.DeleteOneAsync(c => c.Id == id);
return result.IsAcknowledged && result.DeletedCount > 0;
}
/// <summary>
/// Remove um canal pelo ID do YouTube
/// </summary>
public async Task<bool> DeleteByYoutubeIdAsync(string youtubeId)
{
var result = await _channelsCollection.DeleteOneAsync(c => c.YoutubeId == youtubeId);
return result.IsAcknowledged && result.DeletedCount > 0;
}
/// <summary>
/// Busca canais com base em um termo de pesquisa no título ou descrição
/// </summary>
public async Task<IEnumerable<ChannelData>> SearchAsync(string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return await GetAllAsync();
var filter = Builders<ChannelData>.Filter.Text(searchTerm);
return await _channelsCollection.Find(filter).ToListAsync();
}
/// <summary>
/// Obtém canais selecionados (IsSelected = true)
/// </summary>
public async Task<IEnumerable<ChannelData>> GetSelectedAsync()
{
return await _channelsCollection.Find(c => c.IsSelected).ToListAsync();
}
/// <summary>
/// Marca ou desmarca um canal como selecionado
/// </summary>
public async Task<bool> SetSelectedStatusAsync(string id, bool isSelected)
{
if (!ObjectId.TryParse(id, out _))
return false;
var update = Builders<ChannelData>.Update.Set(c => c.IsSelected, isSelected);
var result = await _channelsCollection.UpdateOneAsync(c => c.Id == id, update);
return result.IsAcknowledged && result.ModifiedCount > 0;
}
/// <summary>
/// Converte ChannelResponse para ChannelData
/// </summary>
public ChannelData ConvertFromResponse(ChannelResponse channelResponse)
{
if (channelResponse == null)
return null;
return new ChannelData
{
YoutubeId = channelResponse.Id,
Title = channelResponse.Title,
Description = channelResponse.Description,
ThumbnailUrl = channelResponse.ThumbnailUrl,
PublishedAt = channelResponse.PublishedAt,
SubscriberCount = channelResponse.SubscriberCount,
VideoCount = channelResponse.VideoCount,
IsSelected = channelResponse.IsSelected
};
}
/// <summary>
/// Converte ChannelData para ChannelResponse
/// </summary>
public ChannelResponse ConvertToResponse(ChannelData ChannelData)
{
if (ChannelData == null)
return null;
return new ChannelResponse
{
Id = ChannelData.YoutubeId,
Title = ChannelData.Title,
Description = ChannelData.Description,
ThumbnailUrl = ChannelData.ThumbnailUrl,
PublishedAt = ChannelData.PublishedAt,
SubscriberCount = ChannelData.SubscriberCount,
VideoCount = ChannelData.VideoCount,
IsSelected = ChannelData.IsSelected
};
}
}
}

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Infra.MongoDB.Settings
{
/// <summary>
/// Configuração do MongoDB
/// </summary>
public class MongoDbSettings
{
public string ConnectionString { get; set; }
public string DatabaseName { get; set; }
public string ChannelsCollectionName { get; set; }
}
}

View File

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Postall.Domain;
using Postall.Domain.Services.Contracts;
using Postall.Infra.Services;
namespace BaseDomain.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddYouTubeServices(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddScoped<IVideoService, YouTubeServiceVideo>();
services.AddScoped<IChannelService, ChannelVideoService>();
services.AddScoped<IChannelYoutubeService, ChannelYoutubeService>();
return services;
}
}
}

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.69.0.3707" />
<PackageReference Include="MongoDB.Bson" Version="3.1.0" />
<PackageReference Include="MongoDB.Driver" Version="3.1.0" />
<PackageReference Include="Serilog" Version="4.0.2" />

View File

@ -0,0 +1,96 @@
using BaseDomain.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Postall.Domain.Dtos;
using Postall.Domain.Services.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
namespace Postall.Infra.Services
{
public class ChannelYoutubeService: IChannelYoutubeService
{
private readonly IConfiguration _configuration;
private readonly string _apiKey;
public ChannelYoutubeService(IConfiguration configuration)
{
_configuration = configuration;
_apiKey = _configuration["YouTube:ApiKey"];
}
/// <summary>
/// Busca os detalhes de um canal na API do YouTube
/// </summary>
public async Task<Result<ChannelResponse>> GetChannelDetailsAsync(string channelId)
{
var youtubeService = new YouTubeBaseServiceWrapper(_apiKey);
var channelsRequest = youtubeService.Channels.List("snippet,statistics,contentDetails");
channelsRequest.Id = channelId;
var response = await channelsRequest.ExecuteAsync();
if (response.Items.Count == 0)
throw new Exception($"Canal não encontrado: {channelId}");
var channel = response.Items[0];
return new ChannelResponse
{
Id = channel.Id,
Title = channel.Snippet.Title,
Description = channel.Snippet.Description,
ThumbnailUrl = channel.Snippet.Thumbnails.Default__.Url,
PublishedAt = channel.Snippet.PublishedAt.Value,
SubscriberCount = channel.Statistics.SubscriberCount.GetValueOrDefault(),
VideoCount = channel.Statistics.VideoCount.GetValueOrDefault()
};
}
/// <summary>
/// Busca um canal pelo nome ou ID
/// </summary>
public async Task<Result<List<ChannelResponse>>> SearchChannelsAsync(string query, int maxResults = 5)
{
if (string.IsNullOrEmpty(query) || query.Length < 3)
return new List<ChannelResponse>();
var youtubeService = new YouTubeBaseServiceWrapper(_apiKey);
var searchRequest = youtubeService.Search.List("snippet");
searchRequest.Q = query;
searchRequest.Type = "channel";
searchRequest.MaxResults = maxResults;
var searchResponse = await searchRequest.ExecuteAsync();
var results = new List<ChannelResponse>();
foreach (var item in searchResponse.Items)
{
results.Add(new ChannelResponse
{
Id = item.Id.ChannelId,
Title = item.Snippet.Title,
Description = item.Snippet.Description,
ThumbnailUrl = item.Snippet.Thumbnails.Default__.Url,
PublishedAt = item.Snippet.PublishedAt.Value
});
}
return results;
}
}
// Classe auxiliar para dados básicos de canal
public class ChannelData
{
public string Id { get; set; }
public string Title { get; set; }
}
}

View File

@ -2,7 +2,7 @@
using MongoDB.Driver;
using Postall.Domain.Dtos.Responses;
using Postall.Domain.Entities;
using Postall.Domain.Services;
using Postall.Domain.Services.Contracts;
using System;
using System.Collections.Generic;
using System.Linq;

View File

@ -0,0 +1,227 @@
using Google.Apis.Services;
using Google.Apis.YouTube.v3;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Postall.Domain.Services.Contracts;
using Postall.Infra.Dtos;
using System.Security.Claims;
namespace Postall.Infra.Services
{
public class YouTubeServiceVideo: IVideoService
{
private readonly IConfiguration _configuration;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly string _apiKey;
private readonly string _clientId;
private readonly string _clientSecret;
public YouTubeServiceVideo(IConfiguration configuration, IHttpContextAccessor httpContextAccessor)
{
_configuration = configuration;
_httpContextAccessor = httpContextAccessor;
_apiKey = _configuration["YouTube:ApiKey"];
_clientId = _configuration["Authentication:Google:ClientId"];
_clientSecret = _configuration["Authentication:Google:ClientSecret"];
}
/// <summary>
/// Obtém o ID do usuário atual a partir das claims
/// </summary>
private string GetCurrentUserId()
{
var user = _httpContextAccessor.HttpContext.User;
// Primeiro tentar obter o NameIdentifier (que foi adicionado no ExternalLoginCallback)
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Se não encontrou, tenta usar o email como identificador
if (string.IsNullOrEmpty(userId))
{
var email = user.FindFirst(ClaimTypes.Email)?.Value;
if (!string.IsNullOrEmpty(email))
{
// Gerar ID baseado no email (mesmo método usado no ExternalLoginCallback)
userId = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(email)
).Replace("/", "_").Replace("+", "-").Replace("=", "");
}
}
return userId;
}
/// <summary>
/// Obtém os vídeos dos canais que o usuário tem acesso, ordenados por data
/// </summary>
public async Task<List<VideoItemResponse>> GetUserChannelVideosAsync(int maxResults = 10)
{
// Obter o ID do usuário das claims
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId))
return new List<VideoItemResponse>();
// Obter os canais do usuário do banco de dados
var userChannels = await GetUserChannelsAsync(userId);
if (!userChannels.Any())
return new List<VideoItemResponse>();
var videos = new List<VideoItemResponse>();
// Para cada canal, buscar os vídeos recentes
foreach (var channelId in userChannels)
{
var channelVideos = await GetChannelVideosAsync(channelId, maxResults);
videos.AddRange(channelVideos);
}
// Ordenar por data de publicação e pegar os mais recentes
return videos
.OrderByDescending(v => v.PublishedAt)
.Take(maxResults)
.ToList();
}
/// <summary>
/// Obtém os vídeos de um canal específico
/// </summary>
public async Task<List<VideoItemResponse>> GetChannelVideosAsync(string channelId, int maxResults = 10)
{
try
{
var youtubeService = new YouTubeBaseServiceWrapper(_apiKey);
// Primeiro, buscar os IDs dos últimos vídeos do canal
var searchRequest = youtubeService.Search.List("snippet");
searchRequest.ChannelId = channelId;
searchRequest.Order = SearchResource.ListRequest.OrderEnum.Date;
searchRequest.MaxResults = maxResults;
searchRequest.Type = "video";
var searchResponse = await searchRequest.ExecuteAsync();
if (searchResponse.Items == null || searchResponse.Items.Count == 0)
return new List<VideoItemResponse>();
// Obter os IDs dos vídeos
var videoIds = searchResponse.Items.Select(i => i.Id.VideoId).ToList();
// Buscar detalhes dos vídeos
var videosRequest = youtubeService.Videos.List("snippet,contentDetails,statistics");
videosRequest.Id = string.Join(",", videoIds);
var videosResponse = await videosRequest.ExecuteAsync();
// Converter para o modelo
var videos = new List<VideoItemResponse>();
foreach (var videoItem in videosResponse.Items)
{
var searchItem = searchResponse.Items.FirstOrDefault(i => i.Id.VideoId == videoItem.Id);
videos.Add(new VideoItemResponse
{
Id = videoItem.Id,
Title = videoItem.Snippet.Title,
Description = videoItem.Snippet.Description,
ThumbnailUrl = videoItem.Snippet.Thumbnails.High.Url,
PublishedAt = videoItem.Snippet.PublishedAt.Value,
ChannelId = videoItem.Snippet.ChannelId,
ChannelTitle = videoItem.Snippet.ChannelTitle
});
}
return videos;
}
catch (Exception ex)
{
// Em produção, use um logger adequado
Console.WriteLine($"Erro ao obter vídeos do canal {channelId}: {ex.Message}");
return new List<VideoItemResponse>();
}
}
/// <summary>
/// Método para buscar os canais que o usuário gerencia ou tem acesso
/// Em um cenário real, isso viria do banco de dados
/// </summary>
private async Task<List<string>> GetUserChannelsAsync(string userId)
{
// TODO: Substituir isso por uma consulta real ao banco de dados
// Em produção, você deve implementar uma tabela que relacione
// os usuários com seus canais do YouTube
// Para teste, aqui estão alguns canais populares
// Na implementação real, você deve armazenar os canais que o usuário tem permissão
return new List<string>
{
"UC_x5XG1OV2P6uZZ5FSM9Ttw", // Google Developers
"UCt84aUC9OG6di8kSdKzEHTQ", // Outro canal de exemplo
"UCkw4JCwteGrDHIsyIIKo4tQ" // Google
};
}
/// <summary>
/// Salva um vídeo para o usuário atual
/// Em produção, isso salvaria no banco de dados
/// </summary>
public async Task SaveVideoForUserAsync(string videoId)
{
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(videoId))
return;
// TODO: Salvar no banco de dados a relação entre o usuário e o vídeo
// Código de exemplo:
// await _dbContext.UserVideos.AddAsync(new UserVideo
// {
// UserId = userId,
// VideoId = videoId,
// DateAdded = DateTime.UtcNow
// });
// await _dbContext.SaveChangesAsync();
}
/// <summary>
/// Remove um vídeo do usuário atual
/// Em produção, isso removeria do banco de dados
/// </summary>
public async Task RemoveVideoForUserAsync(string videoId)
{
var userId = GetCurrentUserId();
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(videoId))
return;
// TODO: Remover do banco de dados a relação entre o usuário e o vídeo
// Código de exemplo:
// var userVideo = await _dbContext.UserVideos
// .FirstOrDefaultAsync(uv => uv.UserId == userId && uv.VideoId == videoId);
// if (userVideo != null)
// {
// _dbContext.UserVideos.Remove(userVideo);
// await _dbContext.SaveChangesAsync();
// }
}
}
/// <summary>
/// Wrapper para o serviço base do YouTube
/// </summary>
public class YouTubeBaseServiceWrapper : YouTubeService
{
public YouTubeBaseServiceWrapper(string apiKey)
: base(new BaseClientService.Initializer()
{
ApiKey = apiKey,
ApplicationName = "PostAll"
})
{
}
}
}

View File

@ -0,0 +1,98 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Postall.Domain.Services.Contracts;
using Postall.Infra.Services;
namespace Postall.Controllers
{
[Authorize]
public class ChannelsController : Controller
{
private readonly IChannelService _channelService;
public ChannelsController(IChannelService channelService)
{
_channelService = channelService;
}
public async Task<IActionResult> Index()
{
var userChannels = await _channelService.GetUserChannelsAsync();
return View(userChannels);
}
[HttpGet]
public IActionResult Add()
{
return View();
}
[HttpPost]
public async Task<IActionResult> Add(string channelId)
{
if (string.IsNullOrEmpty(channelId))
{
TempData["Error"] = "ID do canal é obrigatório";
return RedirectToAction("Add");
}
try
{
var success = await _channelService.AddChannelAsync(channelId);
if (success.IsSuccess)
{
TempData["Message"] = "Canal adicionado com sucesso!";
return RedirectToAction("Index");
}
TempData["Error"] = "Não foi possível adicionar o canal. Ele já existe ou é inválido.";
return RedirectToAction("Add");
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao adicionar canal: {ex.Message}";
return RedirectToAction("Add");
}
}
[HttpPost]
public async Task<IActionResult> Remove(string id)
{
try
{
var success = await _channelService.RemoveChannel(id);
if (success.IsSuccess)
TempData["Message"] = "Canal removido com sucesso!";
else
TempData["Error"] = "Não foi possível remover o canal.";
}
catch (Exception ex)
{
TempData["Error"] = $"Erro ao remover canal: {ex.Message}";
}
return RedirectToAction("Index");
}
[HttpGet]
public async Task<IActionResult> Search(string query)
{
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 _channelService.SearchChannelsAsync(query);
return Json(new { success = true, data = results });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
}
}

View File

@ -44,63 +44,70 @@ namespace Postall.Controllers
return Challenge(properties, "Google");
}
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult> ExternalLoginCallback(string code="")
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult> ExternalLoginCallback(string code = "")
{
//TODO: Temporário
var emailExist = HttpContext.User.FindFirst(ClaimTypes.Email).Value;
if (emailExist != null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, emailExist),
var emailExist = HttpContext.User.FindFirst(ClaimTypes.Email).Value;
if (emailExist != null)
{
var uniqueId = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(emailExist)
).Replace("/", "_").Replace("+", "-").Replace("=", "");
var claims = new List<Claim>
{
// Adicionando o NameIdentifier que serve como userId
new Claim(ClaimTypes.NameIdentifier, uniqueId),
// Claims existentes
new Claim(ClaimTypes.Name, emailExist),
new Claim(ClaimTypes.Email, emailExist), // Garantindo que o email esteja nas claims
new Claim("FirstName", HttpContext.User.FindFirst(ClaimTypes.GivenName).Value),
new Claim("FullName", HttpContext.User.FindFirst(ClaimTypes.GivenName).Value + " " + HttpContext.User.FindFirst(ClaimTypes.Surname).Value),
new Claim(ClaimTypes.Role, "User"),
};
new Claim(ClaimTypes.Role, "User"),
// Opcionalmente, adicionar informações do Google que serão úteis para o YouTube
new Claim("GoogleAccount", "true")
};
var claimsIdentity = new ClaimsIdentity(
claims,
CookieAuthenticationDefaults.AuthenticationScheme
);
var claimsIdentity = new ClaimsIdentity(
claims,
CookieAuthenticationDefaults.AuthenticationScheme
);
var authProperties = new AuthenticationProperties
{
//AllowRefresh = <bool>,
// Refreshing the authentication session should be allowed.
var authProperties = new AuthenticationProperties
{
//AllowRefresh = <bool>,
// Refreshing the authentication session should be allowed.
//ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
// The time at which the authentication ticket expires. A
// value set here overrides the ExpireTimeSpan option of
// CookieAuthenticationOptions set with AddCookie.
//IsPersistent = true,
// Whether the authentication session is persisted across
// multiple requests. When used with cookies, controls
// whether the cookie's lifetime is absolute (matching the
// lifetime of the authentication ticket) or session-based.
//IssuedUtc = <DateTimeOffset>,
// The time at which the authentication ticket was issued.
//RedirectUri = <string>
// The full path or absolute URI to be used as an http
// redirect response value.
};
//ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(10),
// The time at which the authentication ticket expires. A
// value set here overrides the ExpireTimeSpan option of
// CookieAuthenticationOptions set with AddCookie.
//IsPersistent = true,
// Whether the authentication session is persisted across
// multiple requests. When used with cookies, controls
// whether the cookie's lifetime is absolute (matching the
// lifetime of the authentication ticket) or session-based.
//IssuedUtc = <DateTimeOffset>,
// The time at which the authentication ticket was issued.
//RedirectUri = <string>
// The full path or absolute URI to be used as an http
// redirect response value.
};
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(claimsIdentity),
authProperties);
return RedirectToAction("Index", "Startup");
}
ViewBag.ErrorTitle = $"Email claim not received from: Microsoft";
ViewBag.ErrorMessage = "Please contact support on info@dotnettutorials.net";
return View("Error");
}
ViewBag.ErrorTitle = $"Email claim not received from: Microsoft";
ViewBag.ErrorMessage = "Please contact support on info@dotnettutorials.net";
return View("Error");
}
[HttpGet]
public async Task<IActionResult> Logout()

View File

@ -2,7 +2,7 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Facebook;
using Microsoft.AspNetCore.Mvc;
using Postall.Domain.Services;
using Postall.Domain.Services.Contracts;
using System.Security.Claims;
namespace Postall.Controllers

View File

@ -0,0 +1,75 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Postall.Models;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Collections.Generic;
using Postall.Models;
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<VideoViewModel> GetSampleVideos()
{
return new List<VideoViewModel>
{
new VideoViewModel
{
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)
}
};
}
public IActionResult Index()
{
var userVideos = GetSampleVideos();
return View(userVideos);
}
[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);
}
[HttpPost]
public IActionResult AddVideos(string[] selectedVideos)
{
if (selectedVideos == null || selectedVideos.Length == 0)
{
return RedirectToAction("Index");
}
// Em um cenário real, você salvaria esses IDs no banco de dados
// Aqui apenas redirecionamos de volta para o Index
TempData["Message"] = $"{selectedVideos.Length} vídeo(s) adicionado(s) com sucesso!";
return RedirectToAction("Index");
}
}
}

View File

@ -0,0 +1,21 @@
using System;
namespace Postall.Models
{
public class VideoViewModel
{
public string Id { 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}";
}
}

View File

@ -34,6 +34,7 @@
<ItemGroup>
<ProjectReference Include="..\Postall.Domain\Postall.Domain.csproj" />
<ProjectReference Include="..\Postall.Infra.MongoDB\Postall.Infra.MongoDB.csproj" />
<ProjectReference Include="..\Postall.Infra\Postall.Infra.csproj" />
</ItemGroup>

View File

@ -1,10 +1,11 @@
using BaseDomain.Extensions;
using Blinks.LogConfig;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.Google;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Options;
using Postall.Domain.Services;
using Postall.Domain.Services.Contracts;
using Postall.Infra.Services;
using Serilog;
using Serilog.Sinks.Grafana.Loki;
@ -12,6 +13,8 @@ using Stripe;
using Stripe.Forwarding;
using System.Globalization;
using System.Security.Policy;
using Postall.Infra.MongoDB.Extensions;
using Postall.Infra.MongoDB.Settings;
var builder = WebApplication.CreateBuilder(args);
@ -88,8 +91,15 @@ builder.Services.AddControllersWithViews();
builder.Services.AddHttpClient();
builder.Services.AddSerilog();
builder.Services.AddYouTubeServices();
builder.Services.AddRepositories();
builder.Services.AddScoped<IFacebookServices, FacebookTokenService>();
builder.Services.Configure<MongoDbSettings>(
builder.Configuration.GetSection("MongoDbSettings"));
var app = builder.Build();
var locOptions = app.Services.GetService<IOptions<RequestLocalizationOptions>>();

View File

@ -16,7 +16,7 @@
"ASPNETCORE_ENVIRONMENT": "Development"
},
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7078;http://localhost:5094"
"applicationUrl": "https://localhost:7078"
},
"IIS Express": {
"commandName": "IISExpress",

View File

@ -0,0 +1,167 @@
@{
ViewData["Title"] = "Adicionar Canal";
}
<div class="container mt-4">
<h2>Adicionar Canal</h2>
<div class="row mt-4">
<div class="col-md-8">
@if (TempData["Error"] != null)
{
<div class="alert alert-danger">
@TempData["Error"]
</div>
}
<div class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="channelAddTabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="search-tab" data-toggle="tab" href="#search" role="tab" aria-controls="search" aria-selected="true">
Buscar Canal
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="id-tab" data-toggle="tab" href="#id" role="tab" aria-controls="id" aria-selected="false">
Inserir ID do Canal
</a>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="channelAddTabsContent">
<div class="tab-pane fade show active" id="search" role="tabpanel" aria-labelledby="search-tab">
<div class="form-group">
<label for="channelSearch">Buscar Canal</label>
<div class="input-group">
<input type="text" class="form-control" id="channelSearch" placeholder="Digite o nome do canal..." minlength="3">
<div class="input-group-append">
<button class="btn btn-primary" type="button" id="searchButton">
<i class="bi bi-search"></i> Buscar
</button>
</div>
</div>
<small class="form-text text-muted">Insira pelo menos 3 caracteres para buscar.</small>
</div>
<div id="searchResults" class="mt-4"></div>
</div>
<div class="tab-pane fade" id="id" role="tabpanel" aria-labelledby="id-tab">
<form asp-action="Add" method="post">
<div class="form-group">
<label for="channelId">ID do Canal</label>
<input type="text" class="form-control" id="channelId" name="channelId" required
placeholder="Ex: UCkw4JCwteGrDHIsyIIKo4tQ">
<small class="form-text text-muted">
O ID do canal pode ser encontrado na URL do canal, ex: youtube.com/channel/<strong>UCkw4JCwteGrDHIsyIIKo4tQ</strong>
</small>
</div>
<button type="submit" class="btn btn-primary">Adicionar Canal</button>
</form>
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="@Url.Action("Index", "Channels")" class="btn btn-link">
<i class="bi bi-arrow-left"></i> Voltar para Meus Canais
</a>
</div>
</div>
<div class="col-md-4">
<div class="card bg-light">
<div class="card-body">
<h5 class="card-title">Dica</h5>
<p class="card-text">
Adicione os canais do YouTube que você gerencia ou tem permissão para acessar.
Depois de adicionar os canais, você poderá ver os vídeos deles na seção "Vídeos".
</p>
<h6 class="mt-3">Como encontrar o ID do canal?</h6>
<ol class="small pl-3">
<li>Visite o canal no YouTube</li>
<li>Observe a URL: <code>youtube.com/channel/ID-DO-CANAL</code></li>
<li>Copie o ID que aparece após "/channel/"</li>
</ol>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script type="text/javascript">
$(function() {
$('#searchButton').click(function() {
searchChannels();
});
$('#channelSearch').keypress(function(e) {
if (e.which === 13) {
e.preventDefault();
searchChannels();
}
});
function searchChannels() {
var query = $('#channelSearch').val().trim();
if (query.length < 3) {
alert('Por favor, insira pelo menos 3 caracteres para buscar.');
return;
}
$('#searchResults').html('<div class="text-center"><div class="spinner-border text-primary" role="status"></div><p class="mt-2">Buscando canais...</p></div>');
$.ajax({
url: '@Url.Action("Search", "Channels")',
type: 'GET',
data: { query: query },
success: function(response) {
if (response.success) {
displaySearchResults(response.data);
} else {
$('#searchResults').html('<div class="alert alert-danger">' + response.message + '</div>');
}
},
error: function() {
$('#searchResults').html('<div class="alert alert-danger">Erro ao buscar canais. Tente novamente.</div>');
}
});
}
function displaySearchResults(channels) {
if (!channels || channels.length === 0) {
$('#searchResults').html('<div class="alert alert-info">Nenhum canal encontrado com este termo.</div>');
return;
}
var html = '<h5>Resultados da busca</h5><div class="list-group">';
channels.forEach(function(channel) {
html += '<div class="list-group-item list-group-item-action">' +
'<div class="d-flex w-100">' +
'<div class="mr-3"><img src="' + channel.thumbnailUrl + '" alt="' + channel.title + '" width="50" class="rounded"></div>' +
'<div>' +
'<h6 class="mb-1">' + channel.title + '</h6>' +
'<p class="mb-1 small text-muted">' + (channel.description ? (channel.description.substring(0, 100) + '...') : '') + '</p>' +
'<form action="@Url.Action("Add", "Channels")" method="post">' +
'<input type="hidden" name="channelId" value="' + channel.id + '">' +
'<div class="d-flex justify-content-between align-items-center mt-2">' +
'<small class="text-muted">ID: ' + channel.id + '</small><br/>' +
'<button type="submit" class="btn btn-sm btn-primary">Adicionar</button>' +
'</div>' +
'</form>' +
'</div>' +
'</div>' +
'</div>';
});
html += '</div>';
$('#searchResults').html(html);
}
});
</script>
}

View File

@ -0,0 +1,93 @@
@using Postall.Domain.Dtos
@model List<ChannelResponse>
@{
ViewData["Title"] = "Meus Canais";
}
<div class="container mt-4">
<div class="row mb-4">
<div class="col-md-8">
<h2>Meus Canais</h2>
<p class="text-muted">Gerencie seus canais do YouTube</p>
</div>
<div class="col-md-4 text-right">
<a href="@Url.Action("Add", "Channels")" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Adicionar Canal
</a>
</div>
</div>
@if (TempData["Message"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Message"]
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
}
<div class="row">
@if (Model != null && Model.Any())
{
foreach (var channel in Model)
{
<div class="col-md-6 mb-4">
<div class="card h-100">
<div class="card-body">
<div class="row">
<div class="col-md-4">
<img src="@channel.ThumbnailUrl" alt="@channel.Title" class="img-fluid rounded">
</div>
<div class="col-md-8">
<h5 class="card-title">@channel.Title</h5>
<p class="card-text text-muted small">
@(channel.Description?.Length > 100 ? channel.Description.Substring(0, 100) + "..." : channel.Description)
</p>
<div class="d-flex justify-content-between">
<small class="text-muted">
<i class="bi bi-people"></i> @channel.SubscriberCount.ToString("N0") inscritos
</small>
<small class="text-muted">
<i class="bi bi-collection-play"></i> @channel.VideoCount.ToString("N0") vídeos
</small>
</div>
</div>
</div>
</div>
<div class="card-footer bg-white d-flex justify-content-between">
<a href="@channel.ChannelUrl" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-youtube"></i> Ver no YouTube
</a>
<form asp-action="Remove" asp-controller="Channels" method="post"
onsubmit="return confirm('Tem certeza que deseja remover este canal da sua lista?');">
<input type="hidden" name="id" value="@channel.Id" />
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> Remover
</button>
</form>
</div>
</div>
</div>
}
}
else
{
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Você ainda não possui canais. Clique em "Adicionar Canal" para começar.
</div>
</div>
}
</div>
</div>

View File

@ -12,6 +12,30 @@
<link rel="stylesheet" href="~/Postall.styles.css" asp-append-version="true" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
@await RenderSectionAsync("Styles", required: false)
<style>
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.loading .spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
</style>
</head>
<body class="hide-body">
<partial name="_Busy" />
@ -26,14 +50,17 @@
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="Plans" asp-action="Index">Planos</a>
</li>
@if (User!=null && User.Identity!=null && User.Identity.IsAuthenticated)
{
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="Channels" asp-action="Index">Canais</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="Videos" asp-action="Index">Videos</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" asp-area="" asp-controller="OtherLogins" asp-action="Index">Sites</a>
</li>
@ -102,8 +129,26 @@
$(function () {
$(document).ready(function () {
$('.loading').hide();
$('#wrapper').show();
//$('body').fadeIn(1000);
$('body').slideDown('slow');
// Intercepta cliques em links
$('a:not([target="_blank"]):not([href^="#"]):not([href^="javascript"])').click(function(e) {
if (this.hostname === window.location.hostname) {
e.preventDefault();
const href = $(this).attr('href');
// Adiciona fade out
$('#wrapper').addClass('fade-out');
$('.loading').show();
// Navega após a animação
setTimeout(() => {
window.location.href = href;
}, 300);
}
});
});
$('a[href="#search"]').on('click', function (event) {
@ -132,7 +177,7 @@
$(document).ready(function () {
$('#wrapper').fadeIn('slow');
$('a.nav-link').click(function () {
$('#navbarNav a.nav-link').click(function () {
if ($(this).hasClass('dropdown-toggle')) {
return;
}
@ -184,6 +229,19 @@
$('body').slideUp('slow');
}
// Remove fade-out quando a página carrega
$(window).on('pageshow', function() {
$('#wrapper').removeClass('fade-out');
$('.loading').hide();
setActiveByLocation();
});
// Gerencia submissão de formulários
$(document).on('submit', 'form', function () {
$('#wrapper').addClass('fade-out');
$('.loading').show();
});
//$(".hide-body").fadeOut(2000);
});
</script>

View File

@ -0,0 +1,169 @@
@model List<Postall.Models.VideoViewModel>
@{
ViewData["Title"] = "Gerenciador de Vídeos";
}
<div class="container mt-4">
<div class="row mb-4">
<div class="col-md-8">
<h2>Meus Vídeos</h2>
<p class="text-muted">Gerencie seus vídeos do YouTube</p>
</div>
<div class="col-md-4 text-right">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#addVideosModal">
<i class="bi bi-plus-circle"></i> Adicionar Vídeos
</button>
</div>
</div>
@if (TempData["Message"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Message"]
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
}
<div class="row">
@if (Model != null && Model.Any())
{
foreach (var video in Model)
{
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<h5 class="mb-0">@video.Title</h5>
<button class="btn btn-sm btn-link" type="button" data-toggle="collapse"
data-target="#collapse-@video.Id" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</button>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<img src="@video.ThumbnailUrl" alt="@video.Title" class="img-fluid rounded">
</div>
<div class="col-md-7">
<p class="card-text">@(video.Description?.Length > 100 ? video.Description.Substring(0, 100) + "..." : video.Description)</p>
<p class="text-muted small">Publicado em: @video.PublishedAt.ToString("dd/MM/yyyy")</p>
</div>
</div>
</div>
<div class="collapse" id="collapse-@video.Id">
<div class="card-footer bg-white">
<div class="d-flex justify-content-between">
<div>
<a href="@video.VideoUrl" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-youtube"></i> Ver no YouTube
</a>
</div>
<div>
<button class="btn btn-sm btn-outline-secondary mr-1">
<i class="bi bi-pencil"></i> Editar
</button>
<button class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> Remover
</button>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
else
{
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Você ainda não possui vídeos. Clique em "Adicionar Vídeos" para começar.
</div>
</div>
}
</div>
</div>
<!-- Modal para adicionar vídeos -->
<div class="modal fade" id="addVideosModal" tabindex="-1" role="dialog" aria-labelledby="addVideosModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addVideosModalLabel">Adicionar Vídeos do YouTube</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div id="channelVideosContainer">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="sr-only">Carregando...</span>
</div>
<p class="mt-2">Carregando vídeos...</p>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary" id="btnAddSelectedVideos">Adicionar Selecionados</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script type="text/javascript">
$(function () {
// Carrega os vídeos do canal quando o modal é aberto
$('#addVideosModal').on('shown.bs.modal', function () {
loadChannelVideos();
});
// Função para carregar os vídeos do canal
function loadChannelVideos() {
$.ajax({
url: '@Url.Action("GetChannelVideos", "Videos")',
type: 'GET',
success: function (result) {
$('#channelVideosContainer').html(result);
},
error: function (error) {
$('#channelVideosContainer').html('<div class="alert alert-danger">Erro ao carregar vídeos. Tente novamente.</div>');
console.error('Erro:', error);
}
});
}
// Manipula o clique no botão de adicionar vídeos selecionados
$('#btnAddSelectedVideos').click(function () {
var selectedVideos = [];
// Coleta todos os checkboxes selecionados
$('.video-checkbox:checked').each(function () {
selectedVideos.push($(this).val());
});
if (selectedVideos.length === 0) {
alert('Selecione pelo menos um vídeo para adicionar.');
return;
}
// Cria um formulário para enviar os IDs dos vídeos selecionados
var form = $('<form action="@Url.Action("AddVideos", "Videos")" method="post"></form>');
// Adiciona inputs ocultos para cada vídeo selecionado
selectedVideos.forEach(function (videoId) {
form.append('<input type="hidden" name="selectedVideos" value="' + videoId + '">');
});
// Adiciona o formulário ao corpo do documento e o submete
$('body').append(form);
form.submit();
});
});
</script>
}

View File

@ -0,0 +1,51 @@
@model List<Postall.Models.VideoViewModel>
<form id="channelVideosForm">
<h6 class="mb-3">Últimos 10 vídeos disponíveis (ordenados por data):</h6>
@if (Model != null && Model.Any())
{
<div class="list-group">
@foreach (var video in Model)
{
<div class="list-group-item list-group-item-action flex-column align-items-start">
<div class="d-flex">
<div class="checkbox-container mr-3 mt-1">
<div class="form-check">
<input class="form-check-input video-checkbox" type="checkbox" value="@video.Id" id="video-@video.Id">
</div>
</div>
<div class="row w-100">
<div class="col-md-4">
<img src="@video.ThumbnailUrl" alt="@video.Title" class="img-fluid rounded">
</div>
<div class="col-md-8">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">@video.Title</h5>
<small>@video.PublishedAt.ToString("dd/MM/yyyy")</small>
</div>
<p class="mb-1">@video.Description</p>
<small class="text-muted">
<i class="bi bi-youtube"></i>
@(video.ChannelTitle ?? "Seu Canal")
</small>
</div>
</div>
</div>
</div>
}
</div>
}
else
{
<div class="alert alert-info">
Nenhum vídeo encontrado nos canais aos quais você tem acesso.
</div>
}
</form>
<style>
.checkbox-container {
min-width: 30px;
}
</style>

View File

@ -4,5 +4,10 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"MongoDbSettings": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "YouTubeChannelsDB",
"ChannelsCollectionName": "Channels"
}
}

View File

@ -21,5 +21,13 @@
"AppId": "963281005306692",
"AppSecret": "575839ccbb36d1457715f1f6dd0a8db9"
}
},
"Youtube": {
"ApiKey": "AIzaSyBwFTW5WRAaBupyTgayVIeaS3LjGn0gpNI"
},
"MongoDbSettings": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "YouTubeChannelsDB",
"ChannelsCollectionName": "Channels"
}
}

View File

@ -0,0 +1,82 @@
/* Estilos para a página de vídeos */
.card {
transition: all 0.3s ease;
border: 1px solid rgba(0,0,0,.125);
}
.card:hover {
box-shadow: 0 5px 15px rgba(0,0,0,.1);
}
.card-header button.btn-link {
text-decoration: none;
color: #212529;
}
.card-header button.btn-link:hover {
color: #007bff;
}
.card-body img {
max-height: 120px;
object-fit: cover;
width: 100%;
}
.video-checkbox {
cursor: pointer;
width: 20px;
height: 20px;
}
.list-group-item {
transition: background-color 0.2s ease;
}
.list-group-item:hover {
background-color: rgba(0,123,255,.03);
}
.list-group-item img {
max-height: 100px;
object-fit: cover;
}
/* Animação para o colapso */
.collapse {
transition: all 0.3s ease;
}
/* Estilo para o botão de expansão do card */
.card-header button.btn-link {
transform: rotate(0deg);
transition: transform 0.3s ease;
}
.card-header button.btn-link[aria-expanded="true"] i {
transform: rotate(180deg);
}
/* Ajustes para responsividade */
@media (max-width: 768px) {
.col-md-6.mb-4 {
padding-left: 5px;
padding-right: 5px;
}
.card-body .row {
flex-direction: column;
}
.card-body .col-md-5,
.card-body .col-md-7 {
width: 100%;
max-width: 100%;
flex: 0 0 100%;
}
.card-body .col-md-5 {
margin-bottom: 15px;
}
}