├── .env.example ├── .eslintrc.json ├── .gitattributes ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── bun.lockb ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── ThemeProvider.tsx │ ├── api │ │ ├── temp │ │ │ └── episodes │ │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── v1 │ │ │ ├── characters │ │ │ │ └── [id] │ │ │ │ │ └── route.ts │ │ │ ├── episodes │ │ │ │ └── [id] │ │ │ │ │ └── route.ts │ │ │ ├── episodesMetadata │ │ │ │ └── [id] │ │ │ │ │ └── route.ts │ │ │ ├── info │ │ │ │ └── [id] │ │ │ │ │ └── route.ts │ │ │ ├── search │ │ │ │ └── [query] │ │ │ │ │ └── route.ts │ │ │ └── source │ │ │ │ └── route.ts │ │ └── v2 │ │ │ ├── advancedSearch │ │ │ └── route.ts │ │ │ ├── episodes │ │ │ └── [id] │ │ │ │ └── route.ts │ │ │ ├── info │ │ │ └── [id] │ │ │ │ └── route.ts │ │ │ ├── studios │ │ │ └── [id] │ │ │ │ └── route.ts │ │ │ └── thumbnails │ │ │ └── [id] │ │ │ └── route.ts │ ├── catalog │ │ ├── Card.tsx │ │ └── page.tsx │ ├── dmca │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── info │ │ └── [id] │ │ │ ├── Accordions.tsx │ │ │ ├── Img.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── popular │ │ ├── Card.tsx │ │ └── page.tsx │ ├── privacy │ │ └── page.tsx │ ├── providers.tsx │ ├── renderNav.tsx │ ├── robots.ts │ ├── settings │ │ ├── home │ │ │ └── page.tsx │ │ ├── homeSettings.tsx │ │ ├── page.tsx │ │ ├── player │ │ │ └── page.tsx │ │ └── playerSettings.tsx │ ├── sitemap.ts │ ├── studio │ │ └── [id] │ │ │ ├── Card.tsx │ │ │ └── page.tsx │ ├── trending │ │ ├── Card.tsx │ │ └── page.tsx │ └── watch │ │ └── [id] │ │ └── [episode] │ │ ├── Accordions.tsx │ │ ├── Share.tsx │ │ └── page.tsx ├── components │ ├── Card.tsx │ ├── Changelogs.tsx │ ├── Characters.tsx │ ├── EpisodesList.tsx │ ├── Hero.tsx │ ├── Nav.tsx │ ├── Player │ │ ├── VidstackPlayer.tsx │ │ ├── base.css │ │ └── components │ │ │ ├── buttons.tsx │ │ │ ├── layouts │ │ │ ├── captions.module.css │ │ │ ├── video-layout.module.css │ │ │ └── video-layout.tsx │ │ │ ├── menus.tsx │ │ │ ├── sliders.tsx │ │ │ ├── time-group.tsx │ │ │ └── title.tsx │ ├── ProgressBar.tsx │ ├── ScrollToTop.tsx │ ├── Video.tsx │ ├── footer.tsx │ └── slider.css ├── functions │ ├── cache.ts │ ├── clientRequests.ts │ ├── info.ts │ ├── jsxUtilityFunctions.tsx │ ├── requests.ts │ └── utilityFunctions.ts ├── hooks │ ├── VideoProgressSave.ts │ ├── useDebounce.ts │ ├── useSSRLocalStorage.ts │ └── useTruncate.ts └── types │ ├── anify.ts │ ├── consumet.ts │ └── site.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | REDIS_URI = "" # Your Redis URI. Not required. Remove this if you don't need Redis for caching. It will use node-cache by default. 2 | 3 | CONSUMET_API = "" # Consumet API. Host from: https://github.com/consumet/api.consumet.org/ 4 | NEXT_PUBLIC_DOMAIN = "http://localhost:3000" # Your website URL (required for info and episode), replace it with your website URL 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "endOfLine": "crlf", 4 | "plugins": ["prettier-plugin-tailwindcss"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Rever"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Reveraki 2 | 3 | Minimalistic simple-looking Anime website created using Next.js 14 and Tailwind CSS. 4 | 5 | ## Features 6 | 7 | - Fast 8 | - Mobile/Tablet friendly 9 | - Easy to navigate 10 | - Trending/Popular/Seasonal anime 11 | - No Ads 12 | 13 | ## Screenshots 14 | 15 | ### Home Page 16 | 17 | ![home](https://media.discordapp.net/attachments/1233827110171185313/1236138770407886899/image.png?ex=6636eb71&is=663599f1&hm=de3ad764ac67dd72ad23228ad25f6cca07b0afb56b194bf92c7eecdb5ee10bd8&=&format=webp&quality=lossless&width=1163&height=662) 18 | 19 | ### Trending/Popular Page 20 | 21 | ![popularOrTrending](https://media.discordapp.net/attachments/1233827110171185313/1236139073341489322/image.png?ex=6636ebb9&is=66359a39&hm=480dc41b686f3be0fe7d976434c799dc8c43b393735424568b9eb746fc5a95f0&=&format=webp&quality=lossless&width=687&height=395) 22 | 23 | ### Information Page 24 | 25 | ![info](https://media.discordapp.net/attachments/1233827110171185313/1236139305525837854/image.png?ex=6636ebf0&is=66359a70&hm=dbd7ec627c236b0c3ab952471fe6b47392b551c8fb5ce9cdbebdaf7230ffb417&=&format=webp&quality=lossless&width=687&height=391) 26 | 27 | ### Watch Page 28 | 29 | ![watch](https://media.discordapp.net/attachments/1233827110171185313/1236291819399286824/image.png?ex=663779fa&is=6636287a&hm=f4ce0ba6102aeef6743ca51307e117143f58a99bac9814587e3e947630e1ebda&=&format=webp&quality=lossless&width=1397&height=662) 30 | 31 | ## Self-Host 32 | 33 | ### Clone and install packages 34 | 35 | First, clone the repository: 36 | 37 | ```sh 38 | git clone https://github.com/codeblitz97/reveraki.git 39 | ``` 40 | 41 | Then navigate to the folder and install packages with your preferred package manager. 42 | 43 | ```sh 44 | npm install # or yarn install or bun install 45 | ``` 46 | 47 | ### Editing environment variables 48 | 49 | Rename the `.env.example` file to `.env.local` and fill in these: 50 | 51 | ```env 52 | REDIS_URI = "" # Your Redis URI. Not required. Remove this if you don't need Redis for caching. It will use node-cache by default. 53 | 54 | CONSUMET_API = "" # Consumet API. Host from: https://github.com/consumet/api.consumet.org/ 55 | NEXT_PUBLIC_DOMAIN = "http://localhost:3000" # Your website URL (required for info and episode), replace it with your website URL 56 | ``` 57 | 58 | ### Starting in development mode 59 | 60 | To start the application in development mode, run any of these commands: 61 | 62 | ```sh 63 | bun dev 64 | # or yarn 65 | yarn dev 66 | # or npm 67 | npm dev 68 | ``` 69 | 70 | ## Note 71 | 72 | If you like this project, consider giving it a star <3 73 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shimizudev/reveraki/18ad41587450d2adcc80efa31f14c47bd9c8e619/bun.lockb -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | hostname: 's4.anilist.co', 7 | port: '', 8 | pathname: '/**', 9 | protocol: 'https', 10 | }, 11 | { 12 | hostname: 'artworks.thetvdb.com', 13 | port: '', 14 | pathname: '/**', 15 | protocol: 'https', 16 | }, 17 | { 18 | hostname: 'media.kitsu.io', 19 | port: '', 20 | pathname: '/**', 21 | protocol: 'https', 22 | }, 23 | { 24 | hostname: 'image.tmdb.org', 25 | port: '', 26 | pathname: '/**', 27 | protocol: 'https', 28 | }, 29 | { 30 | hostname: 'upload.wikimedia.org', 31 | port: '', 32 | pathname: '/**', 33 | protocol: 'https', 34 | }, 35 | { 36 | hostname: 'img1.ak.crunchyroll.com', 37 | port: '', 38 | pathname: '/**', 39 | protocol: 'https', 40 | }, 41 | ], 42 | }, 43 | }; 44 | 45 | export default nextConfig; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reveraki", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@nextui-org/react": "^2.3.6", 13 | "@vidstack/react": "^1.11.21", 14 | "axios": "^1.6.8", 15 | "framer-motion": "^11.1.7", 16 | "hls.js": "^1.5.8", 17 | "ioredis": "^5.4.1", 18 | "lodash": "^4.17.21", 19 | "marked": "^12.0.2", 20 | "media-icons": "^1.1.4", 21 | "next": "14.2.3", 22 | "next-nprogress-bar": "^2.3.11", 23 | "node-cache": "^5.1.2", 24 | "react": "^18", 25 | "react-dom": "^18", 26 | "react-icons": "^5.2.0", 27 | "react-use-localstorage": "^3.5.3", 28 | "sharp": "^0.33.3", 29 | "swiper": "^11.1.1" 30 | }, 31 | "devDependencies": { 32 | "@types/lodash": "^4.17.0", 33 | "@types/node": "^20", 34 | "@types/react": "^18", 35 | "@types/react-dom": "^18", 36 | "eslint": "^8", 37 | "eslint-config-next": "14.2.3", 38 | "postcss": "^8", 39 | "prettier": "^3.2.5", 40 | "prettier-plugin-tailwindcss": "^0.5.14", 41 | "tailwindcss": "^3.4.1", 42 | "typescript": "^5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useEffect, useState } from 'react'; 4 | 5 | type Theme = 6 | | 'dark' 7 | | 'secondaryDark' 8 | | 'success' 9 | | 'danger' 10 | | 'warning' 11 | | 'navyBlue'; 12 | 13 | interface ThemeProviderProps { 14 | children: React.ReactNode; 15 | } 16 | 17 | const ThemeProvider: React.FC = ({ children }) => { 18 | const [theme, setTheme] = useState('dark'); 19 | 20 | useEffect(() => { 21 | const savedTheme = localStorage.getItem('themeColor'); 22 | if ( 23 | savedTheme && 24 | (savedTheme === 'dark' || 25 | savedTheme === 'secondaryDark' || 26 | savedTheme === 'success' || 27 | savedTheme === 'danger' || 28 | savedTheme === 'warning' || 29 | savedTheme === 'navyBlue') 30 | ) { 31 | setTheme(savedTheme); 32 | document.body.classList.add(`${savedTheme}`); 33 | document.body.classList.add(`bg-background`); 34 | } 35 | }, []); 36 | 37 | return <>{children}; 38 | }; 39 | 40 | export default ThemeProvider; 41 | -------------------------------------------------------------------------------- /src/app/api/temp/episodes/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { cache } from '@/functions/cache'; 3 | import axios from 'axios'; 4 | import { 5 | EpisodeConsumet, 6 | IEpisode, 7 | convertToEpisode, 8 | } from '@/functions/utilityFunctions'; 9 | 10 | type Params = { 11 | params: { 12 | id: string; 13 | }; 14 | }; 15 | 16 | interface Episode { 17 | id: string; 18 | img: string | null; 19 | title: string; 20 | hasDub: boolean; 21 | number: number; 22 | rating: number | null; 23 | isFiller: boolean; 24 | updatedAt: number; 25 | description: string | null; 26 | } 27 | 28 | interface Provider { 29 | episodes: Episode[]; 30 | providerId: string; 31 | } 32 | 33 | interface MetadataEpisode { 34 | id: string; 35 | description: string; 36 | hasDub: boolean; 37 | img: string; 38 | isFiller: boolean; 39 | number: number; 40 | title: string; 41 | updatedAt: number; 42 | rating: number; 43 | } 44 | 45 | interface EpisodeMetadata { 46 | number: number; 47 | title: string; 48 | fullTitle: string; 49 | thumbnail: string; 50 | } 51 | 52 | interface EpTitle { 53 | english: string; 54 | romaji: string; 55 | native: string; 56 | } 57 | 58 | interface AnimeData { 59 | metadatas: EpisodeMetadata[]; 60 | title: EpTitle; 61 | } 62 | 63 | interface MetadataProviderData { 64 | providerId: string; 65 | data: MetadataEpisode[]; 66 | } 67 | 68 | export const revalidate = 1 * 60 * 60; 69 | 70 | const getOrSetCache = async (key: string, fetchData: Function) => { 71 | const cachedData = await cache.get(key); 72 | 73 | if (cachedData) { 74 | return JSON.parse(cachedData); 75 | } else { 76 | const freshData = await fetchData(); 77 | await cache.set(key, JSON.stringify(freshData), 3600); 78 | return freshData; 79 | } 80 | }; 81 | 82 | const findEpisodeData = ( 83 | episodes: Episode[], 84 | information: any, 85 | metadata?: AnimeData, 86 | episodeImages?: MetadataProviderData[] | undefined, 87 | ): any => { 88 | try { 89 | return episodes.map((episode) => { 90 | // Find the image data for the episode 91 | let foundImage = null; 92 | try { 93 | foundImage = episodeImages 94 | ?.find( 95 | (imageData) => imageData.providerId === 'tvdb' || episodeImages[0], 96 | ) 97 | ?.data.find((data) => episode.number === data.number); 98 | } catch (error) { 99 | foundImage = null; 100 | } 101 | 102 | // Find the metadata for the episode 103 | const foundMetadata = metadata?.metadatas.find( 104 | (meta) => meta.number === episode.number, 105 | ); 106 | 107 | // Construct episode information 108 | const img = foundImage?.img ?? information.coverImage ?? episode.img; 109 | 110 | // Construct episode title 111 | const title = 112 | episode.title && !episode.title.startsWith('EP') 113 | ? episode.title 114 | : foundImage?.title ?? `Episode ${episode.number}`; 115 | 116 | // Construct episode description 117 | const ordinal = 118 | episode.number === 1 119 | ? '1st' 120 | : episode.number === 2 121 | ? '2nd' 122 | : episode.number === 3 123 | ? '3rd' 124 | : `${episode.number}th`; 125 | const description = 126 | episode.description ?? 127 | foundImage?.description ?? 128 | `${ordinal} Episode of ${ 129 | information.title.english ?? 130 | information.title.romaji ?? 131 | information.title.native 132 | }`; 133 | 134 | return { 135 | id: episode.id, 136 | img, 137 | title, 138 | description, 139 | number: episode.number, 140 | }; 141 | }); 142 | 143 | // Error finding metadata: 144 | } catch (error) { 145 | return episodes.map((episode) => { 146 | // Find the metadata for the episode 147 | const foundMetadata = metadata?.metadatas.find( 148 | (meta) => meta.number === episode.number, 149 | ); 150 | 151 | // Construct episode information 152 | const img = information.coverImage ?? episode.img; 153 | 154 | // Construct episode title 155 | const title = 156 | episode.title && !episode.title.startsWith('EP') 157 | ? episode.title 158 | : `Episode ${episode.number}`; 159 | 160 | // Construct episode description 161 | const ordinal = 162 | episode.number === 1 163 | ? '1st' 164 | : episode.number === 2 165 | ? '2nd' 166 | : episode.number === 3 167 | ? '3rd' 168 | : `${episode.number}th`; 169 | const description = 170 | episode.description ?? 171 | `${ordinal} Episode of ${ 172 | information.title.english ?? 173 | information.title.romaji ?? 174 | information.title.native 175 | }`; 176 | 177 | return { 178 | id: episode.id, 179 | img, 180 | title, 181 | description, 182 | number: episode.number, 183 | }; 184 | }); 185 | } 186 | }; 187 | 188 | export const GET = async (request: NextRequest, { params }: Params) => { 189 | try { 190 | const cacheKey = `tempEpisodes:${params.id}`; 191 | const cachedEpisodes = await getOrSetCache(cacheKey, async () => { 192 | let consumetEpisodes: EpisodeConsumet[] = []; 193 | let information = await axios 194 | .get(`${process.env.NEXT_PUBLIC_DOMAIN}/api/v1/info/${params.id}`) 195 | .catch((e) => undefined); 196 | 197 | try { 198 | consumetEpisodes = ( 199 | await axios.get( 200 | `${process.env.CONSUMET_API}/meta/anilist/episodes/${params.id}`, 201 | ) 202 | ).data; 203 | } catch (consumetError) { 204 | console.error( 205 | 'Error fetching episodes from consumet API:', 206 | consumetError, 207 | ); 208 | return NextResponse.json( 209 | { 210 | message: 'Internal Server Error', 211 | status: 500, 212 | }, 213 | { status: 500 }, 214 | ); 215 | } 216 | 217 | let convertedEpisodes: IEpisode[] | undefined; 218 | 219 | if (consumetEpisodes.length > 0) { 220 | convertedEpisodes = consumetEpisodes.map(convertToEpisode); 221 | } else { 222 | convertedEpisodes = []; 223 | } 224 | 225 | let ceps; 226 | 227 | if (convertedEpisodes.length > 0) { 228 | ceps = [ 229 | { 230 | episodes: findEpisodeData( 231 | convertedEpisodes as Episode[], 232 | information?.data, 233 | undefined, // Pass undefined for metadata as it's not available from Consumet API 234 | undefined, // Pass undefined for episodeImages as it's not available from Consumet API 235 | ), 236 | providerId: 'anizone', 237 | }, 238 | ]; 239 | } else { 240 | ceps = [ 241 | { 242 | episodes: [], 243 | providerId: 'anizone', 244 | }, 245 | ]; 246 | } 247 | 248 | return ceps; 249 | }); 250 | 251 | return NextResponse.json(cachedEpisodes); 252 | } catch (error) { 253 | console.error(error); 254 | return NextResponse.json( 255 | { 256 | message: 'Internal Server Error', 257 | status: 500, 258 | }, 259 | { status: 500 }, 260 | ); 261 | } 262 | }; 263 | -------------------------------------------------------------------------------- /src/app/api/v1/characters/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { cache } from '@/functions/cache'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | type Params = { 5 | params: { 6 | id: string; 7 | }; 8 | }; 9 | 10 | interface Prms { 11 | id: string; 12 | } 13 | 14 | interface VoiceActor { 15 | name: string; 16 | image: string; 17 | } 18 | interface ConsumetVoiceActor { 19 | id: number; 20 | language: string; 21 | name: ConsumetName; 22 | image: string; 23 | imageHash: string; 24 | } 25 | 26 | interface ConsumetCharacter { 27 | id: number; 28 | role: string; 29 | name: ConsumetName; 30 | image: string; 31 | imageHash: string; 32 | voiceActors: ConsumetVoiceActor[]; 33 | } 34 | interface ConsumetName { 35 | first: string; 36 | last: string; 37 | full: string; 38 | native: string | null; 39 | userPreferred: string; 40 | } 41 | 42 | interface Character { 43 | name: string; 44 | image: string; 45 | voiceActor: VoiceActor; 46 | } 47 | 48 | function convertToVoiceActor( 49 | consumetVoiceActor: ConsumetVoiceActor, 50 | ): VoiceActor { 51 | return { 52 | name: consumetVoiceActor.name.first + ' ' + consumetVoiceActor.name.last, 53 | image: consumetVoiceActor.image, 54 | }; 55 | } 56 | 57 | function convertToCharacter( 58 | consumetCharacter: ConsumetCharacter, 59 | ): Character | null { 60 | const originalVoiceActor = consumetCharacter.voiceActors.find( 61 | (vo) => vo.language === 'Japanese', 62 | ); 63 | 64 | if (!originalVoiceActor) return null; 65 | 66 | return { 67 | name: 68 | consumetCharacter.name.first + 69 | ' ' + 70 | (consumetCharacter.name.last ? consumetCharacter.name.last : ''), 71 | image: consumetCharacter.image, 72 | voiceActor: convertToVoiceActor(originalVoiceActor), 73 | }; 74 | } 75 | 76 | const defaultResponse = { 77 | slug: null, 78 | id: null, 79 | title: { 80 | native: null, 81 | romaji: null, 82 | english: null, 83 | }, 84 | coverImage: null, 85 | bannerImage: null, 86 | trailer: null, 87 | status: null, 88 | season: null, 89 | studios: null, 90 | currentEpisode: null, 91 | mappings: null, 92 | synonyms: null, 93 | countryOfOrigin: null, 94 | description: null, 95 | duration: null, 96 | color: null, 97 | year: null, 98 | recommendations: null, 99 | rating: { 100 | anilist: null, 101 | mal: null, 102 | tmdb: null, 103 | }, 104 | popularity: null, 105 | type: null, 106 | format: null, 107 | relations: null, 108 | totalEpisodes: null, 109 | genres: null, 110 | tags: null, 111 | episodes: null, 112 | averageRating: null, 113 | averagePopularity: null, 114 | artwork: null, 115 | characters: [], 116 | }; 117 | 118 | const fetchAnilistInfo = async (params: Prms) => { 119 | try { 120 | const query = `query Page($mediaId: Int) { 121 | Media(id: $mediaId) { 122 | title { 123 | english 124 | romaji 125 | native 126 | } 127 | characters { 128 | edges { 129 | name 130 | role 131 | node { 132 | name { 133 | first 134 | middle 135 | last 136 | full 137 | native 138 | alternative 139 | alternativeSpoiler 140 | userPreferred 141 | } 142 | image { 143 | large 144 | medium 145 | } 146 | } 147 | voiceActors { 148 | name { 149 | first 150 | middle 151 | last 152 | full 153 | native 154 | alternative 155 | userPreferred 156 | } 157 | image { 158 | large 159 | medium 160 | } 161 | languageV2 162 | id 163 | } 164 | } 165 | } 166 | } 167 | }`; 168 | 169 | const response = await fetch('https://graphql.anilist.co', { 170 | method: 'POST', 171 | headers: { 172 | Accept: 'application/json', 173 | 'Content-Type': 'application/json', 174 | }, 175 | body: JSON.stringify({ query, variables: { mediaId: params.id } }), 176 | }); 177 | 178 | const responseData = await response.json(); 179 | const d = responseData; 180 | 181 | const animeInfo: any = {}; 182 | 183 | animeInfo.title = { 184 | romaji: d.data.Media.title.romaji, 185 | english: d.data.Media.title.english, 186 | native: d.data.Media.title.native, 187 | }; 188 | 189 | animeInfo.characters = d.data?.Media?.characters?.edges?.map( 190 | (item: any) => ({ 191 | role: item.role, 192 | name: { 193 | first: item.node.name.first, 194 | last: item.node.name.last, 195 | full: item.node.name.full, 196 | native: item.node.name.native, 197 | userPreferred: item.node.name.userPreferred, 198 | }, 199 | image: item.node.image.large ?? item.node.image.medium, 200 | voiceActors: item.voiceActors.map((voiceActor: any) => ({ 201 | id: voiceActor.id, 202 | language: voiceActor.languageV2, 203 | name: { 204 | first: voiceActor.name.first, 205 | last: voiceActor.name.last, 206 | full: voiceActor.name.full, 207 | native: voiceActor.name.native, 208 | userPreferred: voiceActor.name.userPreferred, 209 | }, 210 | image: voiceActor.image.large ?? voiceActor.image.medium, 211 | })), 212 | }), 213 | ); 214 | 215 | return animeInfo as any; 216 | } catch (error) { 217 | console.error(error); 218 | return defaultResponse; 219 | } 220 | }; 221 | 222 | export const GET = async ( 223 | request: NextRequest, 224 | { params }: Readonly, 225 | ) => { 226 | try { 227 | const cacheKey = `anilistcharactersc:${params.id}`; 228 | let info: any = await cache.get(cacheKey); 229 | 230 | if (!info) { 231 | info = await fetchAnilistInfo(params); 232 | await cache.set(cacheKey, JSON.stringify(info), 5 * 60 * 60); 233 | } else { 234 | info = JSON.parse(info); 235 | } 236 | 237 | return NextResponse.json({ 238 | id: params.id, 239 | title: { 240 | native: info.title.native, 241 | romaji: info.title.romaji, 242 | english: info.title.english, 243 | }, 244 | characters: info.characters.map(convertToCharacter).filter(Boolean), 245 | }); 246 | } catch (error) { 247 | console.error('Error fetching info', (error as Error).message); 248 | return NextResponse.json( 249 | { 250 | message: 'Internal Server Error', 251 | status: 500, 252 | }, 253 | { status: 500 }, 254 | ); 255 | } 256 | }; 257 | -------------------------------------------------------------------------------- /src/app/api/v1/episodes/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { cache } from '@/functions/cache'; 3 | import axios from 'axios'; 4 | import { 5 | EpisodeConsumet, 6 | IEpisode, 7 | convertToEpisode, 8 | } from '@/functions/utilityFunctions'; 9 | 10 | type Params = { 11 | params: { 12 | id: string; 13 | }; 14 | }; 15 | 16 | interface Episode { 17 | id: string; 18 | img: string | null; 19 | title: string; 20 | hasDub: boolean; 21 | number: number; 22 | rating: number | null; 23 | isFiller: boolean; 24 | updatedAt: number; 25 | description: string | null; 26 | } 27 | 28 | interface Provider { 29 | episodes: Episode[]; 30 | providerId: string; 31 | } 32 | 33 | interface EpisodesData { 34 | data: Provider[]; 35 | latest: { 36 | updatedAt: number; 37 | latestTitle: string; 38 | latestEpisode: number; 39 | }; 40 | } 41 | 42 | interface EpisodesResponse { 43 | episodes: EpisodesData; 44 | } 45 | 46 | interface MetadataEpisode { 47 | id: string; 48 | description: string; 49 | hasDub: boolean; 50 | img: string; 51 | isFiller: boolean; 52 | number: number; 53 | title: string; 54 | updatedAt: number; 55 | rating: number; 56 | } 57 | 58 | interface EpisodeMetadata { 59 | number: number; 60 | title: string; 61 | fullTitle: string; 62 | thumbnail: string; 63 | } 64 | 65 | interface EpTitle { 66 | english: string; 67 | romaji: string; 68 | native: string; 69 | } 70 | 71 | interface AnimeData { 72 | metadatas: EpisodeMetadata[]; 73 | title: EpTitle; 74 | } 75 | 76 | interface MetadataProviderData { 77 | providerId: string; 78 | data: MetadataEpisode[]; 79 | } 80 | 81 | export const revalidate = 1 * 60 * 60; 82 | 83 | const getOrSetCache = async (key: string, fetchData: Function) => { 84 | // Get the cached data from redis / node-cache. 85 | const cachedData = await cache.get(key); 86 | console.log(cachedData); 87 | 88 | // If cache data exists, return it. 89 | if (cachedData) { 90 | return JSON.parse(cachedData); 91 | } else { 92 | // Doesn't exist, fetch data and respond. 93 | const freshData = await fetchData(); 94 | await cache.set(key, JSON.stringify(freshData), 3600); 95 | console.log(cache.get(key)); 96 | return freshData; 97 | } 98 | }; 99 | 100 | const findEpisodeData = ( 101 | episodes: Episode[], 102 | information: any, 103 | metadata?: AnimeData, 104 | episodeImages?: MetadataProviderData[] | undefined, 105 | ): any => { 106 | try { 107 | return episodes.map((episode) => { 108 | // Find the image data for the episode 109 | let foundImage = null; 110 | try { 111 | foundImage = episodeImages 112 | ?.find( 113 | (imageData) => imageData.providerId === 'tvdb' || episodeImages[0], 114 | ) 115 | ?.data.find((data) => episode.number === data.number); 116 | } catch (error) { 117 | foundImage = null; 118 | } 119 | 120 | let foundMetadata; 121 | // Find the metadata for the episode 122 | if (metadata) { 123 | foundMetadata = metadata.metadatas.find( 124 | (meta) => meta.number === episode.number, 125 | ); 126 | } else { 127 | foundMetadata = { 128 | metadatas: [ 129 | { 130 | number: null, 131 | title: null, 132 | fullTitle: null, 133 | thumbnail: null, 134 | }, 135 | ], 136 | }; 137 | } 138 | 139 | // Construct episode information 140 | const img = foundImage?.img ?? information.coverImage ?? episode.img; 141 | 142 | // Construct episode title 143 | const title = 144 | episode.title && !episode.title.startsWith('EP') 145 | ? episode.title 146 | : foundImage?.title ?? `Episode ${episode.number}`; 147 | 148 | // Construct episode description 149 | const ordinal = 150 | episode.number === 1 151 | ? '1st' 152 | : episode.number === 2 153 | ? '2nd' 154 | : episode.number === 3 155 | ? '3rd' 156 | : `${episode.number}th`; 157 | const description = 158 | episode.description ?? 159 | foundImage?.description ?? 160 | `${ordinal} Episode of ${ 161 | information.title.english ?? 162 | information.title.romaji ?? 163 | information.title.native 164 | }`; 165 | 166 | return { 167 | id: episode.id, 168 | img, 169 | title, 170 | description, 171 | number: episode.number, 172 | }; 173 | }); 174 | 175 | // Error finding metadata: 176 | } catch (error) { 177 | return episodes.map((episode) => { 178 | // Find the metadata for the episode 179 | const foundMetadata = metadata?.metadatas.find( 180 | (meta) => meta.number === episode.number, 181 | ); 182 | 183 | // Construct episode information 184 | const img = information.coverImage ?? episode.img; 185 | 186 | // Construct episode title 187 | const title = 188 | episode.title && !episode.title.startsWith('EP') 189 | ? episode.title 190 | : `Episode ${episode.number}`; 191 | 192 | // Construct episode description 193 | const ordinal = 194 | episode.number === 1 195 | ? '1st' 196 | : episode.number === 2 197 | ? '2nd' 198 | : episode.number === 3 199 | ? '3rd' 200 | : `${episode.number}th`; 201 | const description = 202 | episode.description ?? 203 | `${ordinal} Episode of ${ 204 | information.title.english ?? 205 | information.title.romaji ?? 206 | information.title.native 207 | }`; 208 | 209 | return { 210 | id: episode.id, 211 | img, 212 | title, 213 | description, 214 | number: episode.number, 215 | }; 216 | }); 217 | } 218 | }; 219 | 220 | export const GET = async (request: NextRequest, { params }: Params) => { 221 | try { 222 | const cacheKey = `episodesDataMain:${params.id}`; 223 | const cachedEpisodes = await getOrSetCache(cacheKey, async () => { 224 | const episodeImagesPromise = axios 225 | .get('https://api.anify.tv/content-metadata/' + params.id, { 226 | timeout: 2000, 227 | }) 228 | .catch((e) => undefined); 229 | const informationPromise = axios 230 | .get(`${process.env.NEXT_PUBLIC_DOMAIN}/api/v1/info/${params.id}`) 231 | .catch((e) => undefined); 232 | 233 | const [episodeImagesData, informationData] = await Promise.all([ 234 | episodeImagesPromise, 235 | informationPromise, 236 | ]); 237 | 238 | let episodeImages; 239 | let episodeMetadata; 240 | let information; 241 | 242 | if (episodeImagesData === undefined) { 243 | episodeImages = undefined; 244 | episodeMetadata = undefined; 245 | } 246 | 247 | [episodeImages, episodeMetadata, information] = await Promise.all([ 248 | episodeImagesData !== undefined 249 | ? (episodeImagesData.data as Promise) 250 | : new Promise((r) => r(undefined)), 251 | new Promise((r) => r(undefined)), 252 | informationData?.data, 253 | ]); 254 | 255 | try { 256 | const anifyEpisodes = ( 257 | await axios.get( 258 | `https://api.anify.tv/info/${params.id}?fields=[episodes]`, 259 | ) 260 | ).data as EpisodesResponse; 261 | 262 | let eps; 263 | 264 | if (anifyEpisodes.episodes.data.length > 0) { 265 | eps = anifyEpisodes.episodes.data.map((anifyEpisode) => ({ 266 | episodes: findEpisodeData( 267 | anifyEpisode.episodes, 268 | information, 269 | episodeMetadata as AnimeData | undefined, 270 | episodeImages as MetadataProviderData[] | undefined, 271 | ), 272 | providerId: 273 | anifyEpisode.providerId === 'zoro' ? 'anirise' : 'anizone', 274 | })); 275 | } else { 276 | eps = { 277 | episodes: [], 278 | providerId: 'anizone', 279 | }; 280 | } 281 | return eps; 282 | } catch (error) { 283 | let consumetEpisodes: EpisodeConsumet[] = []; 284 | try { 285 | consumetEpisodes = ( 286 | await axios.get( 287 | `${process.env.CONSUMET_API}/meta/anilist/episodes/${params.id}`, 288 | ) 289 | ).data; 290 | } catch (consumetError) { 291 | console.error( 292 | 'Error fetching episodes from consumet API:', 293 | consumetError, 294 | ); 295 | return NextResponse.json( 296 | { 297 | message: 'Internal Server Error', 298 | status: 500, 299 | }, 300 | { status: 500 }, 301 | ); 302 | } 303 | 304 | let convertedEpisodes: IEpisode[] | undefined; 305 | 306 | if (consumetEpisodes.length > 0) { 307 | convertedEpisodes = consumetEpisodes.map(convertToEpisode); 308 | } else { 309 | convertedEpisodes = []; 310 | } 311 | 312 | let ceps; 313 | 314 | if (convertedEpisodes.length > 0) { 315 | ceps = [ 316 | { 317 | episodes: findEpisodeData( 318 | convertedEpisodes as Episode[], 319 | information, 320 | episodeMetadata as AnimeData | undefined, 321 | episodeImages as MetadataProviderData[] | undefined, 322 | ), 323 | providerId: 'anizone', 324 | }, 325 | ]; 326 | } else { 327 | ceps = [ 328 | { 329 | episodes: [], 330 | providerId: 'anizone', 331 | }, 332 | ]; 333 | } 334 | 335 | return ceps; 336 | } 337 | }); 338 | 339 | return NextResponse.json(cachedEpisodes); 340 | } catch (error) { 341 | console.error(error); 342 | return NextResponse.json( 343 | { 344 | message: 'Internal Server Error', 345 | status: 500, 346 | }, 347 | { status: 500 }, 348 | ); 349 | } 350 | }; 351 | 352 | -------------------------------------------------------------------------------- /src/app/api/v1/episodesMetadata/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { cache } from '@/functions/cache'; 3 | 4 | type Params = { 5 | params: { 6 | id: string; 7 | }; 8 | }; 9 | 10 | export const GET = async ( 11 | request: NextRequest, 12 | { params }: Readonly, 13 | ) => { 14 | try { 15 | const cachedData = await cache.get(`em1:${params.id}`); 16 | if (cachedData) { 17 | return NextResponse.json(JSON.parse(cachedData)); 18 | } 19 | 20 | const query = `query Page($mediaId: Int) { 21 | Media(id: $mediaId) { 22 | title { 23 | english 24 | romaji 25 | native 26 | } 27 | streamingEpisodes { 28 | title 29 | thumbnail 30 | url 31 | site 32 | } 33 | } 34 | }`; 35 | 36 | const response = await fetch('https://graphql.anilist.co', { 37 | method: 'POST', 38 | headers: { 39 | Accept: 'application/json', 40 | 'Content-Type': 'application/json', 41 | }, 42 | body: JSON.stringify({ 43 | query, 44 | variables: { mediaId: parseInt(params.id) }, 45 | }), 46 | }); 47 | 48 | const episodeNumberPattern = /^Episode (\d+)/; 49 | 50 | const responseData = await response.json(); 51 | 52 | const formattedRes = { 53 | metadatas: responseData.data.Media.streamingEpisodes.map( 54 | (se: { 55 | title: string; 56 | thumbnail: string; 57 | url: string; 58 | site: string; 59 | }) => ({ 60 | number: Number(se.title.match(episodeNumberPattern)?.[1]), 61 | title: se.title.split('-')[1].trim(), 62 | fullTitle: se.title, 63 | thumbnail: se.thumbnail, 64 | }), 65 | ), 66 | title: { 67 | english: responseData.data.Media.title.english, 68 | romaji: responseData.data.Media.title.romaji, 69 | native: responseData.data.Media.title.native, 70 | }, 71 | }; 72 | 73 | await cache.set( 74 | `em1:${params.id}`, 75 | JSON.stringify(formattedRes), 76 | 5 * 60 * 60, 77 | ); 78 | 79 | return NextResponse.json(formattedRes); 80 | } catch (error) { 81 | console.log(error); 82 | return NextResponse.json( 83 | { 84 | message: 'Internal Server Error', 85 | status: 500, 86 | }, 87 | { status: 500 }, 88 | ); 89 | } 90 | }; 91 | -------------------------------------------------------------------------------- /src/app/api/v1/search/[query]/route.ts: -------------------------------------------------------------------------------- 1 | import { ConsumetSearchResult } from '@/types/consumet'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | export const GET = async ( 5 | request: NextRequest, 6 | { params }: { params: { query: string } }, 7 | ) => { 8 | try { 9 | const h = request.headers; 10 | 11 | if (!h.get('x-site') || h.get('x-site') !== 'ezanime') { 12 | return NextResponse.json( 13 | { 14 | message: 'Bad Request', 15 | status: 400, 16 | }, 17 | { status: 400 }, 18 | ); 19 | } 20 | 21 | const response = await fetch( 22 | `${process.env.CONSUMET_API!}/meta/anilist/${params.query}?perPage=10`, 23 | ); 24 | return NextResponse.json((await response.json()) as ConsumetSearchResult); 25 | } catch (error) { 26 | return NextResponse.json( 27 | { 28 | message: 'Internal Server Error', 29 | status: 500, 30 | }, 31 | { status: 500 }, 32 | ); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/app/api/v1/source/route.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { NextResponse, NextRequest } from 'next/server'; 3 | 4 | async function consumetEpisode(id: string, provider = 'gogoanime') { 5 | try { 6 | const { data } = await axios.get( 7 | `${process.env.CONSUMET_API}/meta/anilist/watch/${id}?provider=${provider}`, 8 | ); 9 | return data; 10 | } catch (error) { 11 | console.error(error); 12 | return null; 13 | } 14 | } 15 | 16 | async function anifyEpisode( 17 | provider: string, 18 | episodeid: string, 19 | epnum: string, 20 | id: string, 21 | subtype: string, 22 | ) { 23 | try { 24 | const { data } = await axios.get( 25 | `https://api.anify.tv/sources?providerId=${provider}&watchId=${encodeURIComponent( 26 | episodeid, 27 | )}&episodeNumber=${epnum}&id=${id}&subType=${subtype}`, 28 | ); 29 | return data; 30 | } catch (error) { 31 | console.error(error); 32 | return null; 33 | } 34 | } 35 | 36 | export const GET = async (req: NextRequest) => { 37 | const url = new URL(req.url); 38 | const id = url.searchParams.get('id') as string; 39 | const source = url.searchParams.get('source') as string; 40 | const provider = url.searchParams.get('provider') as string; 41 | const episodeid = url.searchParams.get('episodeid') as string; 42 | const episodenum = url.searchParams.get('episodenum') as string; 43 | const subtype = url.searchParams.get('subtype') as string; 44 | 45 | let data; 46 | 47 | if (source === 'consumet') { 48 | data = await consumetEpisode(episodeid, provider ? provider : undefined); 49 | } 50 | 51 | if (source === 'anify') { 52 | data = await anifyEpisode(provider, episodeid, episodenum, id, subtype); 53 | } 54 | 55 | return NextResponse.json(data); 56 | }; 57 | -------------------------------------------------------------------------------- /src/app/api/v2/advancedSearch/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | 3 | function constructSearchQuery(url: URL) { 4 | const params = url.searchParams; 5 | 6 | const query = params.get('query'); 7 | const type = params.get('type'); 8 | const page = params.get('page'); 9 | const perPage = params.get('perPage'); 10 | const season = params.get('season'); 11 | const format = params.get('format'); 12 | const sort = params.getAll('sort'); 13 | const genres = params.getAll('genres'); 14 | const id = params.get('id'); 15 | const year = params.get('year'); 16 | const status = params.get('status'); 17 | 18 | let searchParams = new URLSearchParams(); 19 | 20 | if (query) searchParams.set('query', query); 21 | if (type) searchParams.set('type', type); 22 | if (page) searchParams.set('page', page); 23 | if (perPage) searchParams.set('perPage', perPage); 24 | if (season) searchParams.set('season', season); 25 | if (format) searchParams.set('format', format); 26 | if (sort && sort.length > 0) { 27 | const validSort = sort.filter((item) => item); 28 | if (validSort.length > 0) 29 | searchParams.set('sort', JSON.stringify(validSort)); 30 | } 31 | if (genres && genres.length > 0) { 32 | const validGenres = genres.filter((genre) => genre); 33 | if (validGenres.length > 0) 34 | searchParams.set('genres', JSON.stringify(validGenres)); 35 | } 36 | if (id) searchParams.set('id', id); 37 | if (year) searchParams.set('year', year); 38 | if (status) searchParams.set('status', status); 39 | 40 | return searchParams.toString(); 41 | } 42 | 43 | export const GET = async (request: NextRequest) => { 44 | try { 45 | const baseUrl = `${process.env.CONSUMET_API}/meta/anilist/advanced-search`; 46 | const url = new URL(request.url); 47 | 48 | const params = constructSearchQuery(url); 49 | 50 | const finalURL = `${baseUrl}?${params}`; 51 | 52 | const response = await fetch(finalURL); 53 | 54 | return NextResponse.json(await response.json()); 55 | } catch (error) { 56 | return NextResponse.json( 57 | { 58 | message: 'Internal Server Error', 59 | status: 500, 60 | }, 61 | { status: 500 }, 62 | ); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/app/api/v2/studios/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { cache } from '@/functions/cache'; 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | import { Title } from '@/types/site'; 5 | 6 | interface MediaTitle { 7 | romaji: string; 8 | english: string | null; 9 | native: string; 10 | userPreferred: string; 11 | } 12 | 13 | interface MediaTrailer { 14 | id: string; 15 | site: string; 16 | thumbnail: string; 17 | } 18 | 19 | interface StartDate { 20 | year: number; 21 | month: number; 22 | day: number; 23 | } 24 | 25 | interface CoverImage { 26 | extraLarge: string; 27 | large: string; 28 | medium: string; 29 | color: string; 30 | } 31 | 32 | interface StatusDistribution { 33 | amount: number; 34 | status: string; 35 | } 36 | 37 | interface MediaNode { 38 | id: number; 39 | idMal: number; 40 | type: string; 41 | title: MediaTitle; 42 | trailer: MediaTrailer; 43 | synonyms: string[]; 44 | status: string; 45 | startDate: StartDate; 46 | seasonYear: number; 47 | season: string; 48 | meanScore: number; 49 | format: string; 50 | bannerImage: string; 51 | description: string; 52 | duration: number; 53 | episodes: number; 54 | coverImage: CoverImage; 55 | averageScore: number; 56 | popularity: number; 57 | stats: { 58 | statusDistribution: StatusDistribution[]; 59 | }; 60 | } 61 | 62 | interface Studio { 63 | name: string; 64 | id: number; 65 | isAnimationStudio: boolean; 66 | favourites: number; 67 | media: { 68 | nodes: MediaNode[]; 69 | }; 70 | } 71 | 72 | interface Data { 73 | Studio: Studio; 74 | } 75 | 76 | interface Response { 77 | data: Data; 78 | } 79 | 80 | export interface StudioInfo { 81 | id: string; 82 | name?: string; 83 | isAnimationStudio?: boolean; 84 | favoritesNumber?: number; 85 | releasedAnime?: { 86 | id: string; 87 | malId: string; 88 | type?: 'ANIME' | 'MANGA'; 89 | title?: Title; 90 | trailer?: string; 91 | synonyms?: string[]; 92 | status?: 93 | | 'FINISHED' 94 | | 'RELEASING' 95 | | 'NOT_YET_RELEASED' 96 | | 'HIATUS' 97 | | 'CANCELLED'; 98 | startDate?: { 99 | day: number; 100 | month: number; 101 | year: number; 102 | }; 103 | year?: number; 104 | season?: 'WINTER' | 'SUMMER' | 'FALL' | 'SPRING'; 105 | meanScore?: number; 106 | format: 'OVA' | 'ONA' | 'TV' | 'MOVIE' | 'TV_SHORT'; 107 | bannerImage?: string; 108 | color?: string; 109 | description?: string; 110 | coverImage?: string; 111 | duration?: number; 112 | totalEpisodes?: number; 113 | averageScore?: number; 114 | popularity?: number; 115 | }[]; 116 | } 117 | 118 | export async function GET( 119 | request: NextRequest, 120 | { params }: { params: { id: string } }, 121 | ) { 122 | try { 123 | let sInfo = await cache.get(`studioData:anilist:v2:${params.id}`); 124 | 125 | if (!sInfo) { 126 | const query = `query Query($studioId: Int) { 127 | Studio(id: $studioId) { 128 | name 129 | id 130 | isAnimationStudio 131 | favourites 132 | media { 133 | nodes { 134 | id 135 | idMal 136 | type 137 | title { 138 | romaji 139 | english 140 | native 141 | userPreferred 142 | } 143 | trailer { 144 | id 145 | site 146 | thumbnail 147 | } 148 | synonyms 149 | status 150 | startDate { 151 | year 152 | month 153 | day 154 | } 155 | seasonYear 156 | season 157 | meanScore 158 | format 159 | bannerImage 160 | description 161 | duration 162 | episodes 163 | coverImage { 164 | extraLarge 165 | large 166 | medium 167 | color 168 | } 169 | averageScore 170 | popularity 171 | stats { 172 | statusDistribution { 173 | amount 174 | status 175 | } 176 | } 177 | } 178 | } 179 | } 180 | }`; 181 | 182 | const variables = { 183 | studioId: Number(params.id), 184 | }; 185 | 186 | const res = await axios.post( 187 | 'https://graphql.anilist.co', 188 | { variables, query }, 189 | { 190 | headers: { 191 | Accept: 'application/json', 192 | 'Content-Type': 'application/json', 193 | }, 194 | }, 195 | ); 196 | 197 | const animeData = (await res.data) as Response; 198 | let studioInfo: StudioInfo = { 199 | id: '', 200 | }; 201 | 202 | studioInfo.id = String(animeData.data.Studio.id); 203 | studioInfo.name = animeData.data.Studio.name; 204 | studioInfo.favoritesNumber = animeData.data.Studio.favourites; 205 | studioInfo.isAnimationStudio = animeData.data.Studio.isAnimationStudio; 206 | studioInfo.releasedAnime = animeData.data.Studio.media.nodes.map( 207 | (node) => { 208 | const trailerId = node.trailer ? node.trailer.id : undefined; 209 | 210 | return { 211 | id: String(node.id) ?? undefined, 212 | malId: String(node.idMal) ?? undefined, 213 | format: 214 | (node.format as 'OVA' | 'ONA' | 'TV' | 'MOVIE' | 'TV_SHORT') ?? 215 | undefined, 216 | type: (node.type as 'ANIME' | 'MANGA') ?? undefined, 217 | color: node.coverImage.color ?? undefined, 218 | coverImage: 219 | node.coverImage.extraLarge ?? 220 | node.coverImage.large ?? 221 | node.coverImage.medium ?? 222 | undefined, 223 | bannerImage: node.bannerImage ?? undefined, 224 | averageScore: node.averageScore ?? undefined, 225 | meanScore: node.meanScore ?? undefined, 226 | synonyms: node.synonyms ?? [], 227 | trailer: trailerId 228 | ? `https://www.youtube.com/watch?v=${trailerId}` 229 | : undefined, 230 | startDate: node.startDate ?? undefined, 231 | status: 232 | (node.status as 233 | | 'RELEASING' 234 | | 'FINISHED' 235 | | 'HIATUS' 236 | | 'CANCELLED' 237 | | 'NOT_YET_RELEASED') ?? undefined, 238 | totalEpisodes: node.episodes ?? undefined, 239 | description: node.description ?? undefined, 240 | duration: node.duration ?? undefined, 241 | popularity: node.popularity ?? undefined, 242 | season: 243 | (node.season as 'WINTER' | 'SUMMER' | 'FALL' | 'SPRING') ?? 244 | undefined, 245 | title: { 246 | english: node.title.english, 247 | romaji: node.title.romaji, 248 | native: node.title.native, 249 | userPreferred: node.title.userPreferred, 250 | } as Title, 251 | year: node.seasonYear, 252 | }; 253 | }, 254 | ); 255 | 256 | sInfo = studioInfo; 257 | await cache.set( 258 | `studioData:anilist:v2:${params.id}`, 259 | JSON.stringify(studioInfo), 260 | 5 * 60 * 60, 261 | ); 262 | } else { 263 | sInfo = JSON.parse(sInfo); 264 | } 265 | 266 | return NextResponse.json(sInfo); 267 | } catch (error) { 268 | console.error('Error fetching info', error as Error); 269 | return NextResponse.json( 270 | { 271 | message: 'Internal Server Error', 272 | status: 500, 273 | }, 274 | { status: 500 }, 275 | ); 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/app/api/v2/thumbnails/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { cache } from '@/functions/cache'; 2 | import axios from 'axios'; 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | 5 | const CACHE_TIMEOUT_SECONDS = 3600; 6 | 7 | interface MalSyncRes { 8 | id: number; 9 | type: string; 10 | title: string; 11 | url: string; 12 | total: null | number; 13 | image: string; 14 | anidbId: number; 15 | Sites: { 16 | [site: string]: { 17 | [key: string]: { 18 | identifier: string | number; 19 | image: string; 20 | malId: number; 21 | aniId: number; 22 | page: string; 23 | title: string; 24 | type: string; 25 | url: string; 26 | external?: boolean; 27 | }; 28 | }; 29 | }; 30 | } 31 | 32 | interface Track { 33 | file: string; 34 | label?: string; 35 | kind: string; 36 | default?: boolean; 37 | } 38 | 39 | interface Interval { 40 | start: number; 41 | end: number; 42 | } 43 | 44 | interface Source { 45 | url: string; 46 | type: string; 47 | } 48 | 49 | interface AnimeMetadata { 50 | tracks: Track[]; 51 | intro?: Interval; 52 | outro?: Interval; 53 | sources: Source[]; 54 | anilistID: number; 55 | malID: number; 56 | } 57 | 58 | const getZoroId = async (malId: string) => { 59 | try { 60 | const cacheKey = `thumbails:zoroId_${malId}`; 61 | let zoroId = await cache.get(cacheKey); 62 | 63 | if (!zoroId) { 64 | const res = ( 65 | await axios.get(`https://api.malsync.moe/mal/anime/${malId}`) 66 | ).data as MalSyncRes; 67 | const zoro = res.Sites['Zoro']; 68 | 69 | for (const key in zoro) { 70 | const zoroInfo = zoro[key]; 71 | zoroId = (await zoroInfo.url.split('/')[3]) as string; 72 | cache.set(cacheKey, zoroId, CACHE_TIMEOUT_SECONDS); 73 | break; 74 | } 75 | } 76 | return zoroId; 77 | } catch (error) { 78 | return null; 79 | } 80 | }; 81 | 82 | interface HiAnimeEpisode { 83 | title: string; 84 | episodeId: string; 85 | number: number; 86 | isFiller: boolean; 87 | } 88 | 89 | interface HiAnime { 90 | totalEpisodes: number; 91 | episodes: HiAnimeEpisode[]; 92 | } 93 | 94 | export const GET = async ( 95 | request: NextRequest, 96 | { params }: { params: { id: string } }, 97 | ) => { 98 | try { 99 | const url = new URL(request.url); 100 | const episodeNumber = parseInt(url.searchParams.get('episodeNumber') || ''); 101 | const isDub = url.searchParams.get('category') === 'dub'; 102 | 103 | const query = `query Query($mediaId: Int) { 104 | Media(id: $mediaId) { 105 | idMal 106 | } 107 | }`; 108 | 109 | const malIdResponse = await axios.post('https://graphql.anilist.co', { 110 | query, 111 | variables: { mediaId: parseInt(params.id) }, 112 | headers: { 113 | Accept: 'application/json', 114 | 'Content-Type': 'application/json', 115 | }, 116 | }); 117 | const malId = malIdResponse.data.data.Media.idMal; 118 | 119 | const zoroId = await getZoroId(malId); 120 | const hiAnimeResResponse = await axios.get( 121 | `${process.env.HIANIME_API}/anime/episodes/${zoroId}`, 122 | ); 123 | const hiAnimeRes = hiAnimeResResponse.data as HiAnime; 124 | 125 | const foundEpisode = hiAnimeRes.episodes.find( 126 | (e) => e.number === episodeNumber, 127 | ); 128 | 129 | const cacheKey = `thumbnails:episodeSources_${foundEpisode?.episodeId}_${isDub ? 'dub' : 'sub'}`; 130 | let sources = JSON.parse(await cache.get(cacheKey)) as AnimeMetadata; 131 | 132 | if (!sources) { 133 | const sourcesResponse = await axios.get( 134 | `${process.env.HIANIME_API}/anime/episode-srcs?id=${foundEpisode?.episodeId}&server=vidstreaming&category=${isDub ? 'dub' : 'sub'}`, 135 | ); 136 | sources = sourcesResponse.data as AnimeMetadata; 137 | cache.set(cacheKey, JSON.stringify(sources), CACHE_TIMEOUT_SECONDS); 138 | } 139 | 140 | return NextResponse.json( 141 | await sources.tracks.find((t) => t.kind === 'thumbnails'), 142 | ); 143 | } catch (error) { 144 | console.error(error); 145 | return NextResponse.json( 146 | { 147 | message: 'Internal Server Error', 148 | status: 500, 149 | }, 150 | { status: 500 }, 151 | ); 152 | } 153 | }; 154 | -------------------------------------------------------------------------------- /src/app/catalog/Card.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { GenerateColoredElementByStatus } from '@/functions/jsxUtilityFunctions'; 4 | import useTruncate from '@/hooks/useTruncate'; 5 | import { ConsumetAnime } from '@/types/consumet'; 6 | import { Image, Link, Tooltip } from '@nextui-org/react'; 7 | import { motion } from 'framer-motion'; 8 | 9 | export const Card = ({ anime }: { anime: ConsumetAnime }) => { 10 | return ( 11 |
12 | 13 | 21 | {anime.image && ( 22 | 23 | {anime.title.english 31 | 32 | )} 33 | 34 | 35 | 39 |

43 | {useTruncate(anime.title.english ?? anime.title.romaji, { 44 | length: 20, 45 | })} 46 |

47 |
48 |
49 | 50 | {anime?.totalEpisodes ?? 0} 51 | {' '} 52 | |{' '} 53 | 54 | 57 | {' '} 58 | |{' '} 59 | 60 | {anime?.type === 'TV' ? 'ANIME' : anime.type} 61 | 62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/app/catalog/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Card } from './Card'; 4 | import { Input, RadioGroup, Radio, Button } from '@nextui-org/react'; 5 | import useDebounce from '@/hooks/useDebounce'; 6 | import React from 'react'; 7 | import { ExtendedAnimePage } from '@/types/consumet'; 8 | 9 | export default function Catalog() { 10 | const [result, setResult] = React.useState(null); 11 | const [selectedGenres, setSelectedGenres] = React.useState([]); 12 | const [season, setSeason] = React.useState(null); 13 | const [year, setYear] = React.useState(null); 14 | const [query, setQuery] = React.useState(''); 15 | const [format, setFormat] = React.useState(null); 16 | 17 | const debouncedQuery = useDebounce(query, 1000); 18 | const debouncedYear = useDebounce(year, 1000); 19 | 20 | React.useEffect(() => { 21 | const fetchSearchResult = async () => { 22 | const params = new URLSearchParams(); 23 | 24 | params.append('type', 'ANIME'); 25 | if (debouncedQuery) { 26 | params.append('query', encodeURIComponent(debouncedQuery)); 27 | } 28 | if (season) { 29 | params.append('season', season); 30 | } 31 | if (debouncedYear) { 32 | params.append('year', debouncedYear); 33 | } 34 | if (format) { 35 | params.append('format', format); 36 | } 37 | 38 | if (selectedGenres.length > 0) { 39 | selectedGenres.forEach((genre) => { 40 | params.append('genres', genre); 41 | }); 42 | } 43 | 44 | const queryParams = params.toString(); 45 | 46 | const res = await fetch( 47 | `${process.env.NEXT_PUBLIC_DOMAIN}/api/v2/advancedSearch?${queryParams}`, 48 | ); 49 | const data = await res.json(); 50 | setResult(data); 51 | }; 52 | 53 | fetchSearchResult(); 54 | }, [selectedGenres, season, debouncedYear, debouncedQuery, format]); // Refetch results when filters change 55 | 56 | const toggleGenre = (genre: string) => { 57 | setSelectedGenres((prevGenres) => 58 | prevGenres.includes(genre) 59 | ? prevGenres.filter((selectedGenre) => selectedGenre !== genre) 60 | : [...prevGenres, genre], 61 | ); 62 | }; 63 | 64 | const isGenreSelected = (genre: string) => selectedGenres.includes(genre); 65 | 66 | return ( 67 |
68 |

Catalog

69 |
70 |
71 |
72 | setQuery(e.target.value)} 76 | /> 77 |
78 |
79 | setYear(e.target.value)} 85 | /> 86 |
87 |
88 | setSeason(value)} 93 | > 94 | Spring 95 | Summer 96 | Fall 97 | Winter 98 | 99 |
100 |
101 | setFormat(value)} 106 | > 107 | TV 108 | TV Short 109 | OVA 110 | ONA 111 | Movie 112 | Special 113 | Music 114 | 115 |
116 |
117 |

Genres

118 |
119 | {[ 120 | 'Action', 121 | 'Adventure', 122 | 'Cars', 123 | 'Comedy', 124 | 'Drama', 125 | 'Fantasy', 126 | 'Horror', 127 | 'Mahou Shoujo', 128 | 'Mecha', 129 | 'Music', 130 | 'Mystery', 131 | 'Psychological', 132 | 'Romance', 133 | 'Sci-Fi', 134 | 'Slice of Life', 135 | 'Sports', 136 | 'Supernatural', 137 | 'Thriller', 138 | ].map((genre) => ( 139 | 146 | ))} 147 |
148 |
149 |
150 |
151 |

Search Results

152 | {result ? ( 153 |
154 | {result.results.length > 0 ? ( 155 | <> 156 | {result.results.map((anime) => { 157 | return ( 158 |
159 | 160 |
161 | ); 162 | })} 163 | 164 | ) : ( 165 | <> 166 |
No result found...
167 | 168 | )} 169 |
170 | ) : null} 171 |
172 |
173 |
174 | ); 175 | } 176 | -------------------------------------------------------------------------------- /src/app/dmca/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Breadcrumbs, BreadcrumbItem, Link } from '@nextui-org/react'; 4 | 5 | export default function DMCA() { 6 | return ( 7 |
8 | 9 | Home 10 | DMCA 11 | 12 | 13 |

DMCA Note

14 |

15 | ReverAki operates independently and is not formally associated with nor 16 | endorsed by any of the anime studios responsible for the creation of the 17 | anime featured on this platform. Our website serves solely as a user 18 | interface, facilitating access to self-hosted files sourced from various 19 | third-party providers across the internet. It's important to note 20 | that ReverAki never initiates downloads of video content from these 21 | providers. Instead, links are provided in response to user requests, 22 | thereby absolving the platform from any potential DMCA compliance 23 | issues. 24 |

25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shimizudev/reveraki/18ad41587450d2adcc80efa31f14c47bd9c8e619/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/info/[id]/Accordions.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React from 'react'; 4 | import { Cards } from '@/components/Card'; 5 | import { Characters } from '@/components/Characters'; 6 | import { GenerateColoredElementBySeason } from '@/functions/jsxUtilityFunctions'; 7 | import { 8 | capitalizeFirstLetter, 9 | numberToMonth, 10 | } from '@/functions/utilityFunctions'; 11 | import { ConsumetAnime } from '@/types/consumet'; 12 | import { AnimeInfo, CharacterRes } from '@/types/site'; 13 | import { Accordion, AccordionItem } from '@nextui-org/accordion'; 14 | import dynamic from 'next/dynamic'; 15 | import { Link } from '@nextui-org/react'; 16 | 17 | const EpisodesList = dynamic(() => import('@/components/EpisodesList'), { 18 | ssr: false, 19 | }); 20 | 21 | export const AccordionComponent = ({ 22 | info, 23 | characters, 24 | episodes, 25 | id, 26 | }: { 27 | info: AnimeInfo; 28 | characters: CharacterRes; 29 | id: string; 30 | episodes?: any; 31 | }) => { 32 | const [selectedKeys, setSelectedKeys] = React.useState( 33 | new Set(['1', '2', '3', '4']), 34 | ); 35 | 36 | return ( 37 | // @ts-ignore 38 | 39 | 40 |
41 |

42 | Overview of{' '} 43 | 44 | {info.title.english ?? info.title.romaji} 45 | 46 |

47 |
48 |
49 |

50 | Studios: 51 | {info.studiosInfo.map((studio, index) => { 52 | return ( 53 | 54 | {studio.name} 55 | {index < info.studiosInfo.length - 1 && ', '} 56 | 57 | ); 58 | })} 59 |

60 |

61 | Country: 62 | {info.countryOfOrigin} 63 |

64 |

65 | Rating: 66 | {info.averageRating / 10} 67 |

68 |

69 | Total Episodes: 70 | {info.totalEpisodes} 71 |

72 |

73 | Release Date: 74 | {info.startDate.day} {numberToMonth(info.startDate.month)}{' '} 75 | {info.startDate.year} 76 |

77 |

78 | Season: 79 | { 80 | 83 | }{' '} 84 | {info.year} 85 |

86 |

87 | Duration Per Episode: 88 | {info.duration} 89 |

90 |
91 |
92 | 93 | {episodes && ( 94 | 95 | 96 | 97 | )} 98 | 99 | 100 | 101 | 106 |
107 |
108 | ({ 113 | ...r, 114 | totalEpisodes: r.episodes, 115 | episodes: undefined, 116 | })) as unknown as ConsumetAnime[], 117 | }} 118 | /> 119 |
120 |
121 |
122 | 123 | ); 124 | }; 125 | 126 | -------------------------------------------------------------------------------- /src/app/info/[id]/Img.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | export const InfoImg = ({ info }: { info: any }) => { 4 | return ( 5 |
6 | {info.coverImage ? ( 7 | {info.color} 14 | ) : ( 15 | {info.color 22 | )} 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/info/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { getInfo, getEpisodes, getCharacters } from '@/functions/requests'; 2 | import { AnimeInfo, CharacterRes } from '@/types/site'; 3 | import React, { use } from 'react'; 4 | import { InfoImg } from './Img'; 5 | import { Button, Chip, Image, Tooltip } from '@nextui-org/react'; 6 | import { GenerateColoredElementByStatus } from '@/functions/jsxUtilityFunctions'; 7 | import { FaPlayCircle } from 'react-icons/fa'; 8 | import { AccordionComponent } from './Accordions'; 9 | import Link from 'next/link'; 10 | import { Metadata, Viewport } from 'next'; 11 | 12 | export async function generateMetadata({ 13 | params, 14 | }: Readonly<{ params: { id: string } }>): Promise { 15 | const info = (await getInfo(params.id)) as AnimeInfo; 16 | return { 17 | title: info 18 | ? `${info?.title.english ? info.title.english : info.title.romaji}` 19 | : 'Loading...', 20 | description: info 21 | ? `${info.description.replace(/<\/?[^>]+(>|$)/g, '').slice(0, 180)}...` 22 | : 'Loading...', 23 | openGraph: { 24 | images: info ? info.coverImage : 'No image', 25 | }, 26 | }; 27 | } 28 | 29 | export async function generateViewport({ 30 | params, 31 | }: Readonly<{ params: { id: string } }>): Promise { 32 | const info = (await getInfo(params.id)) as AnimeInfo; 33 | return { 34 | themeColor: info.color ? info.color : '#000000', 35 | }; 36 | } 37 | 38 | interface Params { 39 | id: string; 40 | } 41 | 42 | const Info: React.FC<{ params: Params }> = ({ params }) => { 43 | const infoPromise = getInfo(params.id) as Promise; 44 | const episodesPromise = getEpisodes(params.id); 45 | const charactersPromise = getCharacters(params.id) as Promise; 46 | 47 | const [info, episodes, characters] = use( 48 | Promise.all([infoPromise, episodesPromise, charactersPromise]), 49 | ); 50 | 51 | return ( 52 | <> 53 |
54 | 55 |
56 |
57 |
58 | {info.title.english 64 |
65 |
66 |
67 |

71 | {info.title.english} 72 |

73 |

74 | {info.title.romaji} 75 |

76 |
77 | { 78 | 81 | } 82 |
83 |
84 | {info.genres.map((g) => { 85 | return ( 86 | 87 | {g} 88 | 89 | ); 90 | })} 91 |
92 |
93 |
94 |
95 | 0 ? 'success' : 'secondary'} 98 | content={ 99 | episodes?.length! > 0 100 | ? `${episodes?.[0].title}` 101 | : 'No episodes available' 102 | } 103 | > 104 |
0 ? '' : 'cursor-no-drop' 107 | }`} 108 | > 109 | 0 112 | ? `/watch/${params.id}/1` 113 | : '#' 114 | } 115 | className={`${ 116 | episodes?.length! > 0 ? '' : 'cursor-no-drop' 117 | }`} 118 | > 119 | 134 | 135 |
136 |
137 | 142 |
143 | 152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | {episodes?.length! > 0 ? ( 164 | 170 | ) : ( 171 | 176 | )} 177 |
178 | 179 | ); 180 | }; 181 | 182 | export default Info; 183 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Exo } from 'next/font/google'; 3 | import './globals.css'; 4 | import { Providers } from './providers'; 5 | import TopProgressBar from '../components/ProgressBar'; 6 | import Footer from '@/components/footer'; 7 | import { ScrollToTop } from '@/components/ScrollToTop'; 8 | import dynamic from 'next/dynamic'; 9 | import NavBarRenderer from './renderNav'; 10 | 11 | const Changelogs = dynamic(() => import('@/components/Changelogs'), { 12 | ssr: false, 13 | }); 14 | 15 | const ThemeProvider = dynamic(() => import('./ThemeProvider'), { 16 | ssr: false, 17 | }); 18 | 19 | const exo = Exo({ subsets: ['latin'] }); 20 | 21 | export const metadata: Metadata = { 22 | title: 'Reveraki', 23 | description: 'An ad-free anime streaming website', 24 | }; 25 | 26 | export default function RootLayout({ 27 | children, 28 | }: Readonly<{ 29 | children: React.ReactNode; 30 | }>) { 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 |
{children}
38 |