This commit is contained in:
white
2025-03-03 11:43:25 +00:00
17 changed files with 268 additions and 294 deletions

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.5.0", "version": "0.15.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --config vite.config.dev.ts", "dev": "vite --config vite.config.dev.ts",

View File

@@ -110,6 +110,7 @@ interface VodListItemProps extends BaseListItemProps, Omit<VodType, "type"> {
} }
const VodListItem: React.FC<VodListItemProps> = ({ const VodListItem: React.FC<VodListItemProps> = ({
vod_id,
title, title,
username, username,
category_name, category_name,
@@ -137,7 +138,7 @@ const VodListItem: React.FC<VodListItemProps> = ({
<div className="p-3"> <div className="p-3">
<h3 className="font-semibold text-lg text-white truncate max-w-full">{title}</h3> <h3 className="font-semibold text-lg text-white truncate max-w-full">{title}</h3>
<p className="text-sm text-gray-300">{username}</p> {variant != "vodDashboard" && <p className="text-sm text-gray-300">{username}</p>}
<p className="text-sm text-gray-400">{category_name}</p> <p className="text-sm text-gray-400">{category_name}</p>
<div className="flex justify-between items-center mt-2"> <div className="flex justify-between items-center mt-2">
<p className="text-xs text-gray-500">{datetime}</p> <p className="text-xs text-gray-500">{datetime}</p>
@@ -147,20 +148,21 @@ const VodListItem: React.FC<VodListItemProps> = ({
</div> </div>
{variant === "vodDashboard" && ( {variant === "vodDashboard" && (
<div className="flex justify-evenly items-stretch rounded-b-lg"> <div className="flex justify-evenly items-stretch rounded-b-lg">
<button {/* <button
className="flex justify-around w-full h-full bg-black/50 hover:bg-black/80 p-2 mx-1 font-semibold rounded-full border border-transparent hover:border-white" className="flex justify-around w-full h-full bg-black/50 hover:bg-black/80 p-2 mx-1 font-semibold rounded-full border border-transparent hover:border-white"
onClick={() => console.log("Publish")} onClick={() => console.log("Publish")}
> >
<UploadIcon /> <UploadIcon />
Publish Publish
</button> </button> */}
<button <a
className="flex justify-around w-full h-full bg-black/50 hover:bg-black/80 p-2 mx-1 font-semibold rounded-full border border-transparent hover:border-white" className="flex justify-around w-full h-full bg-black/50 hover:bg-black/80 p-2 mx-1 font-semibold rounded-full border border-transparent hover:border-white"
onClick={() => console.log("Download")} href={`/vods/${username}/${vod_id}.mp4`}
download={`${username}_vod_${vod_id}.mp4`}
> >
<DownloadIcon /> <DownloadIcon />
Download Download
</button> </a>
</div> </div>
)} )}
</div> </div>

View File

@@ -164,7 +164,6 @@ const ListRow = forwardRef<ListRowRef, ListRowProps>((props, ref) => {
username={item.username} username={item.username}
isLive={item.isLive} isLive={item.isLive}
viewers={item.viewers} viewers={item.viewers}
thumbnail={item.thumbnail}
onItemClick={() => onItemClick(item.username)} onItemClick={() => onItemClick(item.username)}
extraClasses={itemExtraClasses} extraClasses={itemExtraClasses}
/> />

View File

@@ -141,7 +141,7 @@ const Sidebar: React.FC<SideBarProps> = ({ extraClasses = "" }) => {
return ( return (
<div <div
key={`${sidebarId.current}-category-${category.category_id}`} key={`${sidebarId.current}-category-${category.category_id}`}
className="group relative flex flex-col items-center justify-center h-full max-h-[50px] border border-[--text-color] className="group relative flex flex-col items-center justify-center w-full h-full max-h-[50px] border border-[--text-color]
rounded-lg overflow-hidden hover:shadow-lg transition-all text-white hover:text-purple-500 cursor-pointer" rounded-lg overflow-hidden hover:shadow-lg transition-all text-white hover:text-purple-500 cursor-pointer"
onClick={() => (window.location.href = `/category/${category.category_name}`)} onClick={() => (window.location.href = `/category/${category.category_name}`)}
> >

View File

@@ -225,13 +225,13 @@ const StreamDashboard: React.FC<StreamDashboardProps> = ({ username, userId, isL
const handleEndStream = async () => { const handleEndStream = async () => {
console.log("Ending stream..."); console.log("Ending stream...");
const formData = new FormData();
formData.append("key", streamData.stream_key);
try { try {
const response = await fetch("/api/end_stream", { const response = await fetch("/api/end_stream", {
method: "POST", method: "POST",
body: formData, headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ key: streamData.stream_key }),
}); });
if (response.ok) { if (response.ok) {
@@ -375,7 +375,9 @@ const StreamDashboard: React.FC<StreamDashboardProps> = ({ username, userId, isL
streamCategory={streamData.category_name || "Category"} streamCategory={streamData.category_name || "Category"}
viewers={streamData.viewer_count} viewers={streamData.viewer_count}
thumbnail={thumbnailPreview.url || ""} thumbnail={thumbnailPreview.url || ""}
onItemClick={() => {}} onItemClick={() => {
window.open(`/${username}`, "_blank");
}}
extraClasses="max-w-[20vw]" extraClasses="max-w-[20vw]"
/> />
</div> </div>

View File

@@ -23,6 +23,7 @@ const processVodData = (data: any[]): VodType[] => {
// Helper function to process API data into our consistent types // Helper function to process API data into our consistent types
const processStreamData = (data: any[]): StreamType[] => { const processStreamData = (data: any[]): StreamType[] => {
if (!data || data.length === 0 || !data[0] || !data[0].user_id) return [];
return data.map((stream) => ({ return data.map((stream) => ({
type: "stream", type: "stream",
id: stream.user_id, id: stream.user_id,
@@ -76,8 +77,9 @@ export function useFetchContent<T>(
throw new Error(`Error fetching data: ${response.status}`); throw new Error(`Error fetching data: ${response.status}`);
} }
const rawData = await response.json(); const rawData = await response.json();
const processedData = processor(rawData); let processedData = processor(Array.isArray(rawData) ? rawData : (rawData ? [rawData] : []));
console.log("processedData", processedData);
setData(processedData); setData(processedData);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -126,7 +128,7 @@ export function useVods(customUrl?: string): {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} { } {
const url = customUrl || "api/vods/all"; //TODO: Change this to the correct URL or implement it const url = customUrl || "api/vods/all";
const { data, isLoading, error } = useFetchContent<VodType>(url, processVodData, [customUrl]); const { data, isLoading, error } = useFetchContent<VodType>(url, processVodData, [customUrl]);
return { vods: data, isLoading, error }; return { vods: data, isLoading, error };

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

@@ -20,11 +20,11 @@ export default defineConfig({
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,
}, },
"/stream": { "/stream": {
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
}, },
"/images": { "/images": {
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
@@ -33,6 +33,10 @@ export default defineConfig({
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
}, },
"/vods": {
target: "http://localhost:8080",
changeOrigin: true,
},
}, },
}, },
build: { build: {

View File

@@ -99,12 +99,12 @@ http {
alias /user_data/$1/vods/$2; alias /user_data/$1/vods/$2;
# where $1 is the user's username and $2 is the thumbnail_name # where $1 is the user's username and $2 is the thumbnail_name
# The thumbnails should not be cacheable # The thumbnails should not be cacheable
expires -1d; expires -1d;
} }
## 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

@@ -95,12 +95,6 @@ def signup():
# Create user directories for stream data # Create user directories for stream data
path_manager.create_user(username) path_manager.create_user(username)
# Create session for new user, to avoid them having unnecessary state info
session.clear()
session["username"] = username
session["user_id"] = get_user_id(username)
print(f"Logged in as {username}. session: {session.get('username')}. user_id: {session.get('user_id')}", flush=True)
# send_email(username) # send_email(username)
return jsonify({ return jsonify({
@@ -178,8 +172,11 @@ def login():
"error_fields": ["username", "password"], "error_fields": ["username", "password"],
"message": "Invalid username or password" "message": "Invalid username or password"
}), 401 }), 401
# Add user directories for stream data in case they don't exist
path_manager.create_user(username)
# Set up session to avoid having unncessary state information # Set up session
session.clear() session.clear()
session["username"] = username session["username"] = username
session["user_id"] = get_user_id(username) session["user_id"] = get_user_id(username)
@@ -209,8 +206,27 @@ def logout() -> dict:
""" """
Log out and clear the users session. Log out and clear the users session.
If the user is currently streaming, end their stream first.
Can only be accessed by a logged in user. Can only be accessed by a logged in user.
""" """
from database.database import Database
from utils.stream_utils import end_user_stream
# Check if user is currently streaming
user_id = session.get("user_id")
username = session.get("username")
with Database() as db:
is_streaming = db.fetchone("""SELECT is_live FROM users WHERE user_id = ?""", (user_id,))
if is_streaming and is_streaming.get("is_live") == 1:
# Get the user's stream key
stream_key_info = db.fetchone("""SELECT stream_key FROM users WHERE user_id = ?""", (user_id,))
stream_key = stream_key_info.get("stream_key") if stream_key_info else None
if stream_key:
# End the stream
end_user_stream(stream_key, user_id, username)
session.clear() session.clear()
return {"logged_in": False} return {"logged_in": False}

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

@@ -94,7 +94,7 @@ def get_highest_view_categories(no_categories: int = 4, offset: int = 0) -> Opti
""", (no_categories, offset)) """, (no_categories, offset))
return categories return categories
def get_user_category_recommendations(user_id: 1, no_categories: int = 4) -> Optional[List[dict]]: def get_user_category_recommendations(user_id = 1, no_categories: int = 4) -> Optional[List[dict]]:
""" """
Queries user_preferences database to find users top favourite streaming category and returns the category Queries user_preferences database to find users top favourite streaming category and returns the category
""" """

View File

@@ -48,6 +48,81 @@ def get_current_stream_data(user_id: int) -> Optional[dict]:
""", (user_id,)) """, (user_id,))
return most_recent_stream return most_recent_stream
def end_user_stream(stream_key, user_id, username):
"""
Utility function to end a user's stream
Parameters:
stream_key: The stream key of the user
user_id: The ID of the user
username: The username of the user
Returns:
bool: True if stream was ended successfully, False otherwise
"""
from flask import current_app
from datetime import datetime
from dateutil import parser
from celery_tasks.streaming import combine_ts_stream
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:
print("Cannot end stream - missing required information", flush=True)
return False
try:
# Open database connection
with Database() as db:
# Get stream info
stream_info = db.fetchone("""SELECT *
FROM streams
WHERE user_id = ?""", (user_id,))
# If user is not streaming, just return
if not stream_info:
print(f"User {username} (ID: {user_id}) is not streaming", flush=True)
return True, "User is not streaming"
# 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,))
# Queue task to combine TS files into MP4
combine_ts_stream.delay(
path_manager.get_stream_path(username),
path_manager.get_vods_path(username),
vod_id
)
print(f"Stream ended for user {username} (ID: {user_id})", flush=True)
return True, "Stream ended successfully"
except Exception as 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]: def get_category_id(category_name: str) -> Optional[int]:
""" """
Returns the category_id given a category name Returns the category_id given a category name
@@ -77,7 +152,7 @@ def get_vod(vod_id: int) -> dict:
Returns data of a streamers vod Returns data of a streamers vod
""" """
with Database() as db: with Database() as db:
vod = db.fetchone("""SELECT * FROM vods WHERE vod_id = ?;""", (vod_id,)) vod = db.fetchone("""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 WHERE vod_id = ?;""", (vod_id,))
return vod return vod
def get_latest_vod(user_id: int): def get_latest_vod(user_id: int):
@@ -85,7 +160,7 @@ def get_latest_vod(user_id: int):
Returns data of the most recent stream by a streamer Returns data of the most recent stream by a streamer
""" """
with Database() as db: with Database() as db:
latest_vod = db.fetchone("""SELECT vods.*, category_name FROM vods JOIN categories ON vods.category_id = categories.category_id WHERE user_id = ? ORDER BY vod_id DESC;""", (user_id,)) latest_vod = db.fetchone("""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 WHERE vods.user_id = ? ORDER BY vod_id DESC;""", (user_id,))
return latest_vod return latest_vod
def get_user_vods(user_id: int): def get_user_vods(user_id: int):
@@ -93,15 +168,7 @@ def get_user_vods(user_id: int):
Returns data of all vods by a streamer Returns data of all vods by a streamer
""" """
with Database() as db: with Database() as db:
vods = db.fetchall("""SELECT vods.*, category_name FROM vods JOIN categories ON vods.category_id = categories.category_id WHERE user_id = ? ORDER BY vod_id DESC;""", (user_id,)) 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 WHERE vods.user_id = ? ORDER BY vod_id DESC;""", (user_id,))
return vods
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""")
return vods return vods
def generate_thumbnail(stream_file: str, thumbnail_file: str) -> None: def generate_thumbnail(stream_file: str, thumbnail_file: str) -> None: