UPDATE/REFACTOR: Rework of UserPage;

FIX: Fallback for profile pictures;
REFACTOR: Removal of unnecessary files & routes;
REFACTOR: Improve documentation for flask routes;
FIX: Correct data to return when fetching vods;
This commit is contained in:
Chris-1010
2025-03-03 11:05:10 +00:00
parent 04d99928aa
commit 45a0f364a0
8 changed files with 138 additions and 255 deletions

View File

@@ -1,5 +0,0 @@
export const paths = {
pfps: "",
category_thumbnails: "",
icons: "",
};

View File

@@ -1,24 +0,0 @@
import { useEffect, useState } from "react"
export function useSameUser({ username }: { username: string | undefined }) {
const [isSame, setIsSame] = useState(false);
useEffect(() => {
const fetchStatus = async () => {
try {
const response = await fetch(`/api/user/same/${username}`);
if (!response.ok) {
throw new Error("Failed to validate user");
}
const data = await response.json();
setIsSame(data.same);
} catch (error) {
console.error("Error:", error);
}
};
fetchStatus();
}, []);
return isSame;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import AuthModal from "../components/Auth/AuthModal"; import AuthModal from "../components/Auth/AuthModal";
import { useAuthModal } from "../hooks/useAuthModal"; import { useAuthModal } from "../hooks/useAuthModal";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
@@ -10,8 +10,8 @@ import DynamicPageContent from "../components/Layout/DynamicPageContent";
import LoadingScreen from "../components/Layout/LoadingScreen"; import LoadingScreen from "../components/Layout/LoadingScreen";
import { StreamListItem } from "../components/Layout/ListItem"; import { StreamListItem } from "../components/Layout/ListItem";
import { EditIcon } from "lucide-react"; import { EditIcon } from "lucide-react";
import { getCategoryThumbnail } from "../utils/thumbnailUtils"; import ListRow from "../components/Layout/ListRow";
import { useSameUser } from "../hooks/useSameUser"; import { useStreams, useVods } from "../hooks/useContent";
interface UserProfileData { interface UserProfileData {
id: number; id: number;
@@ -20,22 +20,38 @@ interface UserProfileData {
followerCount: number; followerCount: number;
isPartnered: boolean; isPartnered: boolean;
isLive: boolean; isLive: boolean;
currentStreamTitle?: string;
currentStreamCategory?: string;
currentStreamViewers?: number;
currentStreamStartTime?: string;
currentStreamThumbnail?: string;
} }
const UserPage: React.FC = () => { const UserPage: React.FC = () => {
const [userPageVariant, setUserPageVariant] = useState<"personal" | "streamer" | "user" | "admin">("user"); const [userPageVariant, setUserPageVariant] = useState<"personal" | "user" | "admin">("user");
const [profileData, setProfileData] = useState<UserProfileData>(); const [profileData, setProfileData] = useState<UserProfileData>();
const { isFollowing, checkFollowStatus, followUser, unfollowUser } = useFollow(); const { isFollowing, checkFollowStatus, followUser, unfollowUser } = useFollow();
const { showAuthModal, setShowAuthModal } = useAuthModal(); const { showAuthModal, setShowAuthModal } = useAuthModal();
const { username: loggedInUsername } = useAuth(); const { username: loggedInUsername } = useAuth();
const { username } = useParams(); const { username } = useParams();
const isUser = useSameUser({ username }); const { vods } = useVods(`/api/vods/${username}`);
const navigate = useNavigate(); const navigate = useNavigate();
const { streams } = useStreams(`/api/streams/${username}/data`);
const currentStream = streams[0];
const fetchProfileData = useCallback(async () => {
try {
// Profile data
const profileResponse = await fetch(`/api/user/${username}`);
const profileData = await profileResponse.json();
setProfileData({
id: profileData.user_id,
username: profileData.username,
bio: profileData.bio || "This user hasn't written a bio yet.",
followerCount: profileData.num_followers || 0,
isPartnered: profileData.isPartnered || false,
isLive: profileData.is_live,
});
} catch (err) {
console.error("Error fetching profile data:", err);
window.location.href = "/404";
}
}, [username]);
// Saves uploaded image as profile picture for the user // Saves uploaded image as profile picture for the user
const saveUploadedImage = async (event: React.ChangeEvent<HTMLInputElement>) => { const saveUploadedImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -62,58 +78,20 @@ const UserPage: React.FC = () => {
} }
}; };
// Check if the current user is the currently logged-in user
useEffect(() => { useEffect(() => {
// Fetch user profile data if (username === loggedInUsername) setUserPageVariant("personal");
fetch(`/api/user/${username}`) // else if (data.isAdmin) setUserPageVariant("admin");
.then((res) => res.json()) else setUserPageVariant("user");
.then((data) => {
setProfileData({
id: data.user_id,
username: data.username,
bio: data.bio || "This user hasn't written a bio yet.",
followerCount: data.num_followers || 0,
isPartnered: data.isPartnered || false,
isLive: data.is_live,
currentStreamTitle: "",
currentStreamCategory: "",
currentStreamViewers: 0,
currentStreamThumbnail: "",
});
if (data.is_live) {
// Fetch stream data for this streamer
fetch(`/api/streams/${data.user_id}/data`)
.then((res) => res.json())
.then((streamData) => {
setProfileData((prevData) => {
if (!prevData) return prevData;
return {
...prevData,
currentStreamTitle: streamData.title,
currentStreamCategory: streamData.category_id,
currentStreamViewers: streamData.num_viewers,
currentStreamStartTime: streamData.start_time,
currentStreamThumbnail: getCategoryThumbnail(streamData.category_name, streamData.thumbnail),
};
});
let variant: "user" | "streamer" | "personal" | "admin";
if (username === loggedInUsername) variant = "personal";
else if (streamData.title) variant = "streamer";
// else if (data.isAdmin) variant = "admin";
else variant = "user";
setUserPageVariant(variant);
})
.catch((err) => console.error("Error fetching stream data:", err));
}
})
.catch((err) => {
console.error("Error fetching profile data:", err);
navigate("/404");
});
// Check if the *logged-in* user is following this user
if (loggedInUsername && username) checkFollowStatus(username); if (loggedInUsername && username) checkFollowStatus(username);
}, [username]); }, [username, loggedInUsername, checkFollowStatus]);
// Fetch user profile data
useEffect(() => {
if (!username) return;
fetchProfileData();
}, [fetchProfileData]);
if (!profileData) return <LoadingScreen />; if (!profileData) return <LoadingScreen />;
@@ -148,16 +126,21 @@ const UserPage: React.FC = () => {
} inset-0 z-20`} } inset-0 z-20`}
style={{ boxShadow: "var(--user-pfp-border-shadow)" }} style={{ boxShadow: "var(--user-pfp-border-shadow)" }}
> >
<label className={`w-full h-full ${isUser ? "group cursor-pointer" : ""} overflow-visible rounded-full`}> <label
className={`w-full h-full ${userPageVariant === "personal" ? "group cursor-pointer" : ""} overflow-visible rounded-full`}
>
{/* If user is live then displays a live div */} {/* If user is live then displays a live div */}
{profileData.isLive && ( {profileData.isLive ? (
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 bg-[#ff0000] text-white text-sm font-bold py-1 sm:px-5 px-4 z-30 flex items-center justify-center rounded-tr-xl rounded-bl-xl rounded-tl-xl rounded-br-xl"> <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 bg-[#ff0000] text-white text-sm font-bold py-1 sm:px-5 px-4 z-30 flex items-center justify-center rounded-tr-xl rounded-bl-xl rounded-tl-xl rounded-br-xl">
LIVE LIVE
</div> </div>
) : (
""
)} )}
<img <img
src={`/user/${profileData.username}/profile_picture`} src={`/user/${profileData.username}/profile_picture`}
onError={(e) => { onError={(e) => {
console.log("no error")
e.currentTarget.src = "/images/pfps/default.png"; e.currentTarget.src = "/images/pfps/default.png";
e.currentTarget.onerror = null; e.currentTarget.onerror = null;
}} }}
@@ -166,7 +149,7 @@ const UserPage: React.FC = () => {
/> />
{/* If current user is the profile user then allow profile picture swap */} {/* If current user is the profile user then allow profile picture swap */}
{isUser && ( {userPageVariant === "personal" && (
<div className="absolute top-0 bottom-0 left-0 right-0 m-auto flex items-center justify-center opacity-0 z-50 group-hover:opacity-100 transition-opacity duration-200"> <div className="absolute top-0 bottom-0 left-0 right-0 m-auto flex items-center justify-center opacity-0 z-50 group-hover:opacity-100 transition-opacity duration-200">
<EditIcon size={75} className="text-white bg-black/50 p-1 rounded-3xl" /> <EditIcon size={75} className="text-white bg-black/50 p-1 rounded-3xl" />
<input type="file" className="hidden" onChange={saveUploadedImage} accept="image/*" /> <input type="file" className="hidden" onChange={saveUploadedImage} accept="image/*" />
@@ -181,34 +164,32 @@ const UserPage: React.FC = () => {
</h1> </h1>
{/* Follower Count */} {/* Follower Count */}
{userPageVariant === "streamer" && ( <div className="flex items-center space-x-2 mb-6">
<> <span className="text-gray-400">{profileData.followerCount.toLocaleString()} followers</span>
<div className="flex items-center space-x-2 mb-6"> {profileData.isPartnered && <span className="bg-purple-600 text-white text-sm px-2 py-1 rounded">Partner</span>}
<span className="text-gray-400">{profileData.followerCount.toLocaleString()} followers</span> </div>
{profileData.isPartnered && <span className="bg-purple-600 text-white text-sm px-2 py-1 rounded">Partner</span>}
</div>
{/* (Un)Follow Button */} {/* (Un)Follow Button */}
{!isFollowing ? ( {userPageVariant != "personal" ? (
<Button !isFollowing ? (
extraClasses="w-full bg-purple-700 hover:bg-[#28005e]" <Button
onClick={() => followUser(profileData.id, setShowAuthModal)} extraClasses="w-full bg-purple-700 z-50 hover:bg-[#28005e]"
> onClick={() => followUser(profileData.id, setShowAuthModal)}
Follow >
</Button> Follow
) : ( </Button>
<Button extraClasses="w-full bg-[#a80000] z-50" onClick={() => unfollowUser(profileData?.id, setShowAuthModal)}> ) : (
Unfollow <Button extraClasses="w-full bg-[#a80000] z-50" onClick={() => unfollowUser(profileData?.id, setShowAuthModal)}>
</Button> Unfollow
)} </Button>
</> )
) : (
""
)} )}
</div> </div>
<div {/* Bio */}
id="settings" <div className="col-span-1 bg-[var(--user-sideBox)] rounded-lg p-6 grid grid-rows-[auto_1fr] text-center items-center justify-center">
className="col-span-1 bg-[var(--user-sideBox)] rounded-lg p-6 grid grid-rows-[auto_1fr] text-center items-center justify-center"
>
{/* User Type (e.g., "USER") */} {/* User Type (e.g., "USER") */}
<small className="text-green-400">{userPageVariant.toUpperCase()}</small> <small className="text-green-400">{userPageVariant.toUpperCase()}</small>
@@ -223,44 +204,42 @@ const UserPage: React.FC = () => {
id="content" id="content"
className="col-span-2 bg-[var(--user-contentBox)] rounded-lg p-6 grid grid-rows-[auto_1fr] text-center items-center justify-center" className="col-span-2 bg-[var(--user-contentBox)] rounded-lg p-6 grid grid-rows-[auto_1fr] text-center items-center justify-center"
> >
{userPageVariant === "streamer" && ( {/* Stream */}
<> {currentStream && (
{profileData.isLive ? ( <div className="mb-8">
<div className="mb-8"> <h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]">Currently Live!</h2>
<h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]">Currently Live!</h2> <StreamListItem
<StreamListItem id={profileData.id}
id={profileData.id} title={currentStream.title || ""}
title={profileData.currentStreamTitle || ""} streamCategory=""
streamCategory="" username=""
username="" viewers={currentStream.viewers || 0}
viewers={profileData.currentStreamViewers || 0} thumbnail={currentStream.thumbnail}
thumbnail={profileData.currentStreamThumbnail} onItemClick={() => {
onItemClick={() => { navigate(`/${profileData.username}`);
navigate(`/${profileData.username}`); }}
}} />
/> </div>
</div>
) : (
<h1>Currently not live</h1>
)}
{/* ↓↓ VODS ↓↓ */}
<div>
<h2 className="text-2xl font-bold mb-4">Past Broadcasts</h2>
<div className="text-gray-400 rounded-none">No past broadcasts found</div>
</div>
</>
)} )}
{/* VODs */}
{userPageVariant === "user" && ( {vods.length > 0 && (
<> <div>
{/* ↓↓ VODS ↓↓ */} <h2 className="text-2xl font-bold mb-4"></h2>
<div> <ListRow
<h2 className="text-2xl font-bold mb-4">Past Broadcasts</h2> type="vod"
<div className="text-gray-400 rounded-none">No past broadcasts found</div> title={`Past Broadcasts (${vods.length})`}
</div> items={vods}
</> onItemClick={(vod) => {
console.log("VOD Clicked:", vod);
}}
extraClasses="w-fit max-w-[40vw] py-0 mt-0"
amountForScroll={2}
itemExtraClasses="w-[15vw]"
/>
</div>
)} )}
{/* No Content */}
{vods.length === 0 && currentStream && <h2 className="text-2xl font-bold mb-4">No Content Made Yet</h2>}
</div> </div>
<div <div
@@ -294,7 +273,7 @@ const UserPage: React.FC = () => {
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "var(--follow-shadow)")} onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "var(--follow-shadow)")}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")} onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
> >
<button onClick={() => navigate(`/user/${username}/yourCategories`)}>Categories</button> <button onClick={() => navigate(`/user/${username}/followedCategories`)}>Categories</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,5 +6,4 @@ export interface UserType {
username: string; username: string;
isLive: boolean; isLive: boolean;
viewers: number; viewers: number;
thumbnail?: string;
} }

View File

@@ -104,7 +104,7 @@ http {
} }
## Profile pictures location ## Profile pictures location
location ~ ^/user/(.+)/index.png$ { location ~ ^/user/(.+)/profile_picture$ {
alias /user_data/$1/index.png; alias /user_data/$1/index.png;
# where $1 is the user's username # where $1 is the user's username

View File

@@ -67,13 +67,17 @@ def recommended_streams() -> list[dict]:
return streams return streams
@stream_bp.route('/streams/<string:username>/data')
@stream_bp.route('/streams/<int:streamer_id>/data') @stream_bp.route('/streams/<int:streamer_id>/data')
def stream_data(streamer_id) -> dict: def stream_data(username=None, streamer_id=None) -> dict:
""" """
Returns a streamer's current stream data Returns a streamer's current stream data
""" """
if username and not streamer_id:
streamer_id = get_user_id(username)
data = get_current_stream_data(streamer_id) data = get_current_stream_data(streamer_id)
# If the user is the streamer, return the stream key also
if session.get('user_id') == streamer_id: if session.get('user_id') == streamer_id:
with Database() as db: with Database() as db:
stream_key = db.fetchone( stream_key = db.fetchone(
@@ -112,33 +116,21 @@ def recommended_categories() -> list | list[dict]:
""" """
user_id = session.get("user_id") user_id = session.get("user_id")
categories = get_user_category_recommendations(1) categories = get_user_category_recommendations(user_id)
return jsonify(categories) return jsonify(categories)
@login_required @login_required
@stream_bp.route('/categories/following') @stream_bp.route('/streams/followed_categories')
def following_categories_streams(): def following_categories_streams():
""" """
Returns popular streams in categories which the user followed Returns popular streams from categories the user is following
""" """
streams = get_followed_categories_recommendations(session.get('user_id')) streams = get_followed_categories_recommendations(session.get('user_id'))
return jsonify(streams) return jsonify(streams)
@login_required
@stream_bp.route('/categories/your_categories')
def following_your_categories():
"""
Returns categories which the user followed
"""
streams = get_followed_your_categories(session.get('user_id'))
return jsonify(streams)
# User Routes # User Routes
@stream_bp.route('/user/<string:username>/status') @stream_bp.route('/user/<string:username>/status')
def user_live_status(username): def user_live_status(username):
@@ -172,7 +164,7 @@ def user_live_status(username):
@stream_bp.route('/vods/<int:vod_id>') @stream_bp.route('/vods/<int:vod_id>')
def vod(vod_id): def vod(vod_id):
""" """
Returns a JSON of a vod Returns details about a specific vod
""" """
vod = get_vod(vod_id) vod = get_vod(vod_id)
return jsonify(vod) return jsonify(vod)
@@ -187,6 +179,7 @@ def vods(username):
"vod_id": int, "vod_id": int,
"title": str, "title": str,
"datetime": str, "datetime": str,
"username": str,
"category_name": str, "category_name": str,
"length": int (in seconds), "length": int (in seconds),
"views": int "views": int
@@ -204,10 +197,8 @@ def get_all_vods():
Returns data of all VODs by all streamers in a JSON-compatible format Returns data of all VODs by all streamers in a JSON-compatible format
""" """
with Database() as db: with Database() as db:
vods = db.fetchall("SELECT * FROM vods") vods = db.fetchall("""SELECT vods.*, username, category_name FROM vods JOIN users ON vods.user_id = users.user_id JOIN categories ON vods.category_id = categories.category_id;""")
print("Fetched VODs from DB:", vods)
return jsonify(vods) return jsonify(vods)
# RTMP Server Routes # RTMP Server Routes
@@ -355,23 +346,12 @@ def update_stream():
@stream_bp.route("/end_stream", methods=["POST"]) @stream_bp.route("/end_stream", methods=["POST"])
def end_stream(): def end_stream():
""" """
Ends a stream Ends a stream based on the HTTP request
step-by-step:
remove stream from database
move stream to vod table
set user as not streaming
convert ts files to mp4
clean up old ts files
end thumbnail generation
""" """
print("Ending stream", flush=True)
print("TEST END STREAM")
stream_key = request.get_json().get("key") stream_key = request.get_json().get("key")
print(stream_key, flush=True)
user_id = None
username = None
if not stream_key: if not stream_key:
# Try getting stream_key from form data (for nginx in the case that the stream is ended on OBS's end) # Try getting stream_key from form data (for nginx in the case that the stream is ended on OBS's end)
stream_key = request.form.get("name") stream_key = request.form.get("name")
@@ -380,60 +360,24 @@ def end_stream():
print("Unauthorized - No stream key provided", flush=True) print("Unauthorized - No stream key provided", flush=True)
return "Unauthorized", 403 return "Unauthorized", 403
# Open database connection # Get user info from stream key
with Database() as db: with Database() as db:
initial_streams = db.fetchall("""SELECT title FROM streams""") user_info = db.fetchone("""SELECT user_id, username
print("Initial streams:", initial_streams, flush=True)
# Get user info from stream key
user_info = db.fetchone("""SELECT *
FROM users FROM users
WHERE stream_key = ?""", (stream_key,)) WHERE stream_key = ?""", (stream_key,))
stream_info = db.fetchone("""SELECT *
FROM streams
WHERE user_id = ?""", (user_id,))
print("Got stream_info", stream_info, flush=True)
# If stream key is invalid, return unauthorized
if not user_info: if not user_info:
print("Unauthorized - No user found from stream key", flush=True) print("Unauthorized - No user found from stream key", flush=True)
return "Unauthorized", 403 return "Unauthorized", 403
# If stream never published, return
if not stream_info: user_id = user_info["user_id"]
print(f"Stream for stream key: {stream_key} never began", flush=True) username = user_info["username"]
return "Stream ended", 200
result, message = end_user_stream(stream_key, user_id, username)
# Remove stream from database
db.execute("""DELETE FROM streams if result:
WHERE user_id = ?""", (user_id,)) print(f"Stream ended: {message}", flush=True)
return "Stream ended", 200
# Move stream to vod table else:
stream_length = int( print(f"Error ending stream: {message}", flush=True)
(datetime.now() - parser.parse(stream_info.get("start_time"))).total_seconds()) return "Error ending stream", 500
db.execute("""INSERT INTO vods (user_id, title, datetime, category_id, length, views)
VALUES (?, ?, ?, ?, ?, ?)""", (user_id,
stream_info.get("title"),
stream_info.get(
"start_time"),
stream_info.get(
"category_id"),
stream_length,
0))
vod_id = db.get_last_insert_id()
# Set user as not streaming
db.execute("""UPDATE users
SET is_live = 0
WHERE user_id = ?""", (user_id,))
current_streams = db.fetchall("""SELECT title FROM streams""")
combine_ts_stream.delay(path_manager.get_stream_path(
username), path_manager.get_vods_path(username), vod_id)
print("Stream ended. Current streams now:", current_streams, flush=True)
return "Stream ended", 200

View File

@@ -61,17 +61,6 @@ def user_profile_picture_save():
return jsonify({"message": "Profile picture saved", "path": thumbnail_path}) return jsonify({"message": "Profile picture saved", "path": thumbnail_path})
@login_required
@user_bp.route('/user/same/<string:username>')
def user_is_same(username):
"""
Returns if given user is current user
"""
current_username = session.get("username")
if username == current_username:
return jsonify({"same": True})
return jsonify({"same": False})
## Subscription Routes ## Subscription Routes
@login_required @login_required
@user_bp.route('/user/subscription/<string:streamer_name>') @user_bp.route('/user/subscription/<string:streamer_name>')

View File

@@ -67,9 +67,10 @@ def end_user_stream(stream_key, user_id, username):
from utils.path_manager import PathManager from utils.path_manager import PathManager
path_manager = PathManager() path_manager = PathManager()
print(f"Ending stream for user {username} (ID: {user_id})", flush=True)
if not stream_key or not user_id or not username: if not stream_key or not user_id or not username:
current_app.logger.error("Cannot end stream - missing required information") print("Cannot end stream - missing required information", flush=True)
return False return False
try: try:
@@ -82,7 +83,7 @@ def end_user_stream(stream_key, user_id, username):
# If user is not streaming, just return # If user is not streaming, just return
if not stream_info: if not stream_info:
current_app.logger.info(f"User {username} (ID: {user_id}) is not streaming") print(f"User {username} (ID: {user_id}) is not streaming", flush=True)
return True, "User is not streaming" return True, "User is not streaming"
# Remove stream from database # Remove stream from database
@@ -115,11 +116,11 @@ def end_user_stream(stream_key, user_id, username):
vod_id vod_id
) )
current_app.logger.info(f"Stream ended for user {username} (ID: {user_id})") print(f"Stream ended for user {username} (ID: {user_id})", flush=True)
return True, "Stream ended successfully" return True, "Stream ended successfully"
except Exception as e: except Exception as e:
current_app.logger.error(f"Error ending stream for user {username}: {str(e)}") print(f"Error ending stream for user {username}: {str(e)}", flush=True)
return False, f"Error ending stream: {str(e)}" return False, f"Error ending stream: {str(e)}"
def get_category_id(category_name: str) -> Optional[int]: def get_category_id(category_name: str) -> Optional[int]: