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 { useAuthModal } from "../hooks/useAuthModal";
import { useAuth } from "../context/AuthContext";
@@ -10,8 +10,8 @@ import DynamicPageContent from "../components/Layout/DynamicPageContent";
import LoadingScreen from "../components/Layout/LoadingScreen";
import { StreamListItem } from "../components/Layout/ListItem";
import { EditIcon } from "lucide-react";
import { getCategoryThumbnail } from "../utils/thumbnailUtils";
import { useSameUser } from "../hooks/useSameUser";
import ListRow from "../components/Layout/ListRow";
import { useStreams, useVods } from "../hooks/useContent";
interface UserProfileData {
id: number;
@@ -20,22 +20,38 @@ interface UserProfileData {
followerCount: number;
isPartnered: boolean;
isLive: boolean;
currentStreamTitle?: string;
currentStreamCategory?: string;
currentStreamViewers?: number;
currentStreamStartTime?: string;
currentStreamThumbnail?: string;
}
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 { isFollowing, checkFollowStatus, followUser, unfollowUser } = useFollow();
const { showAuthModal, setShowAuthModal } = useAuthModal();
const { username: loggedInUsername } = useAuth();
const { username } = useParams();
const isUser = useSameUser({ username });
const { vods } = useVods(`/api/vods/${username}`);
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
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(() => {
// Fetch user profile data
fetch(`/api/user/${username}`)
.then((res) => res.json())
.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 (username === loggedInUsername) setUserPageVariant("personal");
// else if (data.isAdmin) setUserPageVariant("admin");
else setUserPageVariant("user");
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);
}, [username]);
}, [username, loggedInUsername, checkFollowStatus]);
// Fetch user profile data
useEffect(() => {
if (!username) return;
fetchProfileData();
}, [fetchProfileData]);
if (!profileData) return <LoadingScreen />;
@@ -148,16 +126,21 @@ const UserPage: React.FC = () => {
} inset-0 z-20`}
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 */}
{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">
LIVE
</div>
) : (
""
)}
<img
src={`/user/${profileData.username}/profile_picture`}
onError={(e) => {
console.log("no error")
e.currentTarget.src = "/images/pfps/default.png";
e.currentTarget.onerror = null;
}}
@@ -166,7 +149,7 @@ const UserPage: React.FC = () => {
/>
{/* 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">
<EditIcon size={75} className="text-white bg-black/50 p-1 rounded-3xl" />
<input type="file" className="hidden" onChange={saveUploadedImage} accept="image/*" />
@@ -181,34 +164,32 @@ const UserPage: React.FC = () => {
</h1>
{/* Follower Count */}
{userPageVariant === "streamer" && (
<>
<div className="flex items-center space-x-2 mb-6">
<span className="text-gray-400">{profileData.followerCount.toLocaleString()} followers</span>
{profileData.isPartnered && <span className="bg-purple-600 text-white text-sm px-2 py-1 rounded">Partner</span>}
</div>
<div className="flex items-center space-x-2 mb-6">
<span className="text-gray-400">{profileData.followerCount.toLocaleString()} followers</span>
{profileData.isPartnered && <span className="bg-purple-600 text-white text-sm px-2 py-1 rounded">Partner</span>}
</div>
{/* (Un)Follow Button */}
{!isFollowing ? (
<Button
extraClasses="w-full bg-purple-700 hover:bg-[#28005e]"
onClick={() => followUser(profileData.id, setShowAuthModal)}
>
Follow
</Button>
) : (
<Button extraClasses="w-full bg-[#a80000] z-50" onClick={() => unfollowUser(profileData?.id, setShowAuthModal)}>
Unfollow
</Button>
)}
</>
{/* (Un)Follow Button */}
{userPageVariant != "personal" ? (
!isFollowing ? (
<Button
extraClasses="w-full bg-purple-700 z-50 hover:bg-[#28005e]"
onClick={() => followUser(profileData.id, setShowAuthModal)}
>
Follow
</Button>
) : (
<Button extraClasses="w-full bg-[#a80000] z-50" onClick={() => unfollowUser(profileData?.id, setShowAuthModal)}>
Unfollow
</Button>
)
) : (
""
)}
</div>
<div
id="settings"
className="col-span-1 bg-[var(--user-sideBox)] rounded-lg p-6 grid grid-rows-[auto_1fr] text-center items-center justify-center"
>
{/* Bio */}
<div 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") */}
<small className="text-green-400">{userPageVariant.toUpperCase()}</small>
@@ -223,44 +204,42 @@ const UserPage: React.FC = () => {
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"
>
{userPageVariant === "streamer" && (
<>
{profileData.isLive ? (
<div className="mb-8">
<h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]">Currently Live!</h2>
<StreamListItem
id={profileData.id}
title={profileData.currentStreamTitle || ""}
streamCategory=""
username=""
viewers={profileData.currentStreamViewers || 0}
thumbnail={profileData.currentStreamThumbnail}
onItemClick={() => {
navigate(`/${profileData.username}`);
}}
/>
</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>
</>
{/* Stream */}
{currentStream && (
<div className="mb-8">
<h2 className="text-2xl bg-[#ff0000] border py-4 px-12 font-black mb-4 rounded-[4rem]">Currently Live!</h2>
<StreamListItem
id={profileData.id}
title={currentStream.title || ""}
streamCategory=""
username=""
viewers={currentStream.viewers || 0}
thumbnail={currentStream.thumbnail}
onItemClick={() => {
navigate(`/${profileData.username}`);
}}
/>
</div>
)}
{userPageVariant === "user" && (
<>
{/* ↓↓ 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 */}
{vods.length > 0 && (
<div>
<h2 className="text-2xl font-bold mb-4"></h2>
<ListRow
type="vod"
title={`Past Broadcasts (${vods.length})`}
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
@@ -294,7 +273,7 @@ const UserPage: React.FC = () => {
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = "var(--follow-shadow)")}
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>

View File

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

View File

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

View File

@@ -67,13 +67,17 @@ def recommended_streams() -> list[dict]:
return streams
@stream_bp.route('/streams/<string:username>/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
"""
if username and not streamer_id:
streamer_id = get_user_id(username)
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:
with Database() as db:
stream_key = db.fetchone(
@@ -112,33 +116,21 @@ def recommended_categories() -> list | list[dict]:
"""
user_id = session.get("user_id")
categories = get_user_category_recommendations(1)
categories = get_user_category_recommendations(user_id)
return jsonify(categories)
@login_required
@stream_bp.route('/categories/following')
@stream_bp.route('/streams/followed_categories')
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'))
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
@stream_bp.route('/user/<string:username>/status')
def user_live_status(username):
@@ -172,7 +164,7 @@ def user_live_status(username):
@stream_bp.route('/vods/<int:vod_id>')
def vod(vod_id):
"""
Returns a JSON of a vod
Returns details about a specific vod
"""
vod = get_vod(vod_id)
return jsonify(vod)
@@ -187,6 +179,7 @@ def vods(username):
"vod_id": int,
"title": str,
"datetime": str,
"username": str,
"category_name": str,
"length": int (in seconds),
"views": int
@@ -204,10 +197,8 @@ def get_all_vods():
Returns data of all VODs by all streamers in a JSON-compatible format
"""
with Database() as db:
vods = db.fetchall("SELECT * FROM vods")
print("Fetched VODs from DB:", 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;""")
return jsonify(vods)
# RTMP Server Routes
@@ -355,23 +346,12 @@ def update_stream():
@stream_bp.route("/end_stream", methods=["POST"])
def end_stream():
"""
Ends a stream
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
Ends a stream based on the HTTP request
"""
print("Ending stream", flush=True)
print("TEST END STREAM")
stream_key = request.get_json().get("key")
print(stream_key, flush=True)
user_id = None
username = None
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)
stream_key = request.form.get("name")
@@ -380,60 +360,24 @@ def end_stream():
print("Unauthorized - No stream key provided", flush=True)
return "Unauthorized", 403
# Open database connection
# Get user info from stream key
with Database() as db:
initial_streams = db.fetchall("""SELECT title FROM streams""")
print("Initial streams:", initial_streams, flush=True)
# Get user info from stream key
user_info = db.fetchone("""SELECT *
user_info = db.fetchone("""SELECT user_id, username
FROM users
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:
print("Unauthorized - No user found from stream key", flush=True)
return "Unauthorized", 403
# If stream never published, return
if not stream_info:
print(f"Stream for stream key: {stream_key} never began", flush=True)
return "Stream ended", 200
# Remove stream from database
db.execute("""DELETE FROM streams
WHERE user_id = ?""", (user_id,))
# Move stream to vod table
stream_length = int(
(datetime.now() - parser.parse(stream_info.get("start_time"))).total_seconds())
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
user_id = user_info["user_id"]
username = user_info["username"]
result, message = end_user_stream(stream_key, user_id, username)
if result:
print(f"Stream ended: {message}", flush=True)
return "Stream ended", 200
else:
print(f"Error ending stream: {message}", flush=True)
return "Error ending stream", 500

View File

@@ -61,17 +61,6 @@ def user_profile_picture_save():
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
@login_required
@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
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:
current_app.logger.error("Cannot end stream - missing required information")
print("Cannot end stream - missing required information", flush=True)
return False
try:
@@ -82,7 +83,7 @@ def end_user_stream(stream_key, user_id, username):
# If user is not streaming, just return
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"
# Remove stream from database
@@ -115,11 +116,11 @@ def end_user_stream(stream_key, user_id, username):
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"
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)}"
def get_category_id(category_name: str) -> Optional[int]: