diff options
| author | Factiven <[email protected]> | 2023-09-26 23:35:35 +0700 |
|---|---|---|
| committer | Factiven <[email protected]> | 2023-09-26 23:35:35 +0700 |
| commit | 20b8a7267827e3a07c1eef668c3b9c22fda43765 (patch) | |
| tree | 2fec9006dfac5737d8b227bf5ccce73880800cc2 | |
| parent | Update release.md (diff) | |
| download | moopa-20b8a7267827e3a07c1eef668c3b9c22fda43765.tar.xz moopa-20b8a7267827e3a07c1eef668c3b9c22fda43765.zip | |
Update v4.1.2v4.1.2
26 files changed, 716 insertions, 295 deletions
@@ -50,7 +50,7 @@ ## Introduction -<p><a href="https://moopa.live">Moopa</a> is an anime streaming website made possible by <a href="https://github.com/consumet">Consumet API</a> build with <a href="https://github.com/vercel/next.js/">NextJs</a> and <a href="https://github.com/tailwindlabs/tailwindcss">Tailwind</a> with a sleek and modern design that offers Anilist integration to help you keep track of your favorite anime series. Moopa is entirely free and does not feature any ads, making it a great option for you who want an uninterrupted viewing experience.</p> +<p><a href="https://moopa.live">Moopa</a> is an anime streaming website made possible by the <a href="https://github.com/consumet">Consumet API</a>, built with <a href="https://github.com/vercel/next.js/">Next.js</a> and <a href="https://github.com/tailwindlabs/tailwindcss">Tailwind</a>, featuring a sleek and modern design. It offers Anilist integration to help you keep track of your favorite anime series. Moopa is entirely free and does not display any ads, making it a great option for those who want an uninterrupted viewing experience.</p> ## Features @@ -80,7 +80,7 @@ If you encounter any issues or bug on the site please head to [issues](https://g ## For Local Development -> If you host this site for personal use, please refrain from cloning it or adding ads. This project is non-profit and ads may violate its terms, leading to legal action or site takedown. Uphold these guidelines to maintain its integrity and mission. +> If you want to self-host this app, please note that it is only allowed for personal use. Commercial use is not permitted, and including ads on your self-hosted site may result in actions such as site takedown. 1. Clone this repository using : diff --git a/components/admin/dashboard/index.js b/components/admin/dashboard/index.js new file mode 100644 index 0000000..64a1d6f --- /dev/null +++ b/components/admin/dashboard/index.js @@ -0,0 +1,134 @@ +import React, { useState } from "react"; + +export default function AdminDashboard({ + animeCount, + infoCount, + metaCount, + report, +}) { + const [message, setMessage] = useState(""); + const [selectedTime, setSelectedTime] = useState(""); + const [unixTimestamp, setUnixTimestamp] = useState(null); + + const handleSubmit = (e) => { + e.preventDefault(); + + if (selectedTime) { + const unixTime = Math.floor(new Date(selectedTime).getTime() / 1000); + setUnixTimestamp(unixTime); + } + }; + return ( + <div className="flex flex-col gap-5 px-5 py-10 h-full"> + <div className="flex flex-col gap-2"> + <p className="font-semibold">Stats</p> + <div className="grid grid-cols-3 gap-5"> + <div className="flex-center flex-col bg-secondary rounded p-5"> + <p className="font-karla text-4xl">{animeCount}</p> + <p className="font-karla text-xl">Anime</p> + </div> + <div className="flex-center flex-col bg-secondary rounded p-5"> + <p className="font-karla text-4xl">{infoCount}</p> + <p className="font-karla text-xl">detail info</p> + </div> + <div className="flex-center flex-col bg-secondary rounded p-5"> + <p className="font-karla text-4xl">{metaCount}</p> + <p className="font-karla text-xl">Metadata</p> + </div> + </div> + </div> + <div className="grid grid-cols-2 gap-5 h-full"> + <div className="flex flex-col gap-2"> + <p className="font-semibold">Broadcast</p> + <div className="flex flex-col justify-between bg-secondary rounded p-5 h-full"> + <form onSubmit={handleSubmit}> + <div className="mb-4"> + <label + htmlFor="message" + className="block text-txt font-light mb-2" + > + Message + </label> + <input + type="text" + id="message" + value={message} + onChange={(e) => setMessage(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md focus:outline-none text-black" + /> + </div> + <div className="mb-4"> + <label + htmlFor="selectedTime" + className="block text-txt font-light mb-2" + > + Select Time + </label> + <input + type="datetime-local" + id="selectedTime" + value={selectedTime} + onChange={(e) => setSelectedTime(e.target.value)} + required + className="w-full px-3 py-2 border rounded-md focus:outline-none text-black" + /> + </div> + <button + type="submit" + className="bg-image text-white py-2 px-4 rounded-md hover:bg-opacity-80 transition duration-300" + > + Submit + </button> + </form> + {unixTimestamp && ( + <p> + Unix Timestamp: <strong>{unixTimestamp}</strong> + </p> + )} + </div> + </div> + <div className="flex flex-col gap-2"> + <p className="font-semibold">Recent Reports</p> + <div className="bg-secondary rounded p-5 h-full"> + <div className="rounded overflow-hidden w-full h-full"> + {report?.map((i, index) => ( + <div + key={index} + className="odd:bg-primary/80 even:bg-primary/40 p-2 flex justify-between items-center" + > + {i.desc}{" "} + {i.severity === "Low" && ( + <span className="relative w-5 h-5 flex-center shrink-0"> + {/* <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-500 opacity-75"></span> */} + <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span> + </span> + )} + {i.severity === "Medium" && ( + <span className="relative w-5 h-5 flex-center shrink-0"> + {/* <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-500 opacity-75"></span> */} + <span className="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span> + </span> + )} + {i.severity === "High" && ( + <span className="relative w-5 h-5 flex-center shrink-0"> + {/* <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-500 opacity-75"></span> */} + <span className="relative animate-pulse inline-flex rounded-full h-3 w-3 bg-rose-500"></span> + </span> + )} + {i.severity === "Critical" && ( + <span className="relative w-5 h-5 flex-center shrink-0"> + <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-900 opacity-75"></span> + <span className="relative inline-flex rounded-full h-3 w-3 bg-red-900"></span> + </span> + )} + </div> + ))} + </div> + </div> + </div> + </div> + <div className="w-full h-full">a</div> + </div> + ); +} diff --git a/components/admin/layout.js b/components/admin/layout.js new file mode 100644 index 0000000..3209dcf --- /dev/null +++ b/components/admin/layout.js @@ -0,0 +1,75 @@ +import { + CloudArrowUpIcon, + Cog6ToothIcon, + HomeIcon, + UserIcon, +} from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React from "react"; + +const Navigation = [ + { + name: "Dashboard", + page: 1, + icon: <HomeIcon />, + current: false, + }, + { + name: "Metadata", + page: 2, + icon: <CloudArrowUpIcon />, + current: false, + }, + { + name: "Users", + page: 3, + icon: <UserIcon />, + current: false, + }, + { + name: "Settings", + page: 4, + icon: <Cog6ToothIcon />, + current: false, + }, +]; + +export default function AdminLayout({ children, page, setPage }) { + return ( + <div className="relative w-screen h-screen"> + <div className="absolute flex flex-col gap-5 top-0 left-0 py-2 bg-secondary w-[14rem] h-full"> + <div className="flex flex-col px-3"> + <p className="text-sm font-light text-action font-outfit">moopa</p> + <h1 className="text-2xl font-bold text-white"> + Admin <br /> + Dashboard + </h1> + </div> + <div className="flex flex-col px-1"> + {Navigation.map((item, index) => ( + <button + key={item.name} + onClick={() => { + setPage(item.page); + }} + className={`flex items-center gap-2 p-2 group ${ + page == item.page ? "bg-image/50" : "text-txt" + } hover:bg-image rounded transition-colors duration-200 ease-in-out`} + > + <div + className={`w-5 h-5 ${ + page == item.page ? "text-action" : "text-txt" + } group-hover:text-action`} + > + {item.icon} + </div> + <p>{item.name}</p> + </button> + ))} + </div> + </div> + <div className="ml-[14rem] overflow-x-hidden h-full">{children}</div> + </div> + ); +} diff --git a/components/admin/meta/AppendMeta.js b/components/admin/meta/AppendMeta.js new file mode 100644 index 0000000..1707ed2 --- /dev/null +++ b/components/admin/meta/AppendMeta.js @@ -0,0 +1,252 @@ +import Loading from "@/components/shared/loading"; +import Image from "next/image"; +import { useState } from "react"; +import { toast } from "react-toastify"; + +// Define a function to convert the data +function convertData(episodes) { + const convertedData = episodes.map((episode) => ({ + episode: episode.episode, + title: episode?.title, + description: episode?.description || null, + img: episode?.img?.hd || episode?.img?.mobile || null, // Use hd if available, otherwise use mobile + })); + + return convertedData; +} + +export default function AppendMeta({ api }) { + const [id, setId] = useState(); + const [resultData, setResultData] = useState(null); + + const [query, setQuery] = useState(""); + const [tmdbId, setTmdbId] = useState(); + const [hasilQuery, setHasilQuery] = useState([]); + const [season, setSeason] = useState(); + + const [override, setOverride] = useState(); + + const [loading, setLoading] = useState(false); + + const handleSearch = async () => { + try { + setLoading(true); + setResultData(null); + const res = await fetch(`${api}/meta/tmdb/${query}`); + const json = await res.json(); + const data = json.results.filter((i) => i.type === "TV Series"); + setHasilQuery(data); + setLoading(false); + } catch (err) { + console.log(err); + } + }; + + const handleDetail = async () => { + try { + setLoading(true); + const res = await fetch(`${api}/meta/tmdb/info/${tmdbId}?type=TV%20Series +`); + const json = await res.json(); + const data = json.seasons; + setHasilQuery(data); + setLoading(false); + } catch (err) { + console.log(err); + } + }; + + const handleStore = async () => { + try { + setLoading(true); + if (!resultData && !id) { + console.log("No data to store"); + setLoading(false); + return; + } + const data = await fetch("/api/v2/admin/meta", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: id, + data: resultData, + }), + }); + if (data.status === 200) { + const json = await data.json(); + toast.success(json.message); + setLoading(false); + } + } catch (err) { + console.log(err); + } + }; + + const handleOverride = async () => { + setResultData(JSON.parse(override)); + }; + + return ( + <> + <div className="container mx-auto p-4 scrol"> + <h1 className="text-3xl font-semibold mb-4">Append Data Page</h1> + <div> + <div className="space-y-3 mb-4"> + <label>Search Anime:</label> + <input + type="text" + className="w-full px-3 py-2 border rounded-md text-black" + value={query} + onChange={(e) => setQuery(e.target.value)} + /> + <button + type="button" + className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" + onClick={handleSearch} + > + Find Anime{" "} + </button> + </div> + <div className="space-y-3 mb-4"> + <label>Get Episodes:</label> + <input + type="number" + placeholder="TMDB ID" + className="w-full px-3 py-2 border rounded-md text-black" + value={tmdbId} + onChange={(e) => setTmdbId(e.target.value)} + /> + <button + type="button" + className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" + onClick={handleDetail} + > + Get Details + </button> + </div> + + <div className="space-y-3 mb-4"> + <label>Override Result:</label> + <textarea + rows="5" + className="w-full px-3 py-2 border rounded-md text-black" + value={override} + onChange={(e) => setOverride(e.target.value)} + /> + <button + type="button" + className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" + onClick={handleOverride} + > + Override{" "} + </button> + </div> + + <div className="space-y-3 mb-4"> + <label className="block text-sm font-medium text-gray-300"> + Anime ID: + </label> + <input + type="number" + placeholder="AniList ID" + className="w-full px-3 py-2 border rounded-md text-black" + value={id} + onChange={(e) => setId(e.target.value)} + /> + </div> + <div className="mb-4"> + <button + className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" + onClick={handleStore} + > + Store Data {season && `Season ${season}`} + </button> + </div> + + {!loading && hasilQuery?.some((i) => i?.season) && ( + <div className="border rounded-md p-4 mt-4"> + <h2 className="text-lg font-semibold mb-2"> + Which season do you want to format? + </h2> + <div className="w-full flex gap-2"> + {hasilQuery?.map((season, index) => ( + <button + type="button" + className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" + key={index} + onClick={() => { + setLoading(true); + const data = hasilQuery[index].episodes; + const convertedData = convertData(data); + setSeason(index + 1); + setResultData(convertedData); + console.log(convertedData); + setLoading(false); + }} + > + <p>{season.season} </p> + </button> + ))} + </div> + </div> + )} + + {!loading && resultData && ( + <div className="border rounded-md p-4 mt-4"> + <h2 className="text-lg font-semibold mb-2">Season {season}</h2> + <pre>{JSON.stringify(resultData, null, 2)}</pre> + </div> + )} + {!loading && hasilQuery && ( + <div className="border rounded-md p-4 mt-4"> + {/* <h2 className="text-lg font-semibold mb-2"> + Result Data,{" "} + {hasilQuery.length > 0 && `${hasilQuery.length} Seasons`}: + </h2> */} + <div className="flex flex-wrap gap-10"> + {!hasilQuery.every((i) => i?.episodes) && + hasilQuery?.map((i, index) => ( + <div + key={i.id} + className="flex flex-col items-center gap-2" + > + <p className="font-karla font-semibold"> + {i.releaseDate} + </p> + <Image + src={i.image} + width={500} + height={500} + className="w-[160px] h-[210px] object-cover" + /> + <button + className="bg-blue-500 text-white w-[160px] py-1 rounded-md hover:bg-blue-600 text-sm" + onClick={() => { + setTmdbId(i.id); + }} + > + <p className="line-clamp-1 px-1">{i.title}</p> + </button> + </div> + ))} + </div> + <pre>{JSON.stringify(hasilQuery, null, 2)}</pre> + </div> + )} + + {loading && <Loading />} + </div> + <div> + {/* {resultData && ( + <div className="border rounded-md p-4 mt-4"> + <h2 className="text-lg font-semibold mb-2">Result Data:</h2> + <pre>{JSON.stringify(resultData, null, 2)}</pre> + </div> + )} */} + </div> + </div> + </> + ); +} diff --git a/components/anime/episode.js b/components/anime/episode.js index 6f96c98..25ed997 100644 --- a/components/anime/episode.js +++ b/components/anime/episode.js @@ -34,12 +34,16 @@ export default function AnimeEpisode({ info.status === "RELEASING" ? "true" : "false" }${isDub ? "&dub=true" : ""}` ).then((res) => res.json()); - const getMap = response.find((i) => i?.map === true); + const getMap = response.find((i) => i?.map === true) || response[0]; let allProvider = response; if (getMap) { allProvider = response.filter((i) => { - if (i?.providerId === "gogoanime" && i?.map !== true) { + if ( + i?.providerId === "gogoanime" && + i?.providerId === "9anime" && + i?.map !== true + ) { return null; } return i; @@ -122,7 +126,6 @@ export default function AnimeEpisode({ useEffect(() => { if (artStorage) { - // console.log({ artStorage }); const currentData = JSON.parse(localStorage.getItem("artplayer_settings")) || {}; @@ -138,15 +141,18 @@ export default function AnimeEpisode({ } if (!session?.user?.name) { - setProgress( - Object.keys(updatedData).length > 0 - ? Math.max( - ...Object.keys(updatedData).map( - (key) => updatedData[key].episode - ) - ) - : 0 + const maxWatchedEpisode = Object.keys(updatedData).reduce( + (maxEpisode, key) => { + const episodeData = updatedData[key]; + if (episodeData.timeWatched >= episodeData.duration * 0.9) { + return Math.max(maxEpisode, episodeData.episode); + } + return maxEpisode; + }, + 0 ); + + setProgress(maxWatchedEpisode); } else { return; } @@ -177,7 +183,7 @@ export default function AnimeEpisode({ setLoading(false); } else { const data = await res.json(); - const getMap = data.find((i) => i?.map === true); + const getMap = data.find((i) => i?.map === true) || data[0]; let allProvider = data; if (getMap) { diff --git a/components/anime/mobile/reused/description.js b/components/anime/mobile/reused/description.js index 99973d3..3b61c80 100644 --- a/components/anime/mobile/reused/description.js +++ b/components/anime/mobile/reused/description.js @@ -10,7 +10,7 @@ export default function Description({ className={`${ info?.description?.replace(/<[^>]*>/g, "").length > 240 ? "" - : "pointer-events-none" + : "pointer-events-none hidden" } ${ readMore ? "hidden" : "" } absolute z-30 flex items-end justify-center top-0 w-full h-full transition-all duration-200 ease-linear md:opacity-0 md:hover:opacity-100 bg-gradient-to-b from-transparent to-primary to-95%`} diff --git a/components/anime/viewMode/thumbnailDetail.js b/components/anime/viewMode/thumbnailDetail.js index c7d55a0..494a89f 100644 --- a/components/anime/viewMode/thumbnailDetail.js +++ b/components/anime/viewMode/thumbnailDetail.js @@ -41,9 +41,9 @@ export default function ThumbnailDetail({ className={`absolute bottom-0 left-0 h-[2px] bg-red-700`} style={{ width: - progress || (artStorage && epi?.number <= progress) + progress !== undefined && progress >= epi?.number ? "100%" - : artStorage?.[epi?.id] + : artStorage?.[epi?.id] !== undefined ? `${prog}%` : "0%", }} diff --git a/components/anime/viewMode/thumbnailOnly.js b/components/anime/viewMode/thumbnailOnly.js index 7259beb..1b403fa 100644 --- a/components/anime/viewMode/thumbnailOnly.js +++ b/components/anime/viewMode/thumbnailOnly.js @@ -29,7 +29,7 @@ export default function ThumbnailOnly({ className={`absolute bottom-7 left-0 h-[2px] bg-red-600`} style={{ width: - progress && artStorage && episode?.number <= progress + progress && episode?.number <= progress ? "100%" : artStorage?.[episode?.id] ? `${prog}%` diff --git a/components/anime/viewSelector.js b/components/anime/viewSelector.js index f114a8b..baa13b2 100644 --- a/components/anime/viewSelector.js +++ b/components/anime/viewSelector.js @@ -6,6 +6,7 @@ export default function ViewSelector({ view, setView, episode, map }) { episode?.length > 0 ? map?.every( (item) => + item?.img === null || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null @@ -33,6 +34,7 @@ export default function ViewSelector({ view, setView, episode, map }) { episode?.length > 0 ? map?.every( (item) => + item?.img === null || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null @@ -52,6 +54,7 @@ export default function ViewSelector({ view, setView, episode, map }) { episode?.length > 0 ? map?.every( (item) => + item?.img === null || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null @@ -74,6 +77,7 @@ export default function ViewSelector({ view, setView, episode, map }) { episode?.length > 0 ? map?.every( (item) => + item?.img === null || item?.img?.includes("https://s4.anilist.co/") || item?.image?.includes("https://s4.anilist.co/") || item.title === null diff --git a/components/home/schedule.js b/components/home/schedule.js index a9846a7..d618412 100644 --- a/components/home/schedule.js +++ b/components/home/schedule.js @@ -55,7 +55,7 @@ export default function Schedule({ data, scheduleData, anime, update }) { <div className="w-1/2 lg:w-2/5 hidden lg:block font-medium font-karla leading-[2.9rem] text-white line-clamp-1"> <Link href={`/en/anime/${data.id}`} - className="hover:underline underline-offset-4 decoration-2 lg:text-[1.7vw] " + className="hover:underline underline-offset-4 decoration-2 leading-3 lg:text-[1.5vw] " > {data.title.romaji || data.title.english || data.title.native} </Link> diff --git a/components/searchPalette.js b/components/searchPalette.js index 07c8f89..38a0bc0 100644 --- a/components/searchPalette.js +++ b/components/searchPalette.js @@ -3,7 +3,7 @@ import { Combobox, Dialog, Menu, Transition } from "@headlessui/react"; import useDebounce from "../lib/hooks/useDebounce"; import Image from "next/image"; import { useRouter } from "next/router"; -import { useSearch } from "../lib/hooks/isOpenState"; +import { useSearch } from "../lib/context/isOpenState"; import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline"; import { BookOpenIcon, PlayIcon } from "@heroicons/react/20/solid"; import { useAniList } from "../lib/anilist/useAnilist"; diff --git a/components/shared/NavBar.js b/components/shared/NavBar.js index 42fcff0..7bbd617 100644 --- a/components/shared/NavBar.js +++ b/components/shared/NavBar.js @@ -1,4 +1,4 @@ -import { useSearch } from "@/lib/hooks/isOpenState"; +import { useSearch } from "@/lib/context/isOpenState"; import { getCurrentSeason } from "@/utils/getTimes"; import { ArrowLeftIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"; import { UserIcon } from "@heroicons/react/24/solid"; diff --git a/components/watch/player/artplayer.js b/components/watch/player/artplayer.js index 4eb766d..2ab4ded 100644 --- a/components/watch/player/artplayer.js +++ b/components/watch/player/artplayer.js @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react"; import Artplayer from "artplayer"; import Hls from "hls.js"; -import { useWatchProvider } from "../../../lib/hooks/watchPageProvider"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; import { seekBackward, seekForward } from "./component/overlay"; import artplayerPluginHlsQuality from "artplayer-plugin-hls-quality"; @@ -10,6 +10,7 @@ export default function NewPlayer({ option, getInstance, provider, + track, defSub, defSize, subtitles, @@ -274,6 +275,46 @@ export default function NewPlayer({ ], }); + if ("mediaSession" in navigator) { + art.on("video:timeupdate", () => { + const session = navigator.mediaSession; + if (!session) return; + session.setPositionState({ + duration: art.duration, + playbackRate: art.playbackRate, + position: art.currentTime, + }); + }); + + navigator.mediaSession.setActionHandler("play", () => { + art.play(); + }); + + navigator.mediaSession.setActionHandler("pause", () => { + art.pause(); + }); + + navigator.mediaSession.setActionHandler("previoustrack", () => { + if (track?.prev) { + router.push( + `/en/anime/watch/${id}/${provider}?id=${encodeURIComponent( + track?.prev?.id + )}&num=${track?.prev?.number}` + ); + } + }); + + navigator.mediaSession.setActionHandler("nexttrack", () => { + if (track?.next) { + router.push( + `/en/anime/watch/${id}/${provider}?id=${encodeURIComponent( + track?.next?.id + )}&num=${track?.next?.number}` + ); + } + }); + } + playerRef.current = art; art.events.proxy(document, "keydown", (event) => { diff --git a/components/watch/player/playerComponent.js b/components/watch/player/playerComponent.js index 9fe9cd3..a524b79 100644 --- a/components/watch/player/playerComponent.js +++ b/components/watch/player/playerComponent.js @@ -1,9 +1,9 @@ import React, { useEffect, useState } from "react"; import NewPlayer from "./artplayer"; import { icons } from "./component/overlay"; -import { useWatchProvider } from "../../../lib/hooks/watchPageProvider"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; import { useRouter } from "next/router"; -import { useAniList } from "../../../lib/anilist/useAnilist"; +import { useAniList } from "@/lib/anilist/useAnilist"; export function calculateAspectRatio(width, height) { const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b)); @@ -475,6 +475,7 @@ export default function PlayerComponent({ quality={source} option={option} provider={provider} + track={track} defSize={defSize} defSub={defSub} subSize={subSize} diff --git a/components/watch/secondary/episodeLists.js b/components/watch/secondary/episodeLists.js index 8a057ce..5fa21ad 100644 --- a/components/watch/secondary/episodeLists.js +++ b/components/watch/secondary/episodeLists.js @@ -12,6 +12,7 @@ export default function EpisodeLists({ dub, }) { const progress = info.mediaListEntry?.progress; + return ( <div className="w-screen lg:max-w-sm xl:max-w-xl"> <h1 className="text-xl font-karla pl-5 pb-5 font-semibold">Up Next</h1> @@ -67,11 +68,11 @@ export default function EpisodeLists({ className={`absolute bottom-0 left-0 h-[2px] bg-red-700`} style={{ width: - progress && artStorage && item?.number <= progress + progress !== undefined && progress >= item?.number ? "100%" - : artStorage?.[item?.id] + : artStorage?.[item?.id] !== undefined ? `${prog}%` - : "0", + : "0%", }} /> <span className="absolute bottom-2 left-2 font-karla font-bold text-sm"> diff --git a/lib/hooks/isOpenState.js b/lib/context/isOpenState.js index 6aade61..6aade61 100644 --- a/lib/hooks/isOpenState.js +++ b/lib/context/isOpenState.js diff --git a/lib/hooks/watchPageProvider.js b/lib/context/watchPageProvider.js index a9d707b..a9d707b 100644 --- a/lib/hooks/watchPageProvider.js +++ b/lib/context/watchPageProvider.js diff --git a/package-lock.json b/package-lock.json index 6315554..0632d17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "moopa", - "version": "4.1.1", + "version": "4.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moopa", - "version": "4.1.1", + "version": "4.1.2", "dependencies": { "@apollo/client": "^3.7.3", "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.17", - "@prisma/client": "^5.1.1", + "@prisma/client": "^5.3.1", "@vercel/og": "^0.5.4", "artplayer": "^5.0.9", "artplayer-plugin-hls-quality": "^2.0.0", @@ -26,7 +26,6 @@ "next": "^13.5.2", "next-auth": "^4.22.0", "next-pwa": "^5.6.0", - "next-safe": "^3.4.1", "nextjs-progressbar": "^0.0.16", "nookies": "^2.5.2", "rate-limiter-flexible": "^3.0.0", @@ -44,7 +43,7 @@ "depcheck": "^1.4.3", "eslint": "^8.38.0", "eslint-config-next": "^13.5.2", - "prisma": "^5.1.1", + "prisma": "^5.3.1", "tailwind-scrollbar": "^2.1.0", "tailwindcss": "^3.3.1" } @@ -2218,12 +2217,12 @@ } }, "node_modules/@prisma/client": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.1.1.tgz", - "integrity": "sha512-fxcCeK5pMQGcgCqCrWsi+I2rpIbk0rAhdrN+ke7f34tIrgPwA68ensrpin+9+fZvuV2OtzHmuipwduSY6HswdA==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz", + "integrity": "sha512-ArOKjHwdFZIe1cGU56oIfy7wRuTn0FfZjGuU/AjgEBOQh+4rDkB6nF+AGHP8KaVpkBIiHGPQh3IpwQ3xDMdO0Q==", "hasInstallScript": true, "dependencies": { - "@prisma/engines-version": "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e" + "@prisma/engines-version": "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59" }, "engines": { "node": ">=16.13" @@ -2238,16 +2237,16 @@ } }, "node_modules/@prisma/engines": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.1.1.tgz", - "integrity": "sha512-NV/4nVNWFZSJCCIA3HIFJbbDKO/NARc9ej0tX5S9k2EVbkrFJC4Xt9b0u4rNZWL4V+F5LAjvta8vzEUw0rw+HA==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.3.1.tgz", + "integrity": "sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA==", "devOptional": true, "hasInstallScript": true }, "node_modules/@prisma/engines-version": { - "version": "5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.1.1-1.6a3747c37ff169c90047725a05a6ef02e32ac97e.tgz", - "integrity": "sha512-owZqbY/wucbr65bXJ/ljrHPgQU5xXTSkmcE/JcbqE1kusuAXV/TLN3/exmz21SZ5rJ7WDkyk70J2G/n68iogbQ==" + "version": "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59.tgz", + "integrity": "sha512-y5qbUi3ql2Xg7XraqcXEdMHh0MocBfnBzDn5GbV1xk23S3Mq8MGs+VjacTNiBh3dtEdUERCrUUG7Z3QaJ+h79w==" }, "node_modules/@resvg/resvg-wasm": { "version": "2.4.1", @@ -6748,14 +6747,6 @@ "webpack": "^4.4.0 || ^5.9.0" } }, - "node_modules/next-safe": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/next-safe/-/next-safe-3.4.1.tgz", - "integrity": "sha512-GOam3DYMHUIKwxHeqVg9pkuYFhvtUeivHexdbL3lg0mjibsnIB3NOD81dKDgaeX+cReWbugdCtWYllzXmmpE+Q==", - "peerDependencies": { - "next": "^9.5.0 || ^10.2.1 || ^11.1.0 || ^12.1.0 || ^13.0.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -7461,13 +7452,13 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" }, "node_modules/prisma": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.1.1.tgz", - "integrity": "sha512-WJFG/U7sMmcc6TjJTTifTfpI6Wjoh55xl4AzopVwAdyK68L9/ogNo8QQ2cxuUjJf/Wa82z/uhyh3wMzvRIBphg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.3.1.tgz", + "integrity": "sha512-Wp2msQIlMPHe+5k5Od6xnsI/WNG7UJGgFUJgqv/ygc7kOECZapcSz/iU4NIEzISs3H1W9sFLjAPbg/gOqqtB7A==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/engines": "5.1.1" + "@prisma/engines": "5.3.1" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index 61ba395..116d84a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moopa", - "version": "4.1.1", + "version": "4.1.2", "private": true, "founder": "Factiven", "scripts": { @@ -14,7 +14,7 @@ "@apollo/client": "^3.7.3", "@headlessui/react": "^1.7.15", "@heroicons/react": "^2.0.17", - "@prisma/client": "^5.1.1", + "@prisma/client": "^5.3.1", "@vercel/og": "^0.5.4", "artplayer": "^5.0.9", "artplayer-plugin-hls-quality": "^2.0.0", @@ -29,7 +29,6 @@ "next": "^13.5.2", "next-auth": "^4.22.0", "next-pwa": "^5.6.0", - "next-safe": "^3.4.1", "nextjs-progressbar": "^0.0.16", "nookies": "^2.5.2", "rate-limiter-flexible": "^3.0.0", @@ -47,7 +46,7 @@ "depcheck": "^1.4.3", "eslint": "^8.38.0", "eslint-config-next": "^13.5.2", - "prisma": "^5.1.1", + "prisma": "^5.3.1", "tailwind-scrollbar": "^2.1.0", "tailwindcss": "^3.3.1" } diff --git a/pages/_app.js b/pages/_app.js index 9e07d22..f553a98 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -7,9 +7,9 @@ import "react-toastify/dist/ReactToastify.css"; import "react-loading-skeleton/dist/skeleton.css"; import { SkeletonTheme } from "react-loading-skeleton"; import SearchPalette from "@/components/searchPalette"; -import { SearchProvider } from "@/lib/hooks/isOpenState"; +import { SearchProvider } from "@/lib/context/isOpenState"; import Head from "next/head"; -import { WatchPageProvider } from "@/lib/hooks/watchPageProvider"; +import { WatchPageProvider } from "@/lib/context/watchPageProvider"; import { ToastContainer, toast } from "react-toastify"; import { useEffect } from "react"; import { unixTimestampToRelativeTime } from "@/utils/getTimes"; diff --git a/pages/admin/index.js b/pages/admin/index.js index 4fdc8c2..cbb5086 100644 --- a/pages/admin/index.js +++ b/pages/admin/index.js @@ -1,19 +1,14 @@ +import AdminDashboard from "@/components/admin/dashboard"; +import AdminLayout from "@/components/admin/layout"; +import AppendMeta from "@/components/admin/meta/AppendMeta"; +import { + countKeysWithPrefix, + countNumericKeys, + getValuesWithPrefix, +} from "@/utils/getRedisWithPrefix"; import { getServerSession } from "next-auth"; import { authOptions } from "pages/api/auth/[...nextauth]"; -import { useState } from "react"; -import { toast } from "react-toastify"; - -// Define a function to convert the data -function convertData(episodes) { - const convertedData = episodes.map((episode) => ({ - episode: episode.episode, - title: episode?.title, - description: episode?.description || null, - img: episode?.img?.hd || episode?.img?.mobile || null, // Use hd if available, otherwise use mobile - })); - - return convertedData; -} +import React, { useState } from "react"; export async function getServerSideProps(context) { const sessions = await getServerSession( @@ -43,221 +38,49 @@ export async function getServerSideProps(context) { }; } + const [anime, info, meta, report] = await Promise.all([ + countNumericKeys(), + countKeysWithPrefix("anime:"), + countKeysWithPrefix("meta:"), + getValuesWithPrefix("report:"), + ]); + return { props: { session: sessions, + animeCount: anime || 0, + infoCount: info || 0, + metaCount: meta || 0, + report: report || [], api, }, }; } -export default function Admin({ api }) { - const [id, setId] = useState(); - const [resultData, setResultData] = useState(null); - - const [query, setQuery] = useState(""); - const [tmdbId, setTmdbId] = useState(); - const [hasilQuery, setHasilQuery] = useState([]); - const [season, setSeason] = useState(); - - const [override, setOverride] = useState(); - - const [loading, setLoading] = useState(false); - - const handleSearch = async () => { - try { - setLoading(true); - setResultData(null); - const res = await fetch(`${api}/meta/tmdb/${query}`); - const json = await res.json(); - const data = json.results; - setHasilQuery(data); - setLoading(false); - } catch (err) { - console.log(err); - } - }; - - const handleDetail = async () => { - try { - setLoading(true); - const res = await fetch(`${api}/meta/tmdb/info/${tmdbId}?type=TV%20Series -`); - const json = await res.json(); - const data = json.seasons; - setHasilQuery(data); - setLoading(false); - } catch (err) { - console.log(err); - } - }; - - const handleStore = async () => { - try { - setLoading(true); - if (!resultData && !id) { - console.log("No data to store"); - setLoading(false); - return; - } - const data = await fetch("/api/v2/admin/meta", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: id, - data: resultData, - }), - }); - if (data.status === 200) { - const json = await data.json(); - toast.success(json.message); - setLoading(false); - } - } catch (err) { - console.log(err); - } - }; - - const handleOverride = async () => { - setResultData(JSON.parse(override)); - }; +export default function Admin({ + animeCount, + infoCount, + metaCount, + report, + api, +}) { + const [page, setPage] = useState(1); return ( - <> - <div className="container mx-auto p-4"> - <h1 className="text-3xl font-semibold mb-4">Append Data Page</h1> - <div> - <div className="space-y-3 mb-4"> - <label>Search Anime:</label> - <input - type="text" - className="w-full px-3 py-2 border rounded-md text-black" - value={query} - onChange={(e) => setQuery(e.target.value)} - /> - <button - type="button" - className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" - onClick={handleSearch} - > - Find Anime{" "} - {loading && <span className="animate-spin ml-2">🔄</span>} - </button> - </div> - <div className="space-y-3 mb-4"> - <label>Get Episodes:</label> - <input - type="number" - className="w-full px-3 py-2 border rounded-md text-black" - value={tmdbId} - onChange={(e) => setTmdbId(e.target.value)} - /> - <button - type="button" - className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" - onClick={handleDetail} - > - Get Details{" "} - {loading && <span className="animate-spin ml-2">🔄</span>} - </button> - </div> - - <div className="space-y-3 mb-4"> - <label>Override Result:</label> - <textarea - rows="5" - className="w-full px-3 py-2 border rounded-md text-black" - value={override} - onChange={(e) => setOverride(e.target.value)} - /> - <button - type="button" - className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" - onClick={handleOverride} - > - Override{" "} - {loading && <span className="animate-spin ml-2">🔄</span>} - </button> - </div> - - <div className="space-y-3 mb-4"> - <label className="block text-sm font-medium text-gray-300"> - Anime ID: - </label> - <input - type="number" - className="w-full px-3 py-2 border rounded-md text-black" - value={id} - onChange={(e) => setId(e.target.value)} - /> - </div> - <div className="mb-4"> - <button - className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" - onClick={handleStore} - > - Store Data {season && `Season ${season}`} - </button> - </div> - - {hasilQuery?.some((i) => i?.season) && ( - <div className="border rounded-md p-4 mt-4"> - <h2 className="text-lg font-semibold mb-2"> - Which season do you want to format? - </h2> - <div className="w-full flex gap-2"> - {hasilQuery?.map((season, index) => ( - <button - type="button" - className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600" - key={index} - onClick={() => { - setLoading(true); - const data = hasilQuery[index].episodes; - const convertedData = convertData(data); - setSeason(index + 1); - setResultData(convertedData); - console.log(convertedData); - setLoading(false); - }} - > - <p> - {season.season}{" "} - {loading && <span className="animate-spin ml-2">🔄</span>} - </p> - </button> - ))} - </div> - </div> - )} - - {resultData && ( - <div className="border rounded-md p-4 mt-4"> - <h2 className="text-lg font-semibold mb-2">Season {season}</h2> - <pre>{JSON.stringify(resultData, null, 2)}</pre> - </div> - )} - {hasilQuery && ( - <div className="border rounded-md p-4 mt-4"> - <h2 className="text-lg font-semibold mb-2"> - Result Data,{" "} - {hasilQuery.length > 0 && `${hasilQuery.length} Seasons`}: - </h2> - <pre>{JSON.stringify(hasilQuery, null, 2)}</pre> - </div> - )} - </div> - <div> - {/* {resultData && ( - <div className="border rounded-md p-4 mt-4"> - <h2 className="text-lg font-semibold mb-2">Result Data:</h2> - <pre>{JSON.stringify(resultData, null, 2)}</pre> - </div> - )} */} - </div> + <AdminLayout page={page} setPage={setPage}> + <div className="h-full"> + {page == 1 && ( + <AdminDashboard + animeCount={animeCount} + infoCount={infoCount} + metaCount={metaCount} + report={report} + /> + )} + {page == 2 && <AppendMeta api={api} />} + {page == 3 && <p className="flex-center h-full">Coming Soon!</p>} + {page == 4 && <p className="flex-center h-full">Coming Soon!</p>} </div> - </> + </AdminLayout> ); } diff --git a/pages/api/v2/admin/meta/index.js b/pages/api/v2/admin/meta/index.js index 5f51b7f..600a3ef 100644 --- a/pages/api/v2/admin/meta/index.js +++ b/pages/api/v2/admin/meta/index.js @@ -27,12 +27,6 @@ export default async function handler(req, res) { }); } - const getId = await redis.get(`meta:${id}`); - if (getId) { - return res - .status(200) - .json({ message: `Data already exist for id: ${id}` }); - } await redis.set(`meta:${id}`, JSON.stringify(data)); return res .status(200) diff --git a/pages/en/anime/[...id].js b/pages/en/anime/[...id].js index 4809ce5..910bbc6 100644 --- a/pages/en/anime/[...id].js +++ b/pages/en/anime/[...id].js @@ -152,7 +152,7 @@ export default function Info({ info, color }) { <MobileNav sessions={session} hideProfile={true} /> <main className="w-screen min-h-screen relative flex flex-col items-center bg-primary gap-5"> <div className="w-screen absolute"> - <div className="bg-gradient-to-t from-primary from-10% to-transparent absolute h-[280px] w-screen z-10 inset-0 backdrop-blur-[2px]" /> + <div className="bg-gradient-to-t from-primary from-10% to-transparent absolute h-[280px] w-screen z-10 inset-0" /> {info?.bannerImage && ( <Image src={info?.bannerImage} @@ -160,7 +160,7 @@ export default function Info({ info, color }) { height={1000} width={1000} blurDataURL={info?.bannerImage} - className="object-cover bg-image w-screen absolute top-0 left-0 h-[250px] brightness-[55%] z-0" + className="object-cover bg-image blur-[2px] w-screen absolute top-0 left-0 h-[250px] brightness-[55%] z-0" /> )} </div> diff --git a/pages/en/anime/watch/[...info].js b/pages/en/anime/watch/[...info].js index f5b4fce..b74a3f2 100644 --- a/pages/en/anime/watch/[...info].js +++ b/pages/en/anime/watch/[...info].js @@ -4,7 +4,7 @@ import { FlagIcon, ShareIcon } from "@heroicons/react/24/solid"; import Details from "@/components/watch/primary/details"; import EpisodeLists from "@/components/watch/secondary/episodeLists"; import { getServerSession } from "next-auth"; -import { useWatchProvider } from "@/lib/hooks/watchPageProvider"; +import { useWatchProvider } from "@/lib/context/watchPageProvider"; import { authOptions } from "../../../api/auth/[...nextauth]"; import { createList, createUser, getEpisode } from "@/prisma/user"; import Link from "next/link"; @@ -289,6 +289,29 @@ export default function Watch({ }; }, [provider, watchId, info?.id]); + useEffect(() => { + const mediaSession = navigator.mediaSession; + if (!mediaSession) return; + + const now = episodeNavigation?.playing; + const poster = now?.img || info?.bannerImage; + const title = now?.title || info?.title?.romaji; + + const artwork = poster + ? [{ src: poster, sizes: "512x512", type: "image/jpeg" }] + : undefined; + + mediaSession.metadata = new MediaMetadata({ + title: title, + artist: `Moopa ${ + title === info?.title?.romaji + ? "- Episode " + epiNumber + : `- ${info?.title?.romaji || info?.title?.english}` + }`, + artwork, + }); + }, [episodeNavigation, info, epiNumber]); + const handleShareClick = async () => { try { if (navigator.share) { @@ -338,7 +361,6 @@ export default function Watch({ <meta name="robots" content="index, follow" /> <meta property="og:type" content="website" /> - <meta property="og:url" content="https://moopa.live/" /> <meta property="og:title" content={`Watch - ${ @@ -347,12 +369,19 @@ export default function Watch({ /> <meta property="og:description" - content="Discover your new favorite anime or manga title! Moopa offers a vast library of high-quality content, accessible on multiple devices and without any interruptions. Start using Moopa today!" + content={episodeNavigation?.playing?.description || info?.description} + /> + <meta + property="og:image" + content={episodeNavigation?.playing?.img || info?.bannerImage} /> - <meta property="og:image" content="/preview.png" /> <meta property="og:site_name" content="Moopa" /> <meta name="twitter:card" content="summary_large_image" /> <meta + name="twitter:image" + content={episodeNavigation?.playing?.img || info?.bannerImage} + /> + <meta name="twitter:title" content={`Watch - ${ episodeNavigation?.playing?.title || info?.title?.english @@ -499,6 +528,7 @@ export default function Watch({ > <EpisodeLists info={info} + session={sessions} map={mapEpisode} providerId={provider} watchId={watchId} @@ -2,10 +2,9 @@ This document contains a summary of all significant changes made to this release. -## 🎉 Update v4.1.1 +## 🎉 Update v4.1.2 ### Fixed -- Another patch API episode route -- Manga list at homepage send user to anime page -- Video progress doesn't showed up on episode thumbnail +- Improvement on episode thumbnail when showing progress +- Resolved mediaSession not showing on mobile devices diff --git a/utils/getRedisWithPrefix.js b/utils/getRedisWithPrefix.js new file mode 100644 index 0000000..31a466d --- /dev/null +++ b/utils/getRedisWithPrefix.js @@ -0,0 +1,71 @@ +import { redis } from "@/lib/redis"; + +export async function getValuesWithPrefix(prefix) { + let cursor = "0"; // Start at the beginning of the keyspace + let values = []; + + do { + const [newCursor, matchingKeys] = await redis.scan( + cursor, + "MATCH", + prefix + "*", + "COUNT", + 100 + ); + + // Retrieve values for matching keys and add them to the array + for (const key of matchingKeys) { + const value = await redis.get(key); + values.push(JSON.parse(value)); + } + + // Update the cursor for the next iteration + cursor = newCursor; + } while (cursor !== "0"); // Continue until the cursor is '0' + + return values; +} + +export async function countKeysWithPrefix(prefix) { + let cursor = "0"; // Start at the beginning of the keyspace + let count = 0; + + do { + const [newCursor, matchingKeys] = await redis.scan( + cursor, + "MATCH", + prefix + "*", + "COUNT", + 100 + ); + + // Increment the count by the number of matching keys in this iteration + count += matchingKeys.length; + + // Update the cursor for the next iteration + cursor = newCursor; + } while (cursor !== "0"); // Continue until the cursor is '0' + + return count; +} + +export async function getValuesWithNumericKeys() { + const allKeys = await redis.keys("*"); // Fetch all keys in Redis + const numericKeys = allKeys.filter((key) => /^\d+$/.test(key)); // Filter keys that contain only numbers + + const values = []; + + for (const key of numericKeys) { + const value = await redis.get(key); // Retrieve the value for each numeric key + values.push(value); + } + + return values; +} + +export async function countNumericKeys() { + const allKeys = await redis.keys("*"); // Fetch all keys in Redis + const numericKeys = allKeys.filter((key) => /^\d+$/.test(key)); // Filter keys that contain only numbers + + return numericKeys.length; // Return the count of numeric keys +} |