diff --git a/src/App.js b/src/App.js index c4d8ca6..18b1d91 100644 --- a/src/App.js +++ b/src/App.js @@ -5,6 +5,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import Help from "./components/Help"; import Results from "./components/Results"; import Search from "./components/Search"; +import { SortOptions } from "./types"; import { getPlaylist, sortPlaylist } from "./util/playlistUtil"; ReactGA.initialize("G-LRVNS567ZT"); @@ -15,40 +16,7 @@ const App = () => { const [playlistID, setPlaylistID] = useState(""); const [loading, setLoading] = useState(false); const [playlist, setPlaylist] = useState([]); - const sortOptions = [ - { - value: "vd", - label: "Views Descending", - }, - { - value: "va", - label: "Views Ascending", - }, - { - value: "ld", - label: "Likes Descending", - }, - { - value: "la", - label: "Likes Ascending", - }, - { - value: "ud", - label: "Most Recent", - }, - { - value: "ua", - label: "Earliest", - }, - { - value: "dd", - label: "Longest", - }, - { - value: "da", - label: "Shortest", - }, - ]; + const updateSortOrder = (event) => { const value = event.target.value; // Google Analytics @@ -96,7 +64,7 @@ const App = () => { element={ updatePlaylistID(event)} diff --git a/src/components/Results.tsx b/src/components/Results.tsx index 7c7af81..980f27a 100644 --- a/src/components/Results.tsx +++ b/src/components/Results.tsx @@ -1,9 +1,9 @@ import { Box } from "@mui/material"; import * as React from "react"; -import { IVideoMetadata } from "../types"; +import { VideoMetadata } from "../types"; import Video from "./Video"; -const Results = ({ videos }: { videos: { stats: IVideoMetadata }[] }) => ( +const Results = ({ videos }: { videos: VideoMetadata[] }) => ( ( pb="5vh" > {videos.map((video, index) => { - return ; + return ; })} ); diff --git a/src/components/Search.js b/src/components/Search.tsx similarity index 81% rename from src/components/Search.js rename to src/components/Search.tsx index 236c0f0..02efe60 100644 --- a/src/components/Search.js +++ b/src/components/Search.tsx @@ -1,11 +1,19 @@ import { InfoOutlined, Search as SearchIcon } from "@mui/icons-material"; import { LoadingButton } from "@mui/lab"; import { Box, Button, MenuItem, TextField } from "@mui/material"; -import PropTypes from "prop-types"; -import React from "react"; +import * as React from "react"; import { Link } from "react-router-dom"; +import { SortOptions } from "../types"; -const Search = (props) => ( +const Search = (props: { + playlistID: string; + order: string; + loading: boolean; + updatePlaylistID: any; + updateSortOrder: any; + search: any; + sortOptions: SortOptions[]; +}) => (
@@ -29,8 +37,8 @@ const Search = (props) => ( onChange={props.updateSortOrder} > {props.sortOptions.map((option) => ( - - {option.label} + + {SortOptions[option]} ))} @@ -70,14 +78,4 @@ const Search = (props) => ( ); -Search.propTypes = { - playlistID: PropTypes.string, - order: PropTypes.string, - loading: PropTypes.bool, - updatePlaylistID: PropTypes.func, - updateSortOrder: PropTypes.func, - search: PropTypes.func, - sortOptions: PropTypes.array, -}; - export default Search; diff --git a/src/components/Video.tsx b/src/components/Video.tsx index e464e79..bd442f1 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -1,10 +1,10 @@ import { Box, Link, useMediaQuery } from "@mui/material"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import { IVideoMetadata } from "../types"; +import { VideoMetadata } from "../types"; import { convertISOtoString } from "../util/dateUtil"; -const Video = ({ metadata }: { metadata: IVideoMetadata }) => { +const Video = ({ metadata }: { metadata: VideoMetadata }) => { const mobile = useMediaQuery("(max-width:1000px)"); return ( { - const video = res.data.items[0]; - // make sure video isn't private or removed - if (res.data.items.length !== 0) { - return { - stats: { - views: parseInt(video.statistics.viewCount), - likes: video.statistics.likeCount - ? video.statistics.likeCount - : "Hidden", - uploadDate: video.snippet.publishedAt, - channel: video.snippet.channelTitle, - title: video.snippet.title, - thumbnail: video.snippet.thumbnails.medium.url, - link: `https://www.youtube.com/watch?v=${video.id}`, - duration: toSeconds(parse(video.contentDetails.duration)), - }, - }; - } - }); -} - -export async function getPlaylist(playlistID, pageToken, order, count) { - // 4 pages * 50 videos/page = 200 videos max - if (count > 4) { - return []; - } - const playlistFunction = httpsCallable(functions, "playlist"); - return playlistFunction({ id: playlistID, token: pageToken }).then( - async (res) => { - const playlist = res.data; - let results = []; - if (!playlist.items) return results; // playlist is empty - for (var i = 0; i < playlist.items.length; i++) { - const videoID = playlist.items[i].snippet.resourceId.videoId; - // ensure all videos are processed before returning - // eslint-disable-next-line - await getVideo(videoID).then((data) => { - if (data) { - results.push(data); - } - }); - } - // recursively get next page of data - if (res.data.nextPageToken) { - await getPlaylist( - playlistID, - res.data.nextPageToken, - order, - count + 1, - ).then((data) => { - // append data to current results - results = results.concat(data); - }); - } - results = sortPlaylist(results, order); - return results; - }, - ); -} diff --git a/src/util/playlistUtil.ts b/src/util/playlistUtil.ts new file mode 100644 index 0000000..456482b --- /dev/null +++ b/src/util/playlistUtil.ts @@ -0,0 +1,151 @@ +import { initializeApp } from "firebase/app"; +import { getFunctions, httpsCallable } from "firebase/functions"; +import { parse, toSeconds } from "iso8601-duration"; +import { SortOptions, VideoMetadata } from "../types"; +import { convertISOtoInt } from "./dateUtil"; +import firebaseConfig from "./firebaseConfig"; + +const app = initializeApp(firebaseConfig); +const functions = getFunctions(app); +// connectFunctionsEmulator(functions, "localhost", 5001); + +export function sortPlaylist(videos: VideoMetadata[], order: SortOptions) { + var ret = videos; + let compFunction = (a: VideoMetadata, b: VideoMetadata) => { + switch (SortOptions[order]) { + case SortOptions.VIEWS_ASC: + console.log("VIEWS ASC"); + return a.views - b.views; + case SortOptions.VIEWS_DESC: + return b.views - a.views; + case SortOptions.LIKES_ASC: + return a.likes - b.likes; + case SortOptions.LIKES_DESC: + return b.likes - a.likes; + case SortOptions.LEAST_RECENT: + return convertISOtoInt(a.uploadDate) - convertISOtoInt(b.uploadDate); + case SortOptions.MOST_RECENT: + return convertISOtoInt(b.uploadDate) - convertISOtoInt(a.uploadDate); + case SortOptions.SHORTEST: + return a.duration - b.duration; + case SortOptions.LONGEST: + return b.duration - a.duration; + default: + return 0; + } + }; + ret.sort(compFunction); + return ret; +} + +/** + * Returned from the Youtube API from Firebase functions. + * This type should be cleaned up in the future to live in a centralized + * place to be leveraged by the firebase functions as well + */ +type VideoMetadataAPI = { + id: number; + statistics: { + viewCount: string; + likeCount: string; + }; + snippet: { + publishedAt; + channelTitle: string; + title: string; + thumbnails: { + medium: { + url: string; + }; + }; + }; + contentDetails: { + duration: string; + }; +}; + +type VideoFunctionResponse = { + data: { + items: VideoMetadataAPI[]; + }; +}; + +function getVideo(videoID): Promise { + const videoFunction = httpsCallable(functions, "video"); + return videoFunction({ id: videoID }).then((res: VideoFunctionResponse) => { + const video = res.data.items[0]; + // make sure video isn't private or removed + if (res.data.items.length !== 0) { + return { + views: parseInt(video.statistics.viewCount), + likes: parseInt(video.statistics.likeCount) ?? 0, + uploadDate: video.snippet.publishedAt, + channel: video.snippet.channelTitle, + title: video.snippet.title, + thumbnail: video.snippet.thumbnails.medium.url, + link: `https://www.youtube.com/watch?v=${video.id}`, + duration: toSeconds(parse(video.contentDetails.duration)), + }; + } + return null; + }); +} + +/** + * Returned from the Youtube API from Firebase functions. + * This type should be cleaned up in the future to live in a centralized + * place to be leveraged by the firebase functions as well + */ +type PlaylistMetadataAPI = { + snippet: { + resourceId: { + videoId: number; + }; + }; +}; + +type PlaylistFunctionResponse = { + data: { + items: PlaylistMetadataAPI[]; + nextPageToken: string; + }; +}; + +export async function getPlaylist(playlistID, pageToken, order, count) { + // 4 pages * 50 videos/page = 200 videos max + if (count > 4) { + return []; + } + const playlistFunction = httpsCallable(functions, "playlist"); + return playlistFunction({ id: playlistID, token: pageToken }).then( + async (res: PlaylistFunctionResponse) => { + const playlist = res.data; + let results: VideoMetadata[] = []; + if (!playlist.items) return results; // playlist is empty + for (var i = 0; i < playlist.items.length; i++) { + const videoID = playlist.items[i].snippet.resourceId.videoId; + // ensure all videos are processed before returning + // eslint-disable-next-line + await getVideo(videoID).then((data) => { + if (data) { + results.push(data); + } + }); + } + // recursively get next page of data + if (res.data.nextPageToken) { + await getPlaylist( + playlistID, + res.data.nextPageToken, + order, + count + 1, + ).then((data) => { + // append data to current results + results = results.concat(data); + }); + } + results = sortPlaylist(results, order); + return results; + }, + ); +}