Add project files.

This commit is contained in:
Ricardo Carneiro 2025-05-20 21:45:49 -03:00
parent 7b5e677c96
commit 7b13d0ba7b
19 changed files with 637 additions and 0 deletions

25
YTSearch.sln Normal file
View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.13.35818.85 d17.13
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "YTSearch", "YTSearch\YTSearch.csproj", "{35BF7008-0A54-4797-B94A-14CB360C63E1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{35BF7008-0A54-4797-B94A-14CB360C63E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35BF7008-0A54-4797-B94A-14CB360C63E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35BF7008-0A54-4797-B94A-14CB360C63E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35BF7008-0A54-4797-B94A-14CB360C63E1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FC9E4038-AEC5-4DE9-995B-6DC9F570B4F7}
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,90 @@
using MongoDB.Driver;
using YTSearch.Contracts;
using YTSearch.Models;
namespace YTSearch.AppServices
{
public class QuotaService : IQuotaService
{
private readonly IMongoCollection<QuotaLimit> _quotaCollection;
private const int SEARCH_COST = 100;
private const int VIDEO_DETAILS_COST = 1;
public QuotaService(IMongoDatabase database)
{
_quotaCollection = database.GetCollection<QuotaLimit>("quota_limits");
}
public async Task<bool> CanUseQuotaAsync(int cost)
{
await ResetQuotaIfNeededAsync();
var quota = await GetCurrentQuotaAsync();
return (quota.CurrentUsage + cost) <= quota.DailyLimit;
}
public async Task UseQuotaAsync(int cost)
{
var filter = Builders<QuotaLimit>.Filter.Eq(x => x.Id, "youtube_quota");
var update = Builders<QuotaLimit>.Update
.Inc(x => x.CurrentUsage, cost)
.Set(x => x.LastUpdated, DateTime.UtcNow);
await _quotaCollection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
}
public async Task<QuotaStatus> GetQuotaStatusAsync()
{
await ResetQuotaIfNeededAsync();
var quota = await GetCurrentQuotaAsync();
var pacificTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"));
var nextReset = pacificTime.Date.AddDays(1);
return new QuotaStatus
{
DailyLimit = quota.DailyLimit,
CurrentUsage = quota.CurrentUsage,
Remaining = Math.Max(0, quota.DailyLimit - quota.CurrentUsage),
LastReset = quota.LastReset,
IsExceeded = quota.CurrentUsage >= quota.DailyLimit,
ResetTime = nextReset.ToString("yyyy-MM-dd HH:mm:ss") + " PT"
};
}
public async Task UpdateDailyLimitAsync(long newLimit)
{
var filter = Builders<QuotaLimit>.Filter.Eq(x => x.Id, "youtube_quota");
var update = Builders<QuotaLimit>.Update
.Set(x => x.DailyLimit, newLimit)
.Set(x => x.LastUpdated, DateTime.UtcNow);
await _quotaCollection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
}
public async Task ResetQuotaIfNeededAsync()
{
var quota = await GetCurrentQuotaAsync();
var pacificTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow,
TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time"));
// Reset at midnight Pacific Time
if (quota.LastReset.Date < pacificTime.Date)
{
var filter = Builders<QuotaLimit>.Filter.Eq(x => x.Id, "youtube_quota");
var update = Builders<QuotaLimit>.Update
.Set(x => x.CurrentUsage, 0)
.Set(x => x.LastReset, pacificTime.Date)
.Set(x => x.LastUpdated, DateTime.UtcNow);
await _quotaCollection.UpdateOneAsync(filter, update);
}
}
private async Task<QuotaLimit> GetCurrentQuotaAsync()
{
var quota = await _quotaCollection.Find(x => x.Id == "youtube_quota").FirstOrDefaultAsync();
return quota ?? new QuotaLimit();
}
}
}

View File

@ -0,0 +1,166 @@
using Google.Apis.Services;
using Google.Apis.YouTube.v3;
using YoutubeExplode;
using YoutubeExplode.Common;
using YTSearch.Contracts;
using YTSearch.Models;
namespace YTSearch.AppServices
{
public class YouTubeSearchService : IYouTubeSearchService
{
private readonly IQuotaService _quotaService;
private readonly IConfiguration _configuration;
private readonly YouTubeService _youtubeService;
private readonly YoutubeClient _youtubeClient;
public YouTubeSearchService(IQuotaService quotaService, IConfiguration configuration)
{
_quotaService = quotaService;
_configuration = configuration;
var apiKey = _configuration["GoogleAPiKey"];
_youtubeService = new YouTubeService(new BaseClientService.Initializer()
{
ApiKey = apiKey,
ApplicationName = "YouTube Search API"
});
_youtubeClient = new YoutubeClient();
}
public async Task<SearchResponse> SearchVideosAsync(SearchRequest request)
{
// Calculate quota cost: Search (100) + Video details (1 per video)
var quotaCost = 100 + request.MaxResults;
// Try YouTube Data API first if quota allows
if (await _quotaService.CanUseQuotaAsync(quotaCost))
{
try
{
var result = await SearchWithYouTubeApiAsync(request);
await _quotaService.UseQuotaAsync(quotaCost);
result.SearchMethod = "YouTube Data API";
result.Message = $"Used {quotaCost} quota units. Remaining: {(await _quotaService.GetQuotaStatusAsync()).Remaining}";
return result;
}
catch (Exception ex)
{
return await SearchWithYoutubeExplodeAsync(request, $"YouTube API failed: {ex.Message}. Using YoutubeExplode fallback.");
}
}
else
{
var quotaStatus = await _quotaService.GetQuotaStatusAsync();
return await SearchWithYoutubeExplodeAsync(request,
$"Quota exceeded ({quotaStatus.CurrentUsage}/{quotaStatus.DailyLimit}). Using YoutubeExplode fallback.");
}
}
private async Task<SearchResponse> SearchWithYouTubeApiAsync(SearchRequest request)
{
var searchRequest = _youtubeService.Search.List("snippet");
searchRequest.Q = request.Keywords;
searchRequest.MaxResults = request.MaxResults;
searchRequest.PageToken = request.PageToken;
searchRequest.Type = "video";
searchRequest.Order = SearchResource.ListRequest.OrderEnum.ViewCount;
searchRequest.VideoDuration = SearchResource.ListRequest.VideoDurationEnum.Medium |
SearchResource.ListRequest.VideoDurationEnum.Long__;
var searchResponse = await searchRequest.ExecuteAsync();
var videoIds = searchResponse.Items.Select(item => item.Id.VideoId).ToList();
var videosRequest = _youtubeService.Videos.List("snippet,statistics");
videosRequest.Id = string.Join(",", videoIds);
var videosResponse = await videosRequest.ExecuteAsync();
var videos = videosResponse.Items.Select(video => new VideoResult
{
Id = video.Id,
Title = video.Snippet.Title,
Description = video.Snippet.Description,
ThumbnailUrl = video.Snippet.Thumbnails.High?.Url ?? video.Snippet.Thumbnails.Default__.Url,
ChannelName = video.Snippet.ChannelTitle,
ChannelId = video.Snippet.ChannelId,
ViewCount = (long?) video.Statistics.ViewCount ?? 0,
LikeCount = (long?)video.Statistics.LikeCount ?? 0,
DislikeCount = (long?)video.Statistics.DislikeCount ?? 0,
PublishedAt = video.Snippet.PublishedAt ?? DateTime.MinValue,
VideoUrl = $"https://www.youtube.com/watch?v={video.Id}"
})
.OrderByDescending(v => v.ViewCount)
.ToList();
return new SearchResponse
{
Videos = videos,
NextPageToken = searchResponse.NextPageToken ?? string.Empty,
TotalResults = (int)(searchResponse.PageInfo.TotalResults ?? 0)
};
}
private async Task<SearchResponse> SearchWithYoutubeExplodeAsync(SearchRequest request, string message)
{
try
{
var skip = !string.IsNullOrEmpty(request.PageToken) ? int.Parse(request.PageToken) * request.MaxResults : 0;
var searchResults = _youtubeClient.Search.GetVideosAsync(request.Keywords);
var videos = new List<VideoResult>();
await foreach (var video in searchResults.Skip(skip).Take(request.MaxResults))
{
var videoDetails = await _youtubeClient.Videos.GetAsync(video.Id);
if (videoDetails.Duration?.TotalSeconds <= 60)
{
continue;
}
videos.Add(new VideoResult
{
Id = video.Id,
Title = video.Title,
Description = videoDetails.Description,
ThumbnailUrl = video.Thumbnails.GetWithHighestResolution().Url,
ChannelName = video.Author.ChannelTitle,
ChannelId = video.Author.ChannelId,
ViewCount = videoDetails.Engagement.ViewCount,
LikeCount = videoDetails.Engagement.LikeCount,
DislikeCount = videoDetails.Engagement.DislikeCount,
PublishedAt = videoDetails.UploadDate.DateTime,
VideoUrl = video.Url
});
}
// Sort by view count
videos = videos.OrderByDescending(v => v.ViewCount).ToList();
// Generate next page token
var nextPageToken = videos.Count == request.MaxResults ? (skip / request.MaxResults + 1).ToString() : string.Empty;
return new SearchResponse
{
Videos = videos,
NextPageToken = nextPageToken,
TotalResults = videos.Count,
SearchMethod = "YoutubeExplode",
Message = message
};
}
catch (Exception ex)
{
return new SearchResponse
{
Videos = new List<VideoResult>(),
SearchMethod = "YoutubeExplode",
Message = $"YoutubeExplode search failed: {ex.Message}"
};
}
}
}
}

View File

@ -0,0 +1,13 @@
using YTSearch.Models;
namespace YTSearch.Contracts
{
public interface IQuotaService
{
Task<bool> CanUseQuotaAsync(int cost);
Task UseQuotaAsync(int cost);
Task<QuotaStatus> GetQuotaStatusAsync();
Task UpdateDailyLimitAsync(long newLimit);
Task ResetQuotaIfNeededAsync();
}
}

View File

@ -0,0 +1,9 @@
using YTSearch.Models;
namespace YTSearch.Contracts
{
public interface IYouTubeSearchService
{
Task<SearchResponse> SearchVideosAsync(SearchRequest request);
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using YTSearch.Contracts;
using YTSearch.Models;
namespace YTSearch.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class QuotaController : ControllerBase
{
private readonly IQuotaService _quotaService;
public QuotaController(IQuotaService quotaService)
{
_quotaService = quotaService;
}
/// <summary>
/// Get current quota status
/// </summary>
[HttpGet("status")]
public async Task<ActionResult<QuotaStatus>> GetQuotaStatus()
{
var status = await _quotaService.GetQuotaStatusAsync();
return Ok(status);
}
/// <summary>
/// Update daily quota limit
/// </summary>
[HttpPost("update-limit")]
public async Task<ActionResult> UpdateQuotaLimit([FromBody] QuotaUpdateRequest request)
{
if (request.NewDailyLimit <= 0)
{
return BadRequest(new { message = "Daily limit must be greater than 0" });
}
await _quotaService.UpdateDailyLimitAsync(request.NewDailyLimit);
var updatedStatus = await _quotaService.GetQuotaStatusAsync();
return Ok(new
{
message = "Quota limit updated successfully",
newStatus = updatedStatus
});
}
/// <summary>
/// Force reset quota (for testing purposes)
/// </summary>
[HttpPost("reset")]
public async Task<ActionResult> ResetQuota()
{
await _quotaService.ResetQuotaIfNeededAsync();
var status = await _quotaService.GetQuotaStatusAsync();
return Ok(new
{
message = "Quota reset completed",
status = status
});
}
}
}

View File

@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using YTSearch.Contracts;
using YTSearch.Models;
namespace YTSearch.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class YouTubeController : ControllerBase
{
private readonly IYouTubeSearchService _youtubeService;
public YouTubeController(IYouTubeSearchService youtubeService)
{
_youtubeService = youtubeService;
}
/// <summary>
/// Search for YouTube videos by keywords
/// </summary>
/// <param name="keywords">Search keywords</param>
/// <param name="pageToken">Token for pagination (optional)</param>
/// <param name="maxResults">Maximum number of results (default: 20, max: 50)</param>
/// <returns>List of videos with details</returns>
[HttpGet("search")]
public async Task<ActionResult<SearchResponse>> SearchVideos(
[FromQuery] string keywords,
[FromQuery] string pageToken = "",
[FromQuery] int maxResults = 20)
{
if (string.IsNullOrWhiteSpace(keywords))
{
return BadRequest(new { message = "Keywords are required" });
}
if (maxResults > 50)
{
return BadRequest(new { message = "Maximum results cannot exceed 50" });
}
var request = new SearchRequest
{
Keywords = keywords,
PageToken = pageToken,
MaxResults = maxResults
};
var result = await _youtubeService.SearchVideosAsync(request);
return Ok(result);
}
/// <summary>
/// Search for YouTube videos using POST method
/// </summary>
[HttpPost("search")]
public async Task<ActionResult<SearchResponse>> SearchVideosPost([FromBody] SearchRequest request)
{
if (string.IsNullOrWhiteSpace(request.Keywords))
{
return BadRequest(new { message = "Keywords are required" });
}
if (request.MaxResults > 50)
{
return BadRequest(new { message = "Maximum results cannot exceed 50" });
}
var result = await _youtubeService.SearchVideosAsync(request);
return Ok(result);
}
}
}

View File

@ -0,0 +1,11 @@
namespace YTSearch.Models
{
public class QuotaLimit
{
public string Id { get; set; } = "youtube_quota";
public long DailyLimit { get; set; } = 10000;
public long CurrentUsage { get; set; } = 0;
public DateTime LastReset { get; set; } = DateTime.UtcNow;
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
}
}

View File

@ -0,0 +1,12 @@
namespace YTSearch.Models
{
public class QuotaStatus
{
public long DailyLimit { get; set; }
public long CurrentUsage { get; set; }
public long Remaining { get; set; }
public DateTime LastReset { get; set; }
public bool IsExceeded { get; set; }
public string ResetTime { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,8 @@
namespace YTSearch.Models
{
public class QuotaUpdateRequest
{
public long NewDailyLimit { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace YTSearch.Models
{
public class SearchRequest
{
public string Keywords { get; set; } = string.Empty;
public string PageToken { get; set; } = string.Empty;
public int MaxResults { get; set; } = 20;
}
}

View File

@ -0,0 +1,11 @@
namespace YTSearch.Models
{
public class SearchResponse
{
public List<VideoResult> Videos { get; set; } = new();
public string NextPageToken { get; set; } = string.Empty;
public int TotalResults { get; set; }
public string SearchMethod { get; set; } = string.Empty; // "YouTubeAPI" or "YoutubeExplode"
public string Message { get; set; } = string.Empty;
}
}

View File

@ -0,0 +1,17 @@
namespace YTSearch.Models
{
public class VideoResult
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string ThumbnailUrl { get; set; } = string.Empty;
public string ChannelName { get; set; } = string.Empty;
public string ChannelId { get; set; } = string.Empty;
public long ViewCount { get; set; }
public long LikeCount { get; set; }
public long DislikeCount { get; set; }
public DateTime PublishedAt { get; set; }
public string VideoUrl { get; set; } = string.Empty;
}
}

44
YTSearch/Program.cs Normal file
View File

@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver;
using YTSearch.AppServices;
using YTSearch.Contracts;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// MongoDB Configuration
builder.Services.AddSingleton<IMongoClient>(serviceProvider =>
{
var connectionString = builder.Configuration.GetConnectionString("MongoDB") ?? "mongodb://localhost:27017";
return new MongoClient(connectionString);
});
builder.Services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetService<IMongoClient>();
return client.GetDatabase("YouTubeSearchDB");
});
// Register Services
builder.Services.AddScoped<IQuotaService, QuotaService>();
builder.Services.AddScoped<IYouTubeSearchService, YouTubeSearchService>();
builder.Services.AddHttpClient();
var app = builder.Build();
// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:38465",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7183;http://localhost:5044",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

17
YTSearch/YTSearch.csproj Normal file
View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Apis.YouTube.v3" Version="1.69.0.3764" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
</ItemGroup>
</Project>

6
YTSearch/YTSearch.http Normal file
View File

@ -0,0 +1,6 @@
@YTSearch_HostAddress = http://localhost:5044
GET {{YTSearch_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

12
YTSearch/appsettings.json Normal file
View File

@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"GoogleClientId": "656198616765-06t0blmd1tlpcb9h81vgrshoqs4gd434.apps.googleusercontent.com",
"GoogleAPiKey": "AIzaSyB9YTCRcTzPHQkHW7zdwkYbpo0KON39ll8",
"GoogleClientSecret": "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",
"AllowedHosts": "*"
}