diff --git a/README.md b/README.md index c80b49a..f5d20a3 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ A microservice-based video streaming platform built with .NET 8, React, and Post | Service | Port | Database | What it does | |---------|------|----------|--------------| | API Gateway (YARP) | 5050 | -- | Routes all requests, CORS whitelist, health checks | -| Account | 5001 (REST), 5003 (gRPC) | PostgreSQL :5432 | Auth, profiles, notifications, gRPC username server | -| Video Catalogue | 5002 | PostgreSQL :5433 | Upload, comments, playlists, subscriptions | +| Account | 5001 (REST), 5003 (gRPC) | PostgreSQL :5432 | Auth, profiles, notifications, user search, gRPC username server | +| Video Catalogue | 5002 | PostgreSQL :5433 | Upload, search, comments, playlists, subscriptions | | Donation | 5100 | PostgreSQL :5434 | Wallets, donations, balance transfers | | Chat | 5004 | PostgreSQL :5435 | Chat rooms, messages, real-time via SignalR | | RabbitMQ | 5672 / 15672 | -- | Async event broker (MassTransit) | @@ -158,6 +158,15 @@ Services read from environment variables first, then fall back to `appsettings.j - **Inter-service auth**: gRPC and HTTP calls between services require `INTERNAL_API_KEY` header - **Secrets**: No credentials in code or config files -- all in `.env` (gitignored) +## Search + +The platform supports searching across videos and channels from a single search bar. + +**Backend**: `GET /api/videos/search?q={query}&category={category}` +- Searches video titles and descriptions (case-insensitive, DB-level filtering) +- Optional `category` parameter to narrow results +- Returns videos with resolved uploader usernames (via gRPC) + ## Swagger docs | Service | URL | diff --git a/glense.client/src/App.jsx b/glense.client/src/App.jsx index 13256b5..2f4d199 100644 --- a/glense.client/src/App.jsx +++ b/glense.client/src/App.jsx @@ -9,7 +9,8 @@ import { Donations, Upload, Playlists, - PlaylistDetail + PlaylistDetail, + SearchResults } from "./components"; import Chat from "./components/Chat/Chat"; @@ -22,6 +23,7 @@ const App = () => { } /> + } /> } /> } /> } /> diff --git a/glense.client/src/components/SearchResults.jsx b/glense.client/src/components/SearchResults.jsx new file mode 100644 index 0000000..0a051a3 --- /dev/null +++ b/glense.client/src/components/SearchResults.jsx @@ -0,0 +1,148 @@ +import { useState, useEffect, useMemo } from "react"; +import { useParams, Link } from "react-router-dom"; +import { Box, Typography, Stack, CircularProgress, Avatar } from "@mui/material"; +import { CheckCircle } from "@mui/icons-material"; + +import { Videos } from "./"; +import { searchVideos } from "../utils/videoApi"; +import { profileService } from "../services/profileService"; +import { categories } from "../utils/constants"; + +import "../css/SearchResults.css"; + +const colors = ['#e91e63', '#9c27b0', '#673ab7', '#3f51b5', '#2196f3', '#00bcd4', '#009688', '#4caf50', '#ff9800', '#ff5722']; +function stringToColor(str) { + let h = 0; + for (let i = 0; i < (str || '').length; i++) h = str.charCodeAt(i) + ((h << 5) - h); + return colors[Math.abs(h) % colors.length]; +} + +function SearchResults() { + const { searchTerm } = useParams(); + const [videos, setVideos] = useState([]); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedCategory, setSelectedCategory] = useState(null); + + const filteredVideos = useMemo(() => { + if (!selectedCategory) return videos; + return videos.filter(v => v.category && v.category.toLowerCase() === selectedCategory.toLowerCase()); + }, [videos, selectedCategory]); + + useEffect(() => { + if (!searchTerm) return; + + let mounted = true; + setLoading(true); + setVideos([]); + setChannels([]); + setSelectedCategory(null); + + Promise.all([ + searchVideos(searchTerm).catch(() => []), + profileService.searchUsers(searchTerm).catch(() => []), + ]).then(([videoData, userData]) => { + if (!mounted) return; + if (Array.isArray(videoData)) setVideos(videoData); + if (Array.isArray(userData)) setChannels(userData); + setLoading(false); + }); + + return () => { mounted = false; }; + }, [searchTerm]); + + if (loading) { + return ( + + + + + + ); + } + + const noResults = videos.length === 0 && channels.length === 0; + + return ( + + + Search results for "{searchTerm}" + + + {noResults && ( + + No results found. Try a different search term. + + )} + + {channels.length > 0 && ( + + + Channels + + + {channels.map((user) => ( + + + + {(user.username || '?').charAt(0).toUpperCase()} + + + {user.username} + + + + + ))} + + + )} + + {videos.length > 0 && ( + + + Videos + + + + {categories.filter(c => c.name !== "New Videos").map((cat) => ( + + ))} + + {filteredVideos.length > 0 ? ( + + ) : ( + + No videos in this category. + + )} + + )} + + ); +} + +export default SearchResults; diff --git a/glense.client/src/components/Searchbar.jsx b/glense.client/src/components/Searchbar.jsx index 69df6c9..aab0d0d 100644 --- a/glense.client/src/components/Searchbar.jsx +++ b/glense.client/src/components/Searchbar.jsx @@ -12,7 +12,8 @@ function SearchBar() { const handleSubmit = (e) => { e.preventDefault(); - navigate(`/search/${searchTerm}`); + if (!searchTerm.trim()) return; + navigate(`/search/${encodeURIComponent(searchTerm)}`); setSearchTerm(""); }; @@ -30,7 +31,7 @@ function SearchBar() { onChange={(e) => setSearchTerm(e.target.value)} /> - + diff --git a/glense.client/src/components/VideoCard.jsx b/glense.client/src/components/VideoCard.jsx index bfc59e9..d1d2d80 100644 --- a/glense.client/src/components/VideoCard.jsx +++ b/glense.client/src/components/VideoCard.jsx @@ -40,9 +40,9 @@ function VideoCard({ video }) { {title.slice(0, 80) + (title.length > 80 ? "..." : "")} - + - {demoChannelTitle} + {video?.uploaderUsername || demoChannelTitle} diff --git a/glense.client/src/components/VideoStream.jsx b/glense.client/src/components/VideoStream.jsx index ec3e053..c93206f 100644 --- a/glense.client/src/components/VideoStream.jsx +++ b/glense.client/src/components/VideoStream.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useParams, Link } from "react-router-dom"; import ReactPlayer from "react-player"; import { Typography, Box, Stack, Button, FormControl, Select, MenuItem, Avatar, Snackbar } from "@mui/material"; @@ -13,7 +13,7 @@ import { } from "@mui/icons-material"; import { Videos, VideoComments } from "."; -import { getVideo, getVideos, getPlaylists, addVideoToPlaylist, likeVideo, updateVideoCategory } from "../utils/videoApi"; +import { getVideo, getVideos, getPlaylists, addVideoToPlaylist, likeVideo, getUserLike, updateVideoCategory, incrementView } from "../utils/videoApi"; import { categories } from "../utils/constants"; import { useAuth } from "../context/AuthContext"; @@ -42,6 +42,7 @@ function VideoStream() { const [userLike, setUserLike] = useState(null); const [editingCategory, setEditingCategory] = useState(false); const { user } = useAuth(); + const viewCounted = useRef(false); const isOwner = user && video && String(user.id) === String(video.uploaderId); @@ -52,8 +53,12 @@ function VideoStream() { getVideos().then(list => { if (mounted && Array.isArray(list)) setRelated(list.filter(v => String(v.id) !== String(id)).slice(0, 12)); }).catch(() => {}); setUserLike(null); setShowMoreDesc(false); + viewCounted.current = false; + if (user && id) { + getUserLike(id).then(data => { if (mounted && data?.liked != null) setUserLike(data.liked); }).catch(() => {}); + } return () => { mounted = false; }; - }, [id]); + }, [id, user]); useEffect(() => { let mounted = true; @@ -120,11 +125,17 @@ function VideoStream() { controls width="100%" height="100%" + onStart={() => { + if (!viewCounted.current) { + viewCounted.current = true; + incrementView(id).catch(() => {}); + } + }} /> {video?.title || demoVideoInfo.title} - + {(video?.uploaderUsername || video?.title || '?').charAt(0).toUpperCase()} diff --git a/glense.client/src/components/index.js b/glense.client/src/components/index.js index d390dcb..755ff51 100644 --- a/glense.client/src/components/index.js +++ b/glense.client/src/components/index.js @@ -20,4 +20,5 @@ export { default as DonationModal } from './Donations/DonationModal' export { default as DonationHistory } from './Donations/DonationHistory' export { default as Upload } from './Upload' export { default as PlaylistDetail } from './PlaylistDetail' -export { default as Playlists } from './Playlists' \ No newline at end of file +export { default as Playlists } from './Playlists' +export { default as SearchResults } from './SearchResults' \ No newline at end of file diff --git a/glense.client/src/css/SearchResults.css b/glense.client/src/css/SearchResults.css new file mode 100644 index 0000000..b0d2578 --- /dev/null +++ b/glense.client/src/css/SearchResults.css @@ -0,0 +1,92 @@ +.search-results-container { + padding: var(--spacing-xl); + padding-top: calc(var(--navbar-height) + var(--spacing-xl)); + min-height: 100vh; + background-color: var(--color-bg-primary); +} + +.search-results-loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 50vh; +} + +.search-results-heading { + color: var(--color-text-primary) !important; + margin-bottom: var(--spacing-xl) !important; + font-weight: bold !important; +} + +.search-results-empty { + color: var(--color-text-secondary) !important; + font-size: var(--font-size-lg) !important; + text-align: center; + margin-top: var(--spacing-xl) !important; +} + +.search-results-section { + margin-bottom: var(--spacing-xl); +} + +.search-results-section-title { + color: var(--color-text-primary) !important; + margin-bottom: var(--spacing-base) !important; + font-weight: bold !important; +} + +.search-results-categories { + flex-wrap: wrap !important; + gap: var(--spacing-sm) !important; + margin-bottom: var(--spacing-lg); +} + +.search-results-channels { + flex-wrap: wrap !important; + gap: var(--spacing-lg) !important; + margin-bottom: var(--spacing-lg); +} + +.search-results-channel-link { + text-decoration: none; +} + +.search-results-channel-card { + padding: var(--spacing-base); + border-radius: var(--radius-md); + transition: background-color var(--transition-fast); + min-width: 100px; + align-items: center; +} + +.search-results-channel-card:hover { + background-color: var(--color-bg-overlay); +} + +.search-results-channel-name { + color: var(--color-text-primary) !important; + margin-top: var(--spacing-sm) !important; + display: flex !important; + align-items: center !important; + font-size: var(--font-size-sm) !important; +} + +/* Tablet (1024px) */ +@media screen and (max-width: 1024px) { + .search-results-container { + padding: var(--spacing-lg); + padding-top: calc(var(--navbar-height-tablet) + var(--spacing-lg)); + } +} + +/* Mobile (600px) */ +@media screen and (max-width: 600px) { + .search-results-container { + padding: var(--spacing-base); + padding-top: calc(var(--navbar-height-mobile) + var(--spacing-base)); + } + + .search-results-heading { + font-size: var(--font-size-xl) !important; + } +} diff --git a/glense.client/src/utils/videoApi.js b/glense.client/src/utils/videoApi.js index 5cae3de..152b968 100644 --- a/glense.client/src/utils/videoApi.js +++ b/glense.client/src/utils/videoApi.js @@ -23,6 +23,18 @@ export async function getVideo(id) { return handleRes(res); } +export async function searchVideos(query, category) { + const params = new URLSearchParams({ q: query }); + if (category) params.append('category', category); + const res = await fetch(`${BASE}/api/videos/search?${params}`); + return handleRes(res); +} + +export async function incrementView(videoId) { + const res = await fetch(`${BASE}/api/videos/${videoId}/view`, { method: 'PATCH' }); + return handleRes(res); +} + export async function updateVideoCategory(videoId, category) { const res = await fetch(`${BASE}/api/videos/${videoId}/category`, { method: 'PATCH', @@ -81,6 +93,13 @@ export async function removeVideoFromPlaylist(playlistId, videoId) { return handleRes(res); } +export async function getUserLike(videoId) { + const res = await fetch(`${BASE}/api/videolikes/${videoId}`, { + headers: authHeaders(), + }); + return handleRes(res); +} + export async function likeVideo(videoId, isLiked = true) { const res = await fetch(`${BASE}/api/videolikes`, { method: 'POST', @@ -156,11 +175,14 @@ export default { getPlaylistVideos, removeVideoFromPlaylist, likeVideo, + getUserLike, subscribeTo, unsubscribeFrom, getSubscriptions, getComments, postComment, likeComment, + searchVideos, + incrementView, updateVideoCategory, }; diff --git a/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs b/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs index 17a3e0d..4135598 100644 --- a/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs +++ b/services/Glense.VideoCatalogue/Controllers/VideoLikesController.cs @@ -24,6 +24,16 @@ private Guid GetCurrentUserId() return Guid.TryParse(claim, out var id) ? id : Guid.Empty; } + [Authorize] + [HttpGet("{videoId:guid}")] + public async Task GetUserLike(Guid videoId) + { + var userId = GetCurrentUserId(); + var existing = await _db.VideoLikes.FirstOrDefaultAsync(l => l.UserId == userId && l.VideoId == videoId); + if (existing == null) return Ok(new { liked = (bool?)null }); + return Ok(new { liked = existing.IsLiked }); + } + [Authorize] [HttpPost] public async Task Like([FromBody] DTOs.LikeRequestDTO dto) diff --git a/services/Glense.VideoCatalogue/Controllers/VideosController.cs b/services/Glense.VideoCatalogue/Controllers/VideosController.cs index f711edb..9091330 100644 --- a/services/Glense.VideoCatalogue/Controllers/VideosController.cs +++ b/services/Glense.VideoCatalogue/Controllers/VideosController.cs @@ -10,6 +10,7 @@ using Glense.VideoCatalogue.Services; using System.Linq; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; namespace Glense.VideoCatalogue.Controllers; [ApiController] @@ -21,19 +22,22 @@ public class VideosController : ControllerBase private readonly IVideoStorage _storage; private readonly IAccountGrpcClient _accountClient; private readonly ILogger _logger; + private readonly IMemoryCache _viewCache; public VideosController( Upload uploader, VideoCatalogueDbContext db, IVideoStorage storage, IAccountGrpcClient accountClient, - ILogger logger) + ILogger logger, + IMemoryCache viewCache) { _uploader = uploader; _db = db; _storage = storage; _accountClient = accountClient; _logger = logger; + _viewCache = viewCache; } private Guid GetCurrentUserId() @@ -76,6 +80,45 @@ public async Task Upload([FromForm] DTOs.UploadRequestDTO dto) return CreatedAtAction(nameof(Get), new { id = resp.Id }, resp); } + [HttpGet("search")] + public async Task Search([FromQuery] string q = "", [FromQuery] string? category = null) + { + if (string.IsNullOrWhiteSpace(q)) + return Ok(Array.Empty()); + + var query = _db.Videos.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(v => v.Category == category); + + var lowerQ = q.ToLower(); + query = query.Where(v => + v.Title.ToLower().Contains(lowerQ) || + (v.Description != null && v.Description.ToLower().Contains(lowerQ))); + var matched = await query.OrderByDescending(v => v.ViewCount).ToListAsync(); + + var uploaderIds = matched.Select(v => v.UploaderId).ToList(); + var usernames = await _accountClient.GetUsernamesAsync(uploaderIds); + + var results = matched.Select(video => new DTOs.UploadResponseDTO + { + Id = video.Id, + Title = video.Title, + Description = video.Description, + VideoUrl = video.VideoUrl, + ThumbnailUrl = ResolveThumbnailUrl(video.Id, video.ThumbnailUrl), + UploadDate = video.UploadDate, + UploaderId = video.UploaderId, + UploaderUsername = usernames.GetValueOrDefault(video.UploaderId), + ViewCount = video.ViewCount, + LikeCount = video.LikeCount, + DislikeCount = video.DislikeCount, + Category = video.Category + }).ToList(); + + return Ok(results); + } + [HttpGet] public async Task List() { @@ -129,6 +172,24 @@ public async Task Get(Guid id) return Ok(resp); } + [HttpPatch("{id:guid}/view")] + public async Task IncrementView(Guid id) + { + var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var cacheKey = $"view:{ip}:{id}"; + if (_viewCache.TryGetValue(cacheKey, out _)) + return Ok(new { viewCount = -1 }); + + _viewCache.Set(cacheKey, true, TimeSpan.FromMinutes(30)); + + var rows = await _db.Videos.Where(v => v.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(v => v.ViewCount, v => v.ViewCount + 1)); + if (rows == 0) return NotFound(); + + var viewCount = await _db.Videos.Where(v => v.Id == id).Select(v => v.ViewCount).FirstAsync(); + return Ok(new { viewCount }); + } + [Authorize] [HttpPatch("{id:guid}/category")] public async Task UpdateCategory(Guid id, [FromBody] DTOs.UpdateCategoryDTO dto) diff --git a/services/Glense.VideoCatalogue/Program.cs b/services/Glense.VideoCatalogue/Program.cs index c37fe7e..e47a25c 100644 --- a/services/Glense.VideoCatalogue/Program.cs +++ b/services/Glense.VideoCatalogue/Program.cs @@ -112,6 +112,8 @@ }); }); +builder.Services.AddMemoryCache(); + // Health checks builder.Services.AddHealthChecks();