diff --git a/frontend/public/images/thumbnails/categories/education.webp b/frontend/public/images/thumbnails/categories/education.webp new file mode 100644 index 0000000..aad0bcc Binary files /dev/null and b/frontend/public/images/thumbnails/categories/education.webp differ diff --git a/frontend/src/components/Layout/ListRow.tsx b/frontend/src/components/Layout/ListRow.tsx index 3649fb2..4e15c95 100644 --- a/frontend/src/components/Layout/ListRow.tsx +++ b/frontend/src/components/Layout/ListRow.tsx @@ -68,7 +68,7 @@ const ListRow: React.FC = ({
{items.map((item) => ( = ({ streamId }) => { // Join chat room when component mounts useEffect(() => { if (socket && isConnected) { + // Join chat room socket.emit("join", { stream_id: streamId }); + // Handle beforeunload event + const handleBeforeUnload = () => { + socket.emit("leave", { stream_id: streamId }); + socket.disconnect(); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + // Load initial chat history fetch(`/api/chat/${streamId}`) .then((response) => { @@ -45,9 +54,11 @@ const ChatPanel: React.FC = ({ streamId }) => { setMessages((prev) => [...prev, data]); }); - // Cleanup + // Cleanup function return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); socket.emit("leave", { stream_id: streamId }); + socket.disconnect(); socket.off("new_message"); }; } diff --git a/frontend/src/context/SocketContext.tsx b/frontend/src/context/SocketContext.tsx index b75db69..203e401 100644 --- a/frontend/src/context/SocketContext.tsx +++ b/frontend/src/context/SocketContext.tsx @@ -1,5 +1,5 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; -import { io, Socket } from 'socket.io-client'; +import React, { createContext, useContext, useEffect, useRef, useState } from "react"; +import { io, Socket } from "socket.io-client"; interface SocketContextType { socket: Socket | null; @@ -8,39 +8,92 @@ interface SocketContextType { const SocketContext = createContext(undefined); -export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [socket, setSocket] = useState(null); const [isConnected, setIsConnected] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const socketRef = useRef(null); useEffect(() => { - const newSocket = io("http://localhost:8080", { - path: "/socket.io/", + console.log("Start of useEffect"); + + // Check if we already have a socket instance + if (socketRef.current) { + console.log("Socket already exists, closing existing socket"); + socketRef.current.close(); + } + + console.log("Creating new socket connection"); + const newSocket = io('http://localhost:8080', { + path: '/socket.io/', + transports: ['websocket', 'polling'], withCredentials: true, - transports: ['websocket'], - upgrade: false + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + reconnectionAttempts: 5, + timeout: 5000 }); - - newSocket.on('connect', () => { - console.log('Socket connected!'); - setIsConnected(true); - }); - - newSocket.on('connect_error', (error) => { - console.error('Socket connection error:', error); - }); - - newSocket.on('disconnect', () => { - console.log('Socket disconnected!'); - setIsConnected(false); - }); - + + socketRef.current = newSocket; setSocket(newSocket); + + newSocket.on("connect", () => { + console.log("Socket connected!"); + setIsConnected(true); + setIsLoading(false); + }); + + newSocket.on("reconnect_attempt", (attemptNumber) => { + console.log(`Reconnecting... Attempt ${attemptNumber}`); + }); + + newSocket.on("reconnect_error", (error) => { + console.error("Reconnection error:", error); + }); + + newSocket.on("reconnect", (attemptNumber) => { + console.log(`Reconnected after ${attemptNumber} attempts!`); + }); + + newSocket.on("reconnect_failed", () => { + console.error("Reconnection failed. Please refresh the page."); + }); + + newSocket.on("connect_error", (error) => { + console.error("Socket connection error:", error); + setIsLoading(false); + if (newSocket) newSocket.disconnect(); + newSocket.connect(); + }); + + newSocket.on("disconnect", (reason) => { + console.log( + "Socket disconnected! Reason: " + reason + " - Attempting reconnect..." + ); + setIsConnected(false); + newSocket.connect(); + }); return () => { - newSocket.close(); + if (socketRef.current) { + console.log("Cleaning up socket connection..."); + socketRef.current.disconnect(); + socketRef.current.close(); + socketRef.current = null; + } }; }, []); + if (isLoading) { + return ( +
+
Connecting to socket...
+
+ ); + } + return ( {children} @@ -51,7 +104,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr export const useSocket = () => { const context = useContext(SocketContext); if (context === undefined) { - throw new Error('useSocket must be used within a SocketProvider'); + throw new Error("useSocket must be used within a SocketProvider"); } return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/context/StreamsContext.tsx b/frontend/src/context/StreamsContext.tsx index 5a1d69e..a211992 100644 --- a/frontend/src/context/StreamsContext.tsx +++ b/frontend/src/context/StreamsContext.tsx @@ -48,8 +48,15 @@ export function StreamsProvider({ children }: { children: React.ReactNode }) { title: stream.title, streamer: stream.username, viewers: stream.num_viewers, - thumbnail: stream.thumbnail, + thumbnail: + stream.thumbnail || + `/images/thumbnails/categories/${stream.category_name + .toLowerCase() + .replace(/ /g, "_")}.webp`, + category: stream.category_name, })); + + console.log(extractedData); setFeaturedStreams(extractedData); }); @@ -57,15 +64,15 @@ export function StreamsProvider({ children }: { children: React.ReactNode }) { fetch(fetch_url[1]) .then((response) => response.json()) .then((data: CategoryItem[]) => { - const extractedData: CategoryItem[] = data.map( - (category: any) => ({ - type: "category", - id: category.category_id, - title: category.category_name, - viewers: category.num_viewers, - thumbnail: `/images/thumbnails/categories/${category.category_name.toLowerCase().replace(/ /g, "_")}.webp` - }) - ); + const extractedData: CategoryItem[] = data.map((category: any) => ({ + type: "category", + id: category.category_id, + title: category.category_name, + viewers: category.num_viewers, + thumbnail: `/images/thumbnails/categories/${category.category_name + .toLowerCase() + .replace(/ /g, "_")}.webp`, + })); console.log(extractedData); setFeaturedCategories(extractedData); }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d0db8b3..9bc1251 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,7 +4,7 @@ import './assets/styles/index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + // - , + // , ) diff --git a/frontend/src/pages/VideoPage.tsx b/frontend/src/pages/VideoPage.tsx index c4ccbef..533a97d 100644 --- a/frontend/src/pages/VideoPage.tsx +++ b/frontend/src/pages/VideoPage.tsx @@ -23,24 +23,24 @@ interface StreamDataProps { const VideoPage: React.FC = ({ streamId }) => { const { isLoggedIn } = useAuth(); - const [showCheckout, setShowCheckout] = useState(false); + // const [showCheckout, setShowCheckout] = useState(false); const showReturn = window.location.search.includes("session_id"); const { streamerName } = useParams<{ streamerName: string }>(); const [streamData, setStreamData] = useState(); const navigate = useNavigate(); - useEffect(() => { - // Prevent scrolling when checkout is open - if (showCheckout) { - document.body.style.overflow = "hidden"; - } else { - document.body.style.overflow = "unset"; - } - // Cleanup function to ensure overflow is restored when component unmounts - return () => { - document.body.style.overflow = "unset"; - }; - }, [showCheckout]); + // useEffect(() => { + // // Prevent scrolling when checkout is open + // if (showCheckout) { + // document.body.style.overflow = "hidden"; + // } else { + // document.body.style.overflow = "unset"; + // } + // // Cleanup function to ensure overflow is restored when component unmounts + // return () => { + // document.body.style.overflow = "unset"; + // }; + // }, [showCheckout]); useEffect(() => { // Fetch stream data for this streamer fetch( @@ -83,7 +83,7 @@ const VideoPage: React.FC = ({ streamId }) => { > {isLoggedIn && (
- {showCheckout && setShowCheckout(false)} />} - {showReturn && } + {/* {showCheckout && setShowCheckout(false)} />} */} + {/* {showReturn && } */} ); }; diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 199c658..13d7b88 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -43,6 +43,23 @@ http { listen 8080; root /var/www; + location /api/ { + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://web_server:5000; # flask-app is the name of the Flask container in docker-compose + } + + location /socket.io/ { + proxy_pass http://web_server:5000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_cache_bypass $http_upgrade; + } + + # The MPEG-TS video chunks are stored in /tmp/hls location ~ ^/stream/user/(.+\.ts)$ { alias /tmp/hls/$1; @@ -59,19 +76,6 @@ http { expires -1d; } - location /api/ { - rewrite ^/api/(.*)$ /$1 break; - proxy_pass http://web_server:5000; # flask-app is the name of the Flask container in docker-compose - } - - location /socket.io/ { - proxy_pass http://web_server:5000/socket.io/; - proxy_http_version 1.1; - proxy_buffering off; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "Upgrade"; - } - location / { proxy_pass http://frontend:5173; # frontend is the name of the React container in docker-compose } diff --git a/web_server/Dockerfile b/web_server/Dockerfile index e4d1d2d..93bbc35 100644 --- a/web_server/Dockerfile +++ b/web_server/Dockerfile @@ -16,4 +16,4 @@ COPY . . ENV FLASK_APP=blueprints.__init__ ENV FLASK_DEBUG=True -CMD ["gunicorn", "-b", "0.0.0.0:5000", "blueprints.__init__:create_app()"] +CMD ["python", "-c", "from blueprints.socket import socketio; from blueprints.__init__ import create_app; app = create_app(); socketio.run(app, host='0.0.0.0', port=5000, debug=True)"] \ No newline at end of file diff --git a/web_server/blueprints/__init__.py b/web_server/blueprints/__init__.py index a494d87..6b36f7a 100644 --- a/web_server/blueprints/__init__.py +++ b/web_server/blueprints/__init__.py @@ -1,7 +1,7 @@ from flask import Flask from flask_session import Session from flask_cors import CORS -from blueprints.utils import logged_in_user +from blueprints.utils import logged_in_user, record_time from blueprints.errorhandlers import register_error_handlers # from flask_wtf.csrf import CSRFProtect, generate_csrf @@ -9,7 +9,8 @@ from blueprints.authentication import auth_bp from blueprints.stripe import stripe_bp from blueprints.user import user_bp from blueprints.streams import stream_bp -from blueprints.chat import chat_bp, socketio +from blueprints.chat import chat_bp +from blueprints.socket import socketio from os import getenv @@ -29,8 +30,11 @@ def create_app(): CORS(app, supports_credentials=True) # csrf.init_app(app) + socketio.init_app(app) + Session(app) app.before_request(logged_in_user) + app.after_request(record_time) # adds in error handlers register_error_handlers(app) @@ -48,7 +52,6 @@ def create_app(): app.register_blueprint(stream_bp) app.register_blueprint(chat_bp) - # Tell sockets where the initialisation app is - socketio.init_app(app, cors_allowed_origins="*") + socketio.init_app(app) return app diff --git a/web_server/blueprints/chat.py b/web_server/blueprints/chat.py index 0ace4a9..2222731 100644 --- a/web_server/blueprints/chat.py +++ b/web_server/blueprints/chat.py @@ -1,11 +1,10 @@ from flask import Blueprint, jsonify, session from database.database import Database -from flask_socketio import SocketIO, emit, join_room, leave_room +from .socket import socketio +from flask_socketio import emit, join_room, leave_room from datetime import datetime -from flask_socketio import SocketIO chat_bp = Blueprint("chat", __name__) -socketio = SocketIO() # <---------------------- ROUTES NEEDS TO BE CHANGED TO VIDEO OR DELETED AS DEEMED APPROPRIATE ----------------------> diff --git a/web_server/blueprints/socket.py b/web_server/blueprints/socket.py new file mode 100644 index 0000000..41a9794 --- /dev/null +++ b/web_server/blueprints/socket.py @@ -0,0 +1,3 @@ +from flask_socketio import SocketIO + +socketio = SocketIO(cors_allowed_origins="*", async_mode='gevent', logger=True, engineio_logger=True) diff --git a/web_server/blueprints/streams.py b/web_server/blueprints/streams.py index e81082b..1013778 100644 --- a/web_server/blueprints/streams.py +++ b/web_server/blueprints/streams.py @@ -120,7 +120,7 @@ def get_following_categories_streams(): """ Returns popular streams in categories which the user followed """ - streams = followed_categories_recommendations() + streams = followed_categories_recommendations(get_user_id(session.get('username'))) return jsonify(streams) diff --git a/web_server/blueprints/utils.py b/web_server/blueprints/utils.py index b808fdb..1528ffb 100644 --- a/web_server/blueprints/utils.py +++ b/web_server/blueprints/utils.py @@ -1,14 +1,24 @@ from flask import redirect, url_for, request, g, session from functools import wraps from re import match +from time import time def logged_in_user(): """ Validator to make sure a user is logged in. """ + g.start_time = time() g.user = session.get("username", None) g.admin = session.get("username", None) +def record_time(response): + if hasattr(g, 'start_time'): + time_taken = time() - g.start_time + print(f"Request to {request.endpoint} took {time_taken:.4f} seconds", flush=True) + else: + print("No start time found", flush=True) + return response + def login_required(view): """ Add at start of routes where users need to be logged in to access. diff --git a/web_server/database/app.db b/web_server/database/app.db index c7a7cf1..b83ae03 100644 Binary files a/web_server/database/app.db and b/web_server/database/app.db differ diff --git a/web_server/database/testing_data.sql b/web_server/database/testing_data.sql index 5873c31..4fcf8cf 100644 --- a/web_server/database/testing_data.sql +++ b/web_server/database/testing_data.sql @@ -47,7 +47,8 @@ INSERT INTO subscribes (user_id, subscribed_id, since, expires) VALUES (5, 105, '2024-08-30', '2025-02-28'); INSERT INTO users (username, password, email, num_followers, stream_key, is_partnered, bio) VALUES -('GamerDude2', 'password123', 'gamerdude3@gmail.com', 3200, '7890', 0, 'Streaming my gaming adventures!'); +('GamerDude2', 'password123', 'gamerdude3@gmail.com', 3200, '7890', 0, 'Streaming my gaming adventures!'), +('dev', 'scrypt:32768:8:1$avr94c5cplosNUDc$f2ba0738080facada51a1ed370bf869199e121e547fe64a7094ef0330b5db2ab7fff87700898729977f4cd24f17c17b9e8c0c93e7241dcdf9aa522d5d1732626', 'dev@gmail.com', 1, '8080', 0, 'A test account to save that tedious signup each time!'); INSERT INTO chat (stream_id, chatter_id, message) VALUES (1, 'Susan', 'Hey Every, loving the stream'), @@ -67,17 +68,4 @@ SELECT * FROM stream_tags; -- To see all tables in the database SELECT name FROM sqlite_master WHERE type='table'; - -SELECT isLive FROM streams WHERE user_id = '5'; - - - -SELECT * -FROM ( - SELECT chatter_id, message, time_sent - FROM chat - WHERE stream_id = 1 - ORDER BY time_sent DESC - LIMIT 50 -) -ORDER BY time_sent ASC \ No newline at end of file +INSERT INTO users \ No newline at end of file diff --git a/web_server/database/users.sql b/web_server/database/users.sql index c395bb0..331401e 100644 --- a/web_server/database/users.sql +++ b/web_server/database/users.sql @@ -1,6 +1,3 @@ --- View all tables in the database -SELECT name FROM sqlite_master WHERE type='table'; - DROP TABLE IF EXISTS users; CREATE TABLE users ( diff --git a/web_server/requirements.txt b/web_server/requirements.txt index 0c7d06e..14a4a9e 100644 --- a/web_server/requirements.txt +++ b/web_server/requirements.txt @@ -21,4 +21,6 @@ typing_extensions==4.12.2 urllib3==2.3.0 Werkzeug==3.1.3 WTForms==3.2.1 -Gunicorn==20.1.0 \ No newline at end of file +Gunicorn==20.1.0 +gevent>=22.10.2 +gevent-websocket \ No newline at end of file diff --git a/web_server/utils/recommendation_utils.py b/web_server/utils/recommendation_utils.py index 092dbc7..b934537 100644 --- a/web_server/utils/recommendation_utils.py +++ b/web_server/utils/recommendation_utils.py @@ -15,20 +15,22 @@ def user_recommendation_category(user_id: int) -> Optional[int]: """, (user_id,)) return data -def followed_categories_recommendations(user_id: int): +#TODO Needs to be reworked to get categories instead of streams of categories (below can be done in another function - get_streams_by_category) +def followed_categories_recommendations(user_id : int): """ Returns top 25 streams given a users category following """ with Database() as db: categories = db.fetchall(""" - SELECT users.user_id, title, username, num_viewers, category_name - FROM streams - WHERE category_id IN (SELECT category_id FROM categories WHERE user_id = ?) - ORDER BY num_viewers DESC - LIMIT 25; - """, (user_id,)) + SELECT user_id, title, num_viewers, categories.category_name + FROM streams + JOIN categories ON streams.category_id = categories.category_id + WHERE category_id IN (SELECT category_id FROM categories WHERE user_id = ?) + ORDER BY num_viewers DESC + LIMIT 25; """, (user_id,)) return categories +#TODO Needs to be reworked to get categories instead of streams of categories def recommendations_based_on_category(category_id: int) -> Optional[List[Tuple[int, str, int]]]: """ Queries stream database to get top 25 most viewed streams based on given category and returns @@ -36,14 +38,14 @@ def recommendations_based_on_category(category_id: int) -> Optional[List[Tuple[i """ with Database() as db: data = db.fetchall(""" - SELECT users.user_id, title, username, num_viewers, category_name - FROM streams - JOIN users ON users.user_id = streams.user_id - JOIN categories ON streams.category_id = categories.category_id - WHERE categories.category_id = ? - ORDER BY num_viewers DESC - LIMIT 25 - """, (category_id,)) + SELECT streams.category_id, streams.user_id, streams.title, users.username, streams.num_viewers, categories.category_name + FROM streams + JOIN users ON users.user_id = streams.user_id + JOIN categories ON streams.category_id = categories.category_id + WHERE categories.category_id = ? + ORDER BY num_viewers DESC + LIMIT 25 + """, (category_id,)) return data def default_recommendations(): @@ -53,13 +55,13 @@ def default_recommendations(): """ with Database() as db: data = db.fetchall(""" - SELECT users.user_id, title, username, num_viewers, category_name - FROM streams - JOIN users ON users.user_id = streams.user_id - JOIN categories ON streams.category_id = categories.category_id - ORDER BY num_viewers DESC - LIMIT 25; - """) + SELECT stream_id, users.user_id, title, username, num_viewers, category_name + FROM streams + JOIN users ON users.user_id = streams.user_id + JOIN categories ON streams.category_id = categories.category_id + ORDER BY num_viewers DESC + LIMIT 25; + """) return data def category_recommendations():