├── .npmrc ├── captain-definition ├── src ├── lib │ ├── components │ │ ├── composite │ │ │ ├── navigation │ │ │ │ ├── constants.ts │ │ │ │ ├── AppShell.svelte │ │ │ │ ├── AppRail.svelte │ │ │ │ ├── Tile.svelte │ │ │ │ ├── Header.svelte │ │ │ │ ├── Sidebar.svelte │ │ │ │ └── SearchForm.svelte │ │ │ ├── TextClamp.svelte │ │ │ ├── Carousel.svelte │ │ │ ├── AnimeList.svelte │ │ │ ├── AnimeBadges.svelte │ │ │ ├── Pagination.svelte │ │ │ ├── EpisodePlayer.svelte │ │ │ └── TrendingCarousel.svelte │ │ ├── base │ │ │ ├── LucideIcon.svelte │ │ │ ├── Badge.svelte │ │ │ ├── ImageHeader.svelte │ │ │ ├── Carousel.svelte │ │ │ └── PageProgress.svelte │ │ └── cards │ │ │ └── AnimeCard.svelte │ ├── server │ │ ├── database │ │ │ └── index.ts │ │ ├── providers │ │ │ ├── consumet │ │ │ │ ├── zoro.ts │ │ │ │ ├── 9anime.ts │ │ │ │ └── index.ts │ │ │ ├── animepahe │ │ │ │ ├── utils.ts │ │ │ │ ├── kwik.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── gogo │ │ │ │ ├── scraper.ts │ │ │ │ └── index.ts │ │ │ ├── utils.ts │ │ │ └── generic.ts │ │ ├── auth │ │ │ ├── lucia.ts │ │ │ └── google.ts │ │ ├── models │ │ │ └── anime.ts │ │ ├── types │ │ │ └── consumet.ts │ │ └── services │ │ │ ├── user │ │ │ └── index.ts │ │ │ ├── anime │ │ │ └── index.ts │ │ │ └── episode │ │ │ └── index.ts │ ├── utils.ts │ ├── stores │ │ └── sidebar.ts │ ├── utils │ │ ├── proxy.ts │ │ └── anime.ts │ └── common │ │ ├── jikan │ │ ├── genre.ts │ │ └── index.ts │ │ ├── mal │ │ ├── search │ │ │ └── genre.ts │ │ ├── index.ts │ │ ├── utils.ts │ │ └── search.ts │ │ ├── aniskip │ │ ├── index.test.ts │ │ └── index.ts │ │ ├── mapping │ │ └── index.ts │ │ ├── malsync │ │ ├── index.test.ts │ │ └── index.ts │ │ └── kitsu │ │ └── index.ts ├── routes │ ├── popular │ │ ├── +page.ts │ │ └── +page.svelte │ ├── trending │ │ ├── +page.ts │ │ └── +page.svelte │ ├── api │ │ ├── anime │ │ │ ├── popular │ │ │ │ └── +server.ts │ │ │ └── trending │ │ │ │ └── +server.ts │ │ ├── raw │ │ │ └── [animeId] │ │ │ │ ├── episodes │ │ │ │ └── +server.ts │ │ │ │ └── [episode] │ │ │ │ └── watch │ │ │ │ └── +server.ts │ │ └── oauth │ │ │ └── google │ │ │ └── +server.ts │ ├── search │ │ ├── +page.ts │ │ └── +page.svelte │ ├── +page.server.ts │ ├── auth │ │ ├── logout │ │ │ └── +server.ts │ │ └── login │ │ │ └── google │ │ │ └── +server.ts │ ├── +page.svelte │ ├── anime │ │ └── [id] │ │ │ ├── +page.server.ts │ │ │ ├── watch │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ │ └── +page.svelte │ └── +layout.svelte ├── hooks.server.ts ├── app.d.ts ├── app.html └── app.postcss ├── static ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest └── logo.svg ├── postcss.config.cjs ├── prisma ├── migrations │ ├── 20230514024338_increased_synopsis_length │ │ └── migration.sql │ ├── migration_lock.toml │ └── 20230511134933_initial_migration │ │ └── migration.sql └── schema.prisma ├── .dockerignore ├── .gitignore ├── .eslintignore ├── .prettierignore ├── vite.config.ts ├── .prettierrc ├── Dockerfile ├── .eslintrc.cjs ├── CONTRIBUTE.md ├── tsconfig.json ├── tailwind.config.cjs ├── svelte.config.js ├── .vscode └── settings.json ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /captain-definition: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "dockerfilePath": "./Dockerfile" 4 | } -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/constants.ts: -------------------------------------------------------------------------------- 1 | export const ICON_COLOR = 'white'; 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keerthivasansa/animos/HEAD/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/lib/server/database/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const db = new PrismaClient(); 4 | 5 | export default db; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20230514024338_increased_synopsis_length/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Anime" ALTER COLUMN "synopsis" SET DATA TYPE VARCHAR(512); 3 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function getAnimeRating(jikanRating: string) { 2 | if (jikanRating) return jikanRating.split('-')[0].trim(); 3 | else return ''; 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | src/routes/test -------------------------------------------------------------------------------- /src/lib/components/base/LucideIcon.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/components/base/Badge.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 |
10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'] 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /src/lib/stores/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const sidebarExpand = writable(false); 4 | export const sidebarShowLabel = writable(false); 5 | 6 | sidebarExpand.subscribe((val) => { 7 | if (!val) sidebarShowLabel.set(false); 8 | }); 9 | -------------------------------------------------------------------------------- /src/routes/popular/+page.ts: -------------------------------------------------------------------------------- 1 | import Jikan from '$lib/common/jikan'; 2 | 3 | export async function load({ url }) { 4 | const page = Number(url.searchParams.get('page') || '1'); 5 | const popular = await Jikan.getMostPopular(page); 6 | return { 7 | popular 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/server/providers/consumet/zoro.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderName } from '../generic'; 2 | import Consumet from './index'; 3 | 4 | class Zoro extends Consumet { 5 | public identifier: ProviderName = 'zoro'; 6 | public malSyncId = 'Zoro'; 7 | } 8 | 9 | export default Zoro; 10 | -------------------------------------------------------------------------------- /src/routes/trending/+page.ts: -------------------------------------------------------------------------------- 1 | import Jikan from '$lib/common/jikan/index.js'; 2 | 3 | export async function load({ url }) { 4 | const page = Number(url.searchParams.get('page') || '1'); 5 | const trending = await Jikan.getTrending(page); 6 | return { 7 | trending 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { auth, environment } from '@server/auth/lucia'; 2 | import type { Handle } from '@sveltejs/kit'; 3 | 4 | export const handle: Handle = async ({ event, resolve }) => { 5 | event.locals.auth = auth.handleRequest(event, environment); 6 | return await resolve(event); 7 | }; 8 | -------------------------------------------------------------------------------- /src/lib/server/providers/consumet/9anime.ts: -------------------------------------------------------------------------------- 1 | import type { ProviderName } from '../generic'; 2 | import Consumet from './index'; 3 | 4 | class NineAnime extends Consumet { 5 | public identifier: ProviderName = '9anime'; 6 | public malSyncId = '9anime'; 7 | } 8 | 9 | export default NineAnime; 10 | -------------------------------------------------------------------------------- /src/lib/server/providers/animepahe/utils.ts: -------------------------------------------------------------------------------- 1 | export function getDurationFromString(duration: string) { 2 | const parts = duration.split(':').map((val) => Number(val)); 3 | const hours = parts[0]; 4 | const minutes = parts[1]; 5 | const seconds = parts[2]; 6 | return hours * 3600 + minutes * 60 + seconds; 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/api/anime/popular/+server.ts: -------------------------------------------------------------------------------- 1 | import { MAL } from '$lib/common/mal/index.js'; 2 | import { json } from '@sveltejs/kit'; 3 | 4 | export async function GET({ url }) { 5 | const page = Number(url.searchParams.get('page') || '1'); 6 | const result = await MAL.getMostPopular(page); 7 | return json(result); 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/api/anime/trending/+server.ts: -------------------------------------------------------------------------------- 1 | import { MAL } from '$lib/common/mal/index.js'; 2 | import { json } from '@sveltejs/kit'; 3 | 4 | export async function GET({ url }) { 5 | const page = Number(url.searchParams.get('page') || '1'); 6 | const result = await MAL.getTrending(page); 7 | return json(result); 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/components/composite/TextClamp.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | 9 | 16 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, 6 | { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } 7 | ], 8 | "theme_color": "#ffffff", 9 | "background_color": "#ffffff", 10 | "display": "standalone" 11 | } 12 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace App { 3 | interface Locals { 4 | auth: import('lucia-auth').AuthRequest; 5 | } 6 | } 7 | } 8 | 9 | /// 10 | declare global { 11 | namespace Lucia { 12 | type Auth = import('@server/auth/lucia').Auth; 13 | type UserAttributes = Record; // no custom attributes 14 | } 15 | } 16 | 17 | export {}; 18 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/AppShell.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 | 9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /src/lib/server/providers/index.ts: -------------------------------------------------------------------------------- 1 | import AnimePahe from './animepahe'; 2 | import NineAnime from './consumet/9anime'; 3 | import Zoro from './consumet/zoro'; 4 | import GogoProvider from './gogo'; 5 | 6 | const providers = { gogo: GogoProvider, '9anime': NineAnime, zoro: Zoro, animepahe: AnimePahe }; 7 | 8 | export type AvailableProvider = keyof typeof providers; 9 | 10 | export default providers; 11 | -------------------------------------------------------------------------------- /src/routes/search/+page.ts: -------------------------------------------------------------------------------- 1 | import Jikan from '$lib/common/jikan/index.js'; 2 | import { redirect } from '@sveltejs/kit'; 3 | 4 | export async function load({ url }) { 5 | const q = url.searchParams.get('q') || ''; 6 | const page = url.searchParams.get('page') || '1'; 7 | if (!q) return redirect(307, '/'); 8 | const result = await Jikan.getSearch(q, Number(page)); 9 | return { 10 | result 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | 3 | FROM node:16-alpine 4 | 5 | ARG GOOGLE_CLIENT_ID 6 | ARG GOOGLE_CLIENT_SECRET 7 | ARG DATABASE_URL 8 | ENV DATABASE_URL=$DATABASE_URL 9 | ENV GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID 10 | ENV GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET 11 | 12 | WORKDIR /app 13 | COPY package.json package-lock.json ./ 14 | 15 | 16 | COPY . . 17 | RUN npm run build 18 | 19 | 20 | EXPOSE 3000 21 | CMD ["node", "build"] -------------------------------------------------------------------------------- /src/lib/utils/proxy.ts: -------------------------------------------------------------------------------- 1 | import axios, { type CreateAxiosDefaults } from 'axios'; 2 | 3 | export const USER_AGENT = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36`; 4 | 5 | // for use with cloudflare 6 | function createAxios(config?: CreateAxiosDefaults) { 7 | return axios.create({ ...config, headers: { 'User-Agent': USER_AGENT } }); 8 | } 9 | 10 | export { createAxios }; 11 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | /*place global styles here */ 2 | @import '@fontsource/poppins/400.css'; 3 | @import '@fontsource/poppins/600.css'; 4 | @import '@fontsource/poppins/700.css'; 5 | @import '@fontsource/poppins/800.css'; 6 | @import '@fontsource/poppins/900.css'; 7 | 8 | html, 9 | body { 10 | @apply h-full bg-dark-600; 11 | font-family: 'Poppins', sans-serif; 12 | } 13 | 14 | a { 15 | text-decoration: none !important; 16 | color: inherit !important; 17 | } 18 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { AnimeService } from '@server/services/anime'; 2 | 3 | export const prerender = true; 4 | 5 | export const load = async () => { 6 | try { 7 | const trendingList = await AnimeService.getTrending(); 8 | const recommendations = await AnimeService.getGenre('Action'); 9 | return { 10 | trendingList, 11 | recommendations 12 | } 13 | } catch (err) { 14 | console.log("Failed to load trending anime") 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/components/base/ImageHeader.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
10 |

{title}

11 |
12 | -------------------------------------------------------------------------------- /src/lib/server/auth/lucia.ts: -------------------------------------------------------------------------------- 1 | import lucia from 'lucia-auth'; 2 | import { sveltekit } from 'lucia-auth/middleware'; 3 | import prisma from '@lucia-auth/adapter-prisma'; 4 | import { dev } from '$app/environment'; 5 | import db from '@server/database'; 6 | 7 | export const environment = dev ? 'DEV' : 'PROD'; 8 | 9 | export const auth = lucia({ 10 | adapter: prisma(db), 11 | env: environment, 12 | middleware: sveltekit() 13 | }); 14 | 15 | export type Auth = typeof auth; 16 | -------------------------------------------------------------------------------- /src/routes/auth/logout/+server.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@server/auth/lucia.js'; 2 | 3 | const redirectToHome = new Response(null, { 4 | status: 307, 5 | headers: { 6 | location: '/' 7 | } 8 | }); 9 | 10 | export const GET = async ({ locals }) => { 11 | const session = await locals.auth.validate(); 12 | if (!session) return redirectToHome; 13 | await auth.invalidateSession(session.sessionId); 14 | locals.auth.setSession(null); 15 | return redirectToHome; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/common/jikan/genre.ts: -------------------------------------------------------------------------------- 1 | const genres = { 2 | Action: 1, 3 | Adventure: 2, 4 | 'Avant Garde': 5, 5 | 'Award Winning': 46, 6 | 'Boys Love': 28, 7 | Comedy: 4, 8 | Drama: 8, 9 | Fantasy: 10, 10 | 'Girls Love': 26, 11 | Gourmet: 47, 12 | Horror: 14, 13 | Mystery: 7, 14 | Romance: 22, 15 | 'Sci-Fi': 24, 16 | 'Slice of Life': 36, 17 | Sports: 30, 18 | Supernatural: 37, 19 | Suspense: 41 20 | }; 21 | 22 | type MalGenre = keyof typeof genres; 23 | 24 | export default genres; 25 | export type { MalGenre }; 26 | -------------------------------------------------------------------------------- /src/lib/common/mal/search/genre.ts: -------------------------------------------------------------------------------- 1 | const genres = { 2 | Action: 1, 3 | Adventure: 2, 4 | 'Avant Garde': 5, 5 | 'Award Winning': 46, 6 | 'Boys Love': 28, 7 | Comedy: 4, 8 | Drama: 8, 9 | Fantasy: 10, 10 | 'Girls Love': 26, 11 | Gourmet: 47, 12 | Horror: 14, 13 | Mystery: 7, 14 | Romance: 22, 15 | 'Sci-Fi': 24, 16 | 'Slice of Life': 36, 17 | Sports: 30, 18 | Supernatural: 37, 19 | Suspense: 41 20 | }; 21 | 22 | type MalGenre = keyof typeof genres; 23 | 24 | export default genres; 25 | export type { MalGenre }; 26 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | animos | Watch anime in HD quality 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/lib/common/aniskip/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { AnimeSkip } from './index'; 3 | 4 | const deathNote = { 5 | malId: 1535 6 | }; 7 | 8 | const deathNoteSkipTimes = [ 9 | { type: 'OPENING', start: 1, end: 91 }, 10 | { type: 'ENDING', start: 1286, end: 1356 } 11 | ]; 12 | 13 | describe('AniSkip', () => { 14 | it('Death Note Episode 1 Skip Times', async () => { 15 | const skipTimes = await AnimeSkip.getSkipTimes(deathNote.malId, 1, 1380); 16 | expect(skipTimes).toMatchObject(deathNoteSkipTimes); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/lib/components/base/Carousel.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 | 14 | 25 | -------------------------------------------------------------------------------- /src/routes/anime/[id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import Jikan from '$lib/common/jikan/index.js'; 2 | import { AnimeService } from '@server/services/anime/index.js'; 3 | 4 | export async function load({ params }) { 5 | const { id } = params; 6 | const malId = Number(id); 7 | try { 8 | const animeService = new AnimeService(malId); 9 | const anime = await animeService.getInfo(); 10 | // const recommendations = await Jikan.getRecommendations(malId); 11 | console.log("done"); 12 | return { anime }; 13 | } catch (err) { 14 | console.log("err") 15 | console.log(err); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/server/auth/google.ts: -------------------------------------------------------------------------------- 1 | import { google } from '@lucia-auth/oauth/providers'; 2 | import { auth } from './lucia'; 3 | import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private'; 4 | import { dev } from '$app/environment'; 5 | 6 | const origin = dev ? 'http://localhost:5173' : 'https://animos.cf'; 7 | const redirectUri = `${origin}/api/oauth/google`; 8 | 9 | console.log({ redirectUri }); 10 | 11 | const googleAuth = google(auth, { 12 | clientId: GOOGLE_CLIENT_ID, 13 | clientSecret: GOOGLE_CLIENT_SECRET, 14 | redirectUri 15 | }); 16 | 17 | export default googleAuth; 18 | -------------------------------------------------------------------------------- /src/lib/components/composite/Carousel.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # contribute 2 | 3 | ## Setup 4 | 5 | 1. fork the repo and clone the `dev` branch 6 | 7 | ```bash 8 | git clone https://github.com/{user}/animos -b dev 9 | ``` 10 | 11 | 2. setup postgresql 12 | 13 | 3. create a .env file at the root, put something like this in: 14 | 15 | ```url 16 | DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DATABASE 17 | ``` 18 | 19 | (for more information, go to [prisma's explanation](https://www.prisma.io/docs/concepts/database-connectors/postgresql)) 20 | 21 | 4. run: 22 | 23 | ```bash 24 | npm i 25 | npx prisma db push 26 | npx prisma migrate dev 27 | ``` 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/common/mapping/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | type ExternalServices = 'kitsu_id' | 'livechart_id'; 4 | 5 | type MappingApiResponse = Record; 6 | 7 | class Mapping { 8 | private static baseUrl = 'https://api.animos.cf'; 9 | private static client = axios.create({ baseURL: this.baseUrl }); 10 | 11 | static async getId(malId: number, externalService: ExternalServices) { 12 | const response = await this.client.get(`/mappings/${malId}`); 13 | const map = response.data; 14 | const id = map[externalService]; 15 | return id; 16 | } 17 | } 18 | 19 | export default Mapping; 20 | -------------------------------------------------------------------------------- /src/lib/common/malsync/index.test.ts: -------------------------------------------------------------------------------- 1 | import { MalSync } from '$lib/common/malsync'; 2 | import { describe, it, expect } from 'vitest'; 3 | 4 | const deathNoteIds = { 5 | mal: 1535, 6 | gogo: 'death-note', 7 | animepahe: '672' 8 | }; 9 | 10 | describe('MALSync', () => { 11 | it('Death note: MAL -> Gogo', async () => { 12 | const result = await MalSync.getProviderId(deathNoteIds.mal, 'Gogoanime'); 13 | expect(result).toBe(deathNoteIds.gogo); 14 | }); 15 | 16 | it('Death note: MAL -> animepahe', async () => { 17 | const result = await MalSync.getProviderId(deathNoteIds.mal, 'animepahe'); 18 | expect(result).toBe(deathNoteIds.animepahe); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: [ 5 | './src/**/*.{html,js,svelte,ts}', 6 | require('path').join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}') 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | accent: '#CAF2FF', 12 | dark: { 13 | 500: '#2D2F33', 14 | 600: '#202020', 15 | 800: '#131313' 16 | } 17 | }, 18 | spacing: { 19 | 18: '5rem' 20 | } 21 | } 22 | }, 23 | plugins: [ 24 | require('@tailwindcss/typography'), 25 | ...require('@skeletonlabs/skeleton/tailwind/skeleton.cjs')() 26 | ] 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/components/composite/AnimeList.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |

{title}

11 |
12 | {#each animeList as anime (anime.mal_id)} 13 | 14 | {/each} 15 |
16 |
17 | 18 | 27 | -------------------------------------------------------------------------------- /src/routes/auth/login/google/+server.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from './$types'; 2 | import googleAuth from '@server/auth/google'; 3 | 4 | export const GET: RequestHandler = async ({ cookies }) => { 5 | // get url to redirect the user to, with the state 6 | const [url, state] = await googleAuth.getAuthorizationUrl(); 7 | 8 | // the state can be stored in cookies or localstorage for request validation on callback 9 | cookies.set('google_oauth_state', state, { 10 | path: '/', 11 | maxAge: 60 * 60 12 | }); 13 | 14 | // redirect to authorization url 15 | return new Response(null, { 16 | status: 302, 17 | headers: { 18 | location: url.toString() 19 | } 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/routes/trending/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 21 | { 23 | changePage(page); 24 | }} 25 | data={currentData} 26 | /> 27 | -------------------------------------------------------------------------------- /src/routes/api/raw/[animeId]/episodes/+server.ts: -------------------------------------------------------------------------------- 1 | import providers, { type AvailableProvider } from '@server/providers/index.js'; 2 | import { AnimeService } from '@server/services/anime/index.js'; 3 | import { json } from '@sveltejs/kit'; 4 | 5 | export const GET = async ({ params, url }) => { 6 | const { animeId } = params; 7 | const provider = url.searchParams.get('provider'); 8 | 9 | const malId = Number(animeId); 10 | const anime = new AnimeService(malId); 11 | if (provider && !Object.keys(providers).includes(provider)) 12 | return new Response('The supplied `provider` does not exist or is disabled.', { status: 400 }); 13 | else if (provider) anime.setProvider(provider as AvailableProvider); 14 | const episodes = await anime.getEpisodes(); 15 | return json(episodes); 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/api/raw/[animeId]/[episode]/watch/+server.ts: -------------------------------------------------------------------------------- 1 | import providers, { type AvailableProvider } from '@server/providers/index.js'; 2 | import { AnimeService } from '@server/services/anime/index.js'; 3 | import { json } from '@sveltejs/kit'; 4 | 5 | export const GET = async ({ params, url }) => { 6 | const { animeId, episode } = params; 7 | const provider = url.searchParams.get('provider'); 8 | 9 | const malId = Number(animeId); 10 | const anime = new AnimeService(malId); 11 | if (provider && !Object.keys(providers).includes(provider)) 12 | return new Response('The supplied `provider` does not exist or is disabled.', { status: 400 }); 13 | else if (provider) anime.setProvider(provider as AvailableProvider); 14 | const source = await anime.getSource(episode); 15 | return json(source); 16 | }; 17 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | alias: { 16 | '@server/*': 'src/lib/server/*' 17 | } 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /src/lib/server/models/anime.ts: -------------------------------------------------------------------------------- 1 | import { getAnimeRating } from '$lib/utils'; 2 | import db from '@server/database'; 3 | import type { Anime } from '@tutkli/jikan-ts'; 4 | 5 | class AnimeModel { 6 | static async insertOrUpdate(anime: Anime) { 7 | const animeData = { 8 | episodes: anime.episodes || -1, 9 | image: anime.images.webp?.image_url || anime.images.jpg.image_url, 10 | malId: anime.mal_id, 11 | rating: getAnimeRating(anime.rating), 12 | score: anime.score, 13 | synopsis: anime.synopsis, 14 | title: anime.title_english || anime.title, 15 | type: anime.title 16 | }; 17 | const result = await db.anime.upsert({ 18 | create: animeData, 19 | update: animeData, 20 | where: { 21 | malId: anime.mal_id 22 | } 23 | }); 24 | return result; 25 | } 26 | } 27 | 28 | export default AnimeModel; 29 | -------------------------------------------------------------------------------- /src/routes/popular/+page.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | { 26 | changePage(page); 27 | }} 28 | data={currentData} 29 | /> 30 | -------------------------------------------------------------------------------- /src/lib/common/malsync/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export class MalSync { 4 | private static readonly baseUrl = 'https://api.malsync.moe'; 5 | private static readonly client = axios.create({ 6 | baseURL: this.baseUrl 7 | }); 8 | 9 | static async getProviderId(malId: number, provider: string) { 10 | const response = await this.client.get(`/mal/anime/${malId}`); 11 | console.log(provider); 12 | const keys = Object.keys(response.data.Sites[provider]); 13 | const id = keys.shift(); 14 | if (!id) throw new Error('Missing atleast 1 key for provider in MALSync for malId: ' + malId); 15 | // if (provider != "Zoro") 16 | // return id; 17 | // const obj = response.data.Sites[provider][id]; 18 | // const urlSlug = obj.url.split("/").at(-1); 19 | // if (!urlSlug) 20 | // throw new Error("Zoro slug doesn't match given specs"); 21 | return id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/server/types/consumet.ts: -------------------------------------------------------------------------------- 1 | export type ConsumetInfoResponse = { 2 | id: string; 3 | title: string; 4 | url: string; 5 | image: string; 6 | releaseDate: string | null; // or null 7 | description: string | null; // or null 8 | genres: [string]; 9 | // subOrDub: sub, 10 | type: string | null; // or null 11 | // status: Ongoing, 12 | otherName: string; // or null 13 | totalEpisodes: number; 14 | episodes: [ 15 | { 16 | id: string; 17 | dubId?: string; 18 | title?: string; 19 | number: number; 20 | isFiller: boolean; 21 | } 22 | ]; 23 | }; 24 | 25 | export type SourceResponse = { 26 | headers: { 27 | Referer: string; 28 | watchsb: string; // or null, since only provided with server being equal to streamsb. 29 | 'User-Agent': string; // or null 30 | }; 31 | sources: [ 32 | { 33 | url: string; 34 | quality: string; 35 | isM3U8: true; 36 | } 37 | ]; 38 | }; 39 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/AppRail.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
9 |
{ 14 | if ($sidebarExpand) $sidebarShowLabel = true; 15 | }} 16 | > 17 | 18 | 19 |
20 |
21 | 22 | 32 | -------------------------------------------------------------------------------- /src/routes/search/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |

Searching for "{q}"

25 | {#if currentData} 26 | 27 | {/if} 28 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/Tile.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
22 | 23 | {#if $sidebarShowLabel && label} 24 |
25 | {label} 26 |
27 | {/if} 28 |
29 | -------------------------------------------------------------------------------- /src/lib/components/composite/AnimeBadges.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {#if anime.status !== AnimeStatus.upcoming} 13 | {#if anime.episodes !== -1} 14 | EP: {anime.episodes} 15 | {/if} 16 | {getAnimeRating(anime.rating)} 17 | {anime.score} 19 | 24 | {:else} 25 | Upcoming 26 | {/if} 27 |
28 | -------------------------------------------------------------------------------- /src/routes/anime/[id]/watch/+page.server.ts: -------------------------------------------------------------------------------- 1 | import Jikan from '$lib/common/jikan/index.js'; 2 | import EpisodeService from '@server/services/episode/index.js'; 3 | import { redirect } from '@sveltejs/kit'; 4 | 5 | export async function load({ params, url }) { 6 | const { id } = params; 7 | const episode = url.searchParams.get('episode'); 8 | const page = url.searchParams.get('page') || '1'; 9 | console.log('child load'); 10 | const malId = Number(id); 11 | const pageNo = Number(page); 12 | const episodeService = new EpisodeService(malId); 13 | const episodes = await episodeService.getEpisodes(pageNo); 14 | console.log(episodes); 15 | const ep = episode 16 | ? episodes.find((ep) => ep.episodeNumber === Number(episode)) 17 | : episodes.shift(); 18 | if (!ep) throw redirect(307, `/anime/${id}`); 19 | const current = await episodeService.getSource(ep.episodeProviderId); 20 | const anime = await Jikan.getAnime(malId); 21 | const malEpisodes = await Jikan.getEpisodes(malId); 22 | return { episodes, current, anime, malEpisodes }; 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/common/mal/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { Axios } from 'axios'; 2 | import { MALSearch } from './search'; 3 | import { AnimeStatus } from '@prisma/client'; 4 | 5 | export class MAL { 6 | private static baseUrl = 'https://myanimelist.net'; 7 | private static client: Axios = axios.create({ 8 | baseURL: this.baseUrl 9 | }); 10 | private readonly malId: number; 11 | private readonly url: string; 12 | 13 | constructor(malId: number) { 14 | this.malId = malId; 15 | this.url = `/anime/${malId}`; 16 | } 17 | 18 | static getTrending(page: number) { 19 | const anime = MALSearch.getSearch({ 20 | sort: { 21 | order: 'desc', 22 | type: 'popularity' 23 | }, 24 | status: AnimeStatus.CURRENTLY_AIRING, 25 | page 26 | }); 27 | return anime; 28 | } 29 | 30 | static async getMostPopular(page = 1) { 31 | const animeArray = await MALSearch.getSearch({ 32 | sort: { 33 | type: 'popularity', 34 | order: 'desc' 35 | }, 36 | page 37 | }); 38 | return animeArray; 39 | } 40 | 41 | async getInfo() { 42 | const response = MAL.client.get(this.url); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/common/kitsu/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Mapping from '../mapping'; 3 | 4 | type KitsuAnimeResponse = { 5 | data: { 6 | attributes: { 7 | coverImage?: { 8 | original: string; 9 | }; 10 | }; 11 | }; 12 | }; 13 | export class Kitsu { 14 | private static baseUrl = 'https://kitsu.io/api/edge'; 15 | private static client = axios.create({ baseURL: this.baseUrl }); 16 | 17 | static async getId(malId: number) { 18 | const id = await Mapping.getId(malId, 'kitsu_id'); 19 | if (!id) throw new Error('Kitsu ID is missing for anime: ' + malId); 20 | return id; 21 | } 22 | 23 | static async getPoster(malId: number) { 24 | const id = await this.getId(malId); 25 | try { 26 | const response = await this.client.get(`/anime/${id}`); 27 | const anime = response.data; 28 | // console.log(anime.data.attributes.coverImage); 29 | console.log("Fetched poster for ", malId); 30 | if (anime.data.attributes.coverImage) return anime.data.attributes.coverImage.original; 31 | else return ''; 32 | } catch (err) { 33 | console.log("Failed to fetch poster for", malId); 34 | return ''; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/server/providers/gogo/scraper.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios'; 2 | import type { CheerioAPI } from 'cheerio'; 3 | import CryptoJS from 'crypto-js'; 4 | 5 | const key = CryptoJS.enc.Utf8.parse('37911490979715163134003223491201'); 6 | const second_key = CryptoJS.enc.Utf8.parse('54674138327930866480207815084989'); 7 | const iv = CryptoJS.enc.Utf8.parse('3134003223491201'); 8 | 9 | export const getAjaxParams = async ($: CheerioAPI, id: string) => { 10 | const encryptedKey = CryptoJS.AES['encrypt'](id, key, { iv: iv }); 11 | const script = $("script[data-name='episode']").data().value as string; 12 | const token = CryptoJS.AES['decrypt'](script, key, { iv: iv }).toString(CryptoJS.enc.Utf8); 13 | return `id=${encryptedKey}&alias=${id}&${token}`; 14 | }; 15 | 16 | type GogoSource = { 17 | source: { file: string; label: string }[]; 18 | source_bk: { file: string; label: string }[]; 19 | }; 20 | 21 | export const decryptAjaxResponse = async (fetchedRes: AxiosResponse): Promise => { 22 | const decryptedString = CryptoJS.enc.Utf8.stringify( 23 | CryptoJS.AES.decrypt(fetchedRes.data, second_key, { iv: iv }) 24 | ); 25 | 26 | return JSON.parse(decryptedString); 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/common/mal/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios'; 2 | import { type Element, load } from 'cheerio'; 3 | 4 | export function getOriginalImageUrl(transformUrl: string) { 5 | const parts = transformUrl.split('/'); 6 | parts.splice(3, 2); 7 | const url = parts.join('/').split('?').shift(); 8 | if (!url) throw new Error('Malformed Image URL.'); 9 | return url.replace('.jpg', '.webp'); 10 | } 11 | 12 | export function extractTopAnimeTable(response: AxiosResponse): number[] { 13 | const $ = load(response.data); 14 | 15 | const table = $(`table.top-ranking-table`); 16 | 17 | const extractRow = (row: Element, index: number) => { 18 | const row$ = $(row); 19 | 20 | const titleLink = row$.find('td.title').find('.detail').find('a').first(); 21 | const url = titleLink.attr('href'); 22 | const idRegex = /\/anime\/(\d+)/; 23 | 24 | if (!url) throw new Error(`URL is missing in row #${index}`); 25 | 26 | const match = url.match(idRegex); 27 | 28 | if (!match) throw new Error('Failed to get mal id via regex.'); 29 | 30 | return Number(match[1]); 31 | }; 32 | 33 | const row = table.find(`tr.ranking-list`); 34 | 35 | const result = row.toArray().map(extractRow); 36 | 37 | return result; 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/components/composite/Pagination.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 |
16 | { 23 | const newPage = e.detail.page; 24 | $page.url.searchParams.set('page', newPage.toString()); 25 | pageNo = newPage; 26 | goto(location.pathname + '?' + $page.url.searchParams.toString()); 27 | onPageChange(newPage); 28 | }} 29 | /> 30 |
31 | 32 | 39 | -------------------------------------------------------------------------------- /src/routes/api/oauth/google/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import { auth } from '@server/auth/lucia'; 3 | import googleAuth from '@server/auth/google'; 4 | import type { RequestHandler } from './$types'; 5 | 6 | export const GET: RequestHandler = async ({ cookies, url, locals }) => { 7 | // get code and state params from url 8 | const code = url.searchParams.get('code'); 9 | const state = url.searchParams.get('state'); 10 | 11 | // get stored state from cookies 12 | const storedState = cookies.get('google_oauth_state'); 13 | 14 | // validate state 15 | if (state !== storedState || !code) throw new Response(null, { status: 401 }); 16 | 17 | try { 18 | const { existingUser, createUser } = await googleAuth.validateCallback(code); 19 | 20 | const getUser = async () => { 21 | if (existingUser) return existingUser; 22 | // create a new user if the user does not exist 23 | return await createUser({}); 24 | }; 25 | const user = await getUser(); 26 | const session = await auth.createSession(user.userId); 27 | locals.auth.setSession(session); 28 | } catch (e) { 29 | // invalid code 30 | console.log(e); 31 | return new Response(null, { 32 | status: 500 33 | }); 34 | } 35 | throw redirect(302, '/'); 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/components/base/PageProgress.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 | {#if progress} 39 |
43 | {/if} 44 |
45 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/Header.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 12 |
13 |
14 |
15 | sidebarExpand.set(!$sidebarExpand)}> 16 | 25 | 26 |
27 |
28 |
29 | Logo 30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | 43 | -------------------------------------------------------------------------------- /src/lib/utils/anime.ts: -------------------------------------------------------------------------------- 1 | import type { RecommendationEntry, Anime } from '@tutkli/jikan-ts'; 2 | 3 | type AnimeLike = Anime | RecommendationEntry 4 | 5 | export function getImageUrl(anime: AnimeLike) { 6 | let imageUrl: string; 7 | let noImage = false; 8 | const MAL_NO_IMAGE_URL = 'https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png'; 9 | const NO_IMAGE_URL = 10 | 'https://img.freepik.com/premium-photo/3d-square-ceramic-black-tile-white-grout-background-decor-modern-home-kitchen-wall_73274-609.jpg'; 11 | 12 | if (anime.images.jpg.image_url !== MAL_NO_IMAGE_URL) { 13 | imageUrl = anime.images.webp?.image_url || anime.images.jpg.image_url; 14 | } else { 15 | imageUrl = NO_IMAGE_URL; 16 | noImage = true; 17 | } 18 | 19 | return { imageUrl, noImage }; 20 | } 21 | 22 | export function getTitle(anime: AnimeLike) { 23 | if (Object.hasOwn(anime, "title_english")) { 24 | const a = anime as Anime 25 | return a.title_english || a.title; 26 | } 27 | else return anime.title; 28 | } 29 | 30 | export function isFullAnime(anime: AnimeLike) { 31 | let fullAnime = false; 32 | if (Object.hasOwn(anime, "score")) { 33 | fullAnime = true; 34 | return { anime: anime as Anime, fullAnime } as const; 35 | } 36 | else 37 | return { fullAnime, anime } as const; 38 | } -------------------------------------------------------------------------------- /src/lib/components/composite/EpisodePlayer.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 |
43 |
45 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/Sidebar.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | 34 | 46 | 47 | 48 | 54 | -------------------------------------------------------------------------------- /src/lib/server/providers/consumet/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Provider, { type ProviderName } from '../generic'; 3 | import type { ConsumetInfoResponse, SourceResponse } from '@server/types/consumet'; 4 | import { getHlsDuration, getProxyUrl } from '../utils'; 5 | 6 | class Consumet extends Provider { 7 | private baseUrl = 'https://api.consumet.org/anime'; 8 | private client = axios.create({ baseURL: this.baseUrl }); 9 | public identifier: ProviderName = 'default'; 10 | 11 | constructor(malId: number) { 12 | super(malId); 13 | } 14 | 15 | async getEpisodes() { 16 | // consumet does not have pagination for episodes 17 | const id = await this.getProviderId(); 18 | try { 19 | const response = await this.client.get( 20 | `/${this.identifier}/info/${id}` 21 | ); 22 | return response.data.episodes; 23 | } catch (err) { 24 | console.log('failed to get info'); 25 | console.log(err); 26 | throw err; 27 | } 28 | } 29 | 30 | async getSourceInfo(episodeId: string) { 31 | const response = await this.client.get(`${this.identifier}/watch/${episodeId}`); 32 | const autoSource = response.data.sources.find((source) => source.quality === 'auto'); 33 | const firstSource = response.data.sources.find((source) => source.quality !== 'auto'); 34 | if (!firstSource) throw new Error('Source list is empty'); 35 | const length = await getHlsDuration(firstSource.url); 36 | const url = autoSource?.url || firstSource.url; 37 | return { url: getProxyUrl(url), length }; 38 | } 39 | } 40 | 41 | export default Consumet; 42 | -------------------------------------------------------------------------------- /src/lib/common/aniskip/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { SkipType } from '@prisma/client'; 3 | 4 | export interface SkipTime { 5 | type: string; 6 | start: number; 7 | end: number; 8 | } 9 | 10 | interface AniskipResponse { 11 | results: { 12 | skipType: 'op' | 'ed'; 13 | interval: { startTime: number; endTime: number }; 14 | }[]; 15 | } 16 | 17 | export class AnimeSkip { 18 | private static readonly baseUrl = 'https://api.aniskip.com/'; 19 | private static readonly client = axios.create({ 20 | baseURL: this.baseUrl 21 | }); 22 | 23 | static async getSkipTimes(malId: number, episodeNum: number, episodeLength: number) { 24 | try { 25 | const aniSkip = await this.client.get( 26 | `/v2/skip-times/${malId}/${episodeNum}`, 27 | { 28 | params: { 29 | types: ['op', 'ed'], 30 | episodeLength 31 | } 32 | } 33 | ); 34 | 35 | const skip = aniSkip.data.results.map((data) => { 36 | return { 37 | type: data.skipType === 'op' ? SkipType.OPENING : SkipType.ENDING, 38 | start: parseInt(data.interval.startTime.toString()), 39 | end: parseInt(data.interval.endTime.toString()) 40 | }; 41 | }); 42 | 43 | return skip; 44 | } catch (err) { 45 | if (err instanceof AxiosError) { 46 | if (err.status === 404) 47 | console.log('No skip times found'); 48 | else if (err.status === 500) 49 | console.log("AniSkip: Internal Server Error.") 50 | } else { 51 | console.log('unknown error while loading skip times:'); 52 | console.log(err); 53 | } 54 | return null; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/server/providers/utils.ts: -------------------------------------------------------------------------------- 1 | import axiosClient from 'axios'; 2 | import HLSParser from 'hls-parser'; 3 | 4 | export const USER_AGENT = 5 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36'; 6 | 7 | export const headerOption = { headers: { 'User-Agent': USER_AGENT } }; 8 | 9 | export function getProxyUrl(url: string) { 10 | const proxy_url = 'https://hls.animos.cf'; 11 | const file_extension = '.m3u8'; 12 | const urlBase = Buffer.from(url).toString('base64'); 13 | return `${proxy_url}/${urlBase}${file_extension}`; 14 | } 15 | 16 | export async function getHlsDuration(url: string, master = false) { 17 | let finalUrl: string; 18 | if (master) { 19 | const urlParts = url.split('/'); 20 | const fileParts = urlParts.at(-1)?.split('.'); 21 | if (!fileParts) throw new Error('Malformed HLS URL provided.'); 22 | fileParts.splice(-1, 0, '720'); 23 | const file = fileParts.join('.'); 24 | urlParts.splice(-1, 1, file); 25 | finalUrl = urlParts.join('/'); 26 | } else { 27 | finalUrl = url; 28 | } 29 | // const config: AxiosRequestConfig = { 30 | // }; 31 | // if (referrer) { 32 | // config.headers = { 33 | // "referrer": referrer 34 | // } 35 | // } 36 | const axios = axiosClient.create(); 37 | const resp = await axios.get(finalUrl); 38 | const hls = HLSParser.parse(resp.data); 39 | if (!hls.isMasterPlaylist) { 40 | const totalLength = hls.segments.reduce((prev, current) => prev + current.duration, 0); 41 | return Math.ceil(totalLength); 42 | } else { 43 | throw new Error('Playlist is Master.'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/server/services/user/index.ts: -------------------------------------------------------------------------------- 1 | import db from '@server/database'; 2 | import type { AvailableProvider } from '@server/providers'; 3 | import providers from '@server/providers'; 4 | import type Provider from '@server/providers/generic'; 5 | 6 | class UserService { 7 | private static currentProvider: AvailableProvider = 'animepahe'; 8 | private userId: string; 9 | 10 | constructor(userId: string) { 11 | this.userId = userId; 12 | } 13 | 14 | async getWatchHistory() { 15 | const watchHistory = await db.episodeHistory.findMany({ 16 | include: { 17 | episode: true 18 | }, 19 | where: { 20 | userId: this.userId 21 | }, 22 | take: 5 23 | }); 24 | 25 | return watchHistory; 26 | } 27 | 28 | async getGenre() { 29 | const history = await db.episodeHistory.findFirst({ 30 | select: { 31 | episode: { 32 | select: { 33 | anime: { 34 | select: { 35 | genre: true 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | where: { 42 | userId: this.userId 43 | } 44 | }); 45 | if (!history) return 'Action'; 46 | 47 | const currentAnimeGenres = history.episode.anime.genre.split(','); 48 | 49 | if (currentAnimeGenres.length < 1) return 'Action'; 50 | 51 | return currentAnimeGenres[0]; 52 | } 53 | 54 | static getProvider(malId: number): Provider { 55 | const providerClass = providers[this.currentProvider]; 56 | const provider = new providerClass(malId); 57 | return provider; 58 | } 59 | 60 | static setProvider(provider: AvailableProvider) { 61 | this.currentProvider = provider; 62 | } 63 | } 64 | 65 | export default UserService; 66 | -------------------------------------------------------------------------------- /src/lib/common/jikan/index.ts: -------------------------------------------------------------------------------- 1 | import { JikanClient, TopAnimeFilter } from '@tutkli/jikan-ts'; 2 | import type { MalGenre } from './genre'; 3 | import genres from './genre'; 4 | 5 | class Jikan { 6 | private static client = new JikanClient({ 7 | baseURL: 'https://jikan.animos.cf/v4' 8 | }); 9 | 10 | static async getTrending(page = 1) { 11 | const result = await this.client.top.getTopAnime({ 12 | filter: TopAnimeFilter.airing, 13 | page 14 | }); 15 | console.log(result.data.length); 16 | return result; 17 | } 18 | 19 | static async getEpisodes(animeMalId: number) { 20 | const result = await this.client.anime.getAnimeEpisodes(animeMalId); 21 | return result; 22 | } 23 | 24 | static async getMostPopular(page = 1) { 25 | const result = await this.client.anime.getAnimeSearch({ 26 | sort: 'desc', 27 | order_by: 'members', 28 | sfw: true, 29 | page 30 | }); 31 | return result; 32 | } 33 | 34 | static async getSearch(q: string, page = 1) { 35 | const result = await this.client.anime.getAnimeSearch({ q, page }); 36 | return result; 37 | } 38 | 39 | static async getGenre(genreId: MalGenre) { 40 | const genreMalId = genres[genreId]; 41 | const result = await this.client.anime.getAnimeSearch({ 42 | genres: genreMalId.toString(), 43 | sort: 'desc', 44 | order_by: 'members', 45 | sfw: true 46 | }); 47 | return result; 48 | } 49 | 50 | static async getAnime(malId: number) { 51 | const anime = await this.client.anime.getAnimeById(malId); 52 | return anime; 53 | } 54 | 55 | static async getRecommendations(malId:number) { 56 | const recommendations = await this.client.anime.getAnimeRecommendations(malId); 57 | return recommendations; 58 | } 59 | } 60 | 61 | export default Jikan; 62 | -------------------------------------------------------------------------------- /src/lib/components/composite/navigation/SearchForm.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
12 | 21 | 40 |
41 | 46 |
47 |
48 | 49 | 62 | -------------------------------------------------------------------------------- /src/lib/server/providers/animepahe/kwik.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | import axios from 'axios'; 4 | 5 | // Thanks to https://github.com/consumet/consumet.ts for the kwik extractor; 6 | 7 | export const extractSource = async (kwikUrl) => { 8 | const axiosInstance = axios.create(); 9 | console.log('Fetching source for', kwikUrl); 10 | 11 | try { 12 | const { data, config } = await axiosInstance.get(kwikUrl, { 13 | headers: { 14 | referer: 'https://animepahe.com/' 15 | } 16 | }); 17 | console.log(config.proxy); 18 | const x = data.match(/p\}.*kwik.*/g); 19 | let y = x[0].split('return p}(')[1].split(','); 20 | 21 | const l = y.slice(0, y.length - 5).join(''); 22 | y = y.slice(y.length - 5, -1); 23 | y.unshift(l); 24 | 25 | const [p, a, c, k, e, d] = y.map((x) => x.split('.sp')[0]); 26 | 27 | const formated = format(p, a, c, k, e, {}); 28 | 29 | const source = formated 30 | .match(/source=\\(.*?)\\'/g)[0] 31 | .replace(/'/g, '') 32 | .replace(/source=/g, '') 33 | .replace(/\\/g, ''); 34 | 35 | return source; 36 | } catch (err) { 37 | console.log('Failed to fetch kwik'); 38 | console.error(err); 39 | } 40 | }; 41 | 42 | function format(p, a, c, k, e, d) { 43 | k = k.split('|'); 44 | e = (c) => { 45 | return ( 46 | (c < a ? '' : e(parseInt((c / a).toString()))) + 47 | ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36)) 48 | ); 49 | }; 50 | if (!''.replace(/^/, String)) { 51 | while (c--) { 52 | d[e(c)] = k[c] || e(c); 53 | } 54 | k = [ 55 | (e) => { 56 | return d[e]; 57 | } 58 | ]; 59 | e = () => { 60 | return '\\w+'; 61 | }; 62 | c = 1; 63 | } 64 | while (c--) { 65 | if (k[c]) { 66 | p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]); 67 | } 68 | } 69 | return p; 70 | } 71 | -------------------------------------------------------------------------------- /src/routes/anime/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {anime.title} | animos 17 | 18 | 19 |
20 |
21 | {anime.title} 22 |
23 |
24 | {anime.title} 25 | 26 |
27 | Type: 28 | {anime.type} 29 |
30 |
31 | 32 | {anime.synopsis} 33 | 34 |
35 | 36 | 41 | 42 |
43 | 49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.documentSelectors": ["**/*.svelte"], 3 | "tailwindCSS.classAttributes": [ 4 | "class", 5 | "accent", 6 | "active", 7 | "background", 8 | "badge", 9 | "bgBackdrop", 10 | "bgDark", 11 | "bgDrawer", 12 | "bgLight", 13 | "blur", 14 | "border", 15 | "button", 16 | "buttonAction", 17 | "buttonBack", 18 | "buttonClasses", 19 | "buttonComplete", 20 | "buttonDismiss", 21 | "buttonNeutral", 22 | "buttonNext", 23 | "buttonPositive", 24 | "buttonTextCancel", 25 | "buttonTextConfirm", 26 | "buttonTextNext", 27 | "buttonTextPrevious", 28 | "buttonTextSubmit", 29 | "caretClosed", 30 | "caretOpen", 31 | "chips", 32 | "color", 33 | "cursor", 34 | "display", 35 | "element", 36 | "fill", 37 | "fillDark", 38 | "fillLight", 39 | "flex", 40 | "gap", 41 | "gridColumns", 42 | "height", 43 | "hover", 44 | "invalid", 45 | "justify", 46 | "meter", 47 | "padding", 48 | "position", 49 | "regionBackdrop", 50 | "regionBody", 51 | "regionCaption", 52 | "regionCaret", 53 | "regionCell", 54 | "regionCone", 55 | "regionContent", 56 | "regionControl", 57 | "regionDefault", 58 | "regionDrawer", 59 | "regionFoot", 60 | "regionFooter", 61 | "regionHead", 62 | "regionHeader", 63 | "regionIcon", 64 | "regionInterface", 65 | "regionInterfaceText", 66 | "regionLabel", 67 | "regionLead", 68 | "regionLegend", 69 | "regionList", 70 | "regionNavigation", 71 | "regionPage", 72 | "regionPanel", 73 | "regionRowHeadline", 74 | "regionRowMain", 75 | "regionTrail", 76 | "ring", 77 | "rounded", 78 | "select", 79 | "shadow", 80 | "slotDefault", 81 | "slotFooter", 82 | "slotHeader", 83 | "slotLead", 84 | "slotMessage", 85 | "slotMeta", 86 | "slotPageContent", 87 | "slotPageFooter", 88 | "slotPageHeader", 89 | "slotSidebarLeft", 90 | "slotSidebarRight", 91 | "slotTrail", 92 | "spacing", 93 | "text", 94 | "track", 95 | "width" 96 | ], 97 | "typescript.tsdk": "node_modules/typescript/lib" 98 | } 99 | -------------------------------------------------------------------------------- /src/routes/anime/[id]/watch/+page.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 | 28 | Watch {animeTitle} in animos 29 | 30 | 31 |
32 |
33 | 34 |
35 | 36 | {animeTitle} / Episode {current.episodeNumber} 38 |
39 | {getEpisodeTitle(current, false)} 40 |
41 |
42 |
43 |
44 |
45 | 50 |
51 |
52 | {#each episodes as ep (ep.id)} 53 | 54 | 59 | 60 | {/each} 61 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /src/lib/components/composite/TrendingCarousel.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
(pauseAutoPlay = true)} 25 | on:blur={() => (pauseAutoPlay = false)} 26 | on:mouseenter={() => (pauseAutoPlay = true)} 27 | on:mouseleave={() => (pauseAutoPlay = false)} 28 | > 29 | 30 | {#each trendingList as trending (trending.malId)} 31 | 32 |
33 | {trending.title} 34 |
37 | 38 | {trending.title} 39 | 40 | 41 | 42 |
43 | {trending.synopsis} 44 |
45 |
46 |
47 |
48 |
49 | {/each} 50 |
51 |
52 | 53 | 73 | -------------------------------------------------------------------------------- /src/lib/components/cards/AnimeCard.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
27 | 28 |
29 | {anime.title} 35 |
36 | 37 |
{title}
38 |
39 |
40 |
41 |
42 | {#if fullAnime.fullAnime} 43 | {@const result = fullAnime.anime} 44 |
48 | 49 | {title} 50 | 51 | 52 | 53 | 54 | {result.synopsis} 55 | 56 | 57 |
58 | {/if} 59 |
60 | 61 | 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "animos", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev --host", 7 | "build": "npx prisma generate && vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "test:unit": "vitest", 12 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 13 | "format": "prettier --plugin-search-dir . --write .", 14 | "test": "vitest", 15 | "coverage": "vitest run --coverage", 16 | "start": "node ./build" 17 | }, 18 | "devDependencies": { 19 | "@iconify/svelte": "^3.1.1", 20 | "@sveltejs/adapter-auto": "^2.0.0", 21 | "@sveltejs/adapter-node": "^1.2.4", 22 | "@sveltejs/adapter-vercel": "^2.4.3", 23 | "@sveltejs/kit": "^1.5.0", 24 | "@tailwindcss/typography": "^0.5.9", 25 | "@types/crypto-js": "^4.1.1", 26 | "@types/hls-parser": "^0.8.4", 27 | "@types/swiper": "^6.0.0", 28 | "@typescript-eslint/eslint-plugin": "^5.45.0", 29 | "@typescript-eslint/parser": "^5.45.0", 30 | "autoprefixer": "^10.4.14", 31 | "eslint": "^8.28.0", 32 | "eslint-config-prettier": "^8.5.0", 33 | "eslint-plugin-svelte3": "^4.0.0", 34 | "postcss": "^8.4.21", 35 | "prettier": "^2.8.0", 36 | "prettier-plugin-svelte": "^2.8.1", 37 | "prisma": "^4.10.1", 38 | "svelte": "^3.54.0", 39 | "svelte-check": "^3.0.1", 40 | "svelte-paginate": "^0.1.0", 41 | "svelte-swipe": "^1.9.2", 42 | "tailwindcss": "^3.3.1", 43 | "tslib": "^2.4.1", 44 | "typescript": "^5.0.0", 45 | "vite": "^4.2.0", 46 | "vitest": "^0.25.3" 47 | }, 48 | "type": "module", 49 | "dependencies": { 50 | "@fontsource/poppins": "^4.5.10", 51 | "@lucia-auth/adapter-prisma": "^2.0.0", 52 | "@lucia-auth/oauth": "^1.0.0", 53 | "@prisma/client": "^4.10.1", 54 | "@skeletonlabs/skeleton": "^1.5.1", 55 | "@tutkli/jikan-ts": "^0.6.61", 56 | "axios": "^1.3.5", 57 | "cheerio": "^1.0.0-rc.12", 58 | "crypto-js": "^4.1.1", 59 | "hls-parser": "^0.10.6", 60 | "hls.js": "^1.4.0", 61 | "https-proxy-agent": "^5.0.1", 62 | "lucia-auth": "^1.3.0", 63 | "lucide-svelte": "^0.145.0", 64 | "plyr": "^3.7.8", 65 | "svelte-hamburgers": "^4.0.1", 66 | "swiper": "^9.2.4", 67 | "thumb-ui": "^0.0.17" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lib/server/providers/generic.ts: -------------------------------------------------------------------------------- 1 | import db from '@server/database'; 2 | import { MalSync } from '$lib/common/malsync'; 3 | 4 | export type ProviderName = 'Gogoanime' | 'default' | '9anime' | 'zoro' | 'animepahe'; 5 | 6 | type ProviderEpisode = { 7 | id: string; 8 | number: number; 9 | title?: string; 10 | length?: number; 11 | }; 12 | 13 | /** 14 | * External providers that provide the source and episode information. 15 | */ 16 | abstract class Provider { 17 | public identifier: ProviderName = 'default'; 18 | public malSyncId = ''; 19 | public malId: number; 20 | 21 | constructor(malId: number) { 22 | this.malId = malId; 23 | } 24 | 25 | /** 26 | * Maps the MAL id to the external provider. 27 | * By default uses the malsync api to map. 28 | * Override this function to implement a custom map. 29 | * @returns The ID of the anime in the provider. 30 | */ 31 | async getProviderId() { 32 | if (this.identifier === 'default') throw new Error('Default provider has been called.'); 33 | const id = await MalSync.getProviderId(this.malId, this.malSyncId); 34 | return id; 35 | } 36 | 37 | /** 38 | * Adds a caching layer on top of `getProviderId()` 39 | * @returns The anime's corresponding ID in the current provider. 40 | */ 41 | async getId() { 42 | const animeProvider = await db.animeProvider.findUnique({ 43 | where: { 44 | malId_provider: { 45 | malId: this.malId, 46 | provider: this.identifier 47 | } 48 | } 49 | }); 50 | if (animeProvider) return animeProvider.providerId; 51 | const id = await this.getProviderId(); 52 | await db.animeProvider.create({ 53 | data: { 54 | malId: this.malId, 55 | provider: this.identifier, 56 | providerId: id 57 | } 58 | }); 59 | return id; 60 | } 61 | 62 | /** 63 | * @returns All the episodes from the provider assoiciated with the anime 64 | */ 65 | abstract getEpisodes(page: number): Promise; 66 | 67 | /** 68 | * Used to get the source from the provider 69 | * @param episode The id that uniquely identifies an episode to the provider. 70 | * @param getLength Whether the length of the given episode is to be fetched. If false, length of 0 is returned. 71 | * @returns The primary HLS source of the episode 72 | */ 73 | abstract getSourceInfo( 74 | episodeId: string, 75 | getLength?: boolean 76 | ): Promise<{ url: string; length: number }>; 77 | } 78 | 79 | export default Provider; 80 | -------------------------------------------------------------------------------- /src/lib/server/providers/animepahe/index.ts: -------------------------------------------------------------------------------- 1 | import Provider, { type ProviderName } from '../generic'; 2 | import { load } from 'cheerio'; 3 | import { MalSync } from '$lib/common/malsync'; 4 | import { extractSource } from './kwik'; 5 | import { getHlsDuration } from '../utils'; 6 | import axios from 'axios'; 7 | import { getDurationFromString } from './utils'; 8 | 9 | interface AnimepaheEpisodeInfo { 10 | duration: string; // 00:25:43 11 | session: string; 12 | title?: string; 13 | episode: number; 14 | } 15 | 16 | type AnimePaheEpisodes = { 17 | current_page: number; 18 | data: AnimepaheEpisodeInfo[]; 19 | last_page: number; 20 | }; 21 | 22 | export default class AnimePahe extends Provider { 23 | public identifier: ProviderName = 'animepahe'; 24 | private baseUrl = 'https://animepahe.ru/'; 25 | public malSyncId = 'animepahe'; 26 | private client = axios.create({ baseURL: this.baseUrl }); 27 | 28 | async getProviderId(): Promise { 29 | const int_id = await MalSync.getProviderId(this.malId, 'animepahe'); 30 | const originialresp = await this.client.get(`/a/${int_id}`); 31 | const id = originialresp.request.res.responseUrl.split('/').at(-1); 32 | return id; 33 | } 34 | 35 | async getEpisodes(page: number) { 36 | const id = await this.getId(); 37 | console.log(id); 38 | const response = await this.client.get(`/api`, { 39 | maxRedirects: 1, 40 | params: { 41 | m: 'release', 42 | sort: 'episode_asc', 43 | page, 44 | id 45 | } 46 | }); 47 | 48 | console.log(response.data); 49 | 50 | return response.data.data.map((ep) => { 51 | return { 52 | id: ep.session, 53 | title: ep.title, 54 | number: ep.episode, 55 | length: getDurationFromString(ep.duration) 56 | }; 57 | }); 58 | } 59 | 60 | async getSourceInfo( 61 | episodeId: string, 62 | getLength = true 63 | ): Promise<{ url: string; length: number }> { 64 | const animeId = await this.getId(); 65 | const url = `/play/${animeId}/${episodeId}`; 66 | 67 | const resp = await this.client.get(url); 68 | 69 | const $ = load(resp.data); 70 | 71 | const sources = $('#resolutionMenu > button') 72 | .map((index, elem) => { 73 | const elem$ = $(elem); 74 | return { 75 | src: elem$.data('src') as string, 76 | resolution: elem$.data('resolution') as number, 77 | audio: elem$.data('audio') as 'jpn' | 'eng' 78 | }; 79 | }) 80 | .toArray(); 81 | 82 | const bestSubSource = sources 83 | .filter((src) => src.audio === 'jpn') 84 | .sort((src1, src2) => src2.resolution - src1.resolution)[0]; 85 | 86 | const src = ((await extractSource(bestSubSource.src)) as string).replace('.cache', '.files'); 87 | 88 | if (!getLength) return { url: src, length: 0 }; 89 | 90 | const length = await getHlsDuration(src, false); 91 | 92 | return { 93 | url: src, 94 | length 95 | }; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/lib/server/providers/gogo/index.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | import Provider, { type ProviderName } from '../generic'; 3 | import axios from 'axios'; 4 | import { USER_AGENT, getHlsDuration, getProxyUrl, headerOption } from '../utils'; 5 | import { decryptAjaxResponse, getAjaxParams } from './scraper'; 6 | 7 | class GogoProvider extends Provider { 8 | identifier: ProviderName = 'Gogoanime'; 9 | malSyncId = 'Gogoanime'; 10 | 11 | private baseUrl = 'https://gogoanime.cl'; 12 | private client = axios.create({ baseURL: this.baseUrl }); 13 | 14 | constructor(malId: number) { 15 | super(malId); 16 | } 17 | 18 | async getSourceInfo(episodeId: string) { 19 | const gogoSlug = await this.getProviderId(); 20 | const response = await this.client.get(`/${gogoSlug}-episode-${episodeId}`); 21 | const $ = load(response.data); 22 | 23 | const gogoWatch = $('div.anime_muti_link > ul > li.vidcdn > a').attr('data-video'); 24 | const gogoWatchLink = new URL('https:' + gogoWatch); 25 | 26 | const gogoServerRes = await axios.get(gogoWatchLink.href, headerOption); 27 | const $$ = load(gogoServerRes.data); 28 | 29 | const id = gogoWatchLink.searchParams.get('id'); 30 | 31 | if (!id) throw new Error('Failed to get Id.'); 32 | 33 | const params = await getAjaxParams($$, id); 34 | 35 | const fetchRes = await axios.get( 36 | `${gogoWatchLink.protocol}//${gogoWatchLink.hostname}/encrypt-ajax.php?${params}`, 37 | { 38 | headers: { 39 | 'User-Agent': USER_AGENT, 40 | 'X-Requested-With': 'XMLHttpRequest' 41 | } 42 | } 43 | ); 44 | 45 | const finalSource = await decryptAjaxResponse(fetchRes.data); 46 | if (!finalSource.source.length) throw new Error('No sources found'); 47 | 48 | const source = finalSource.source[0]; 49 | const url = getProxyUrl(source.file); 50 | const length = await getHlsDuration(source.file, true); 51 | 52 | return { url, length }; 53 | } 54 | 55 | async getId(): Promise { 56 | const slug = await this.getProviderId(); 57 | const response = await this.client.get(`/category/${slug}`); 58 | const $ = load(response.data); 59 | const id = $('#movie_id').val(); 60 | if (typeof id !== 'string') throw new Error('GogoID is not a string'); 61 | return id; 62 | } 63 | 64 | async getEpisodes() { 65 | const id = await this.getId(); 66 | // add caching. 67 | const response = await this.client.get('https://ajax.gogo-load.com/ajax/load-list-episode', { 68 | params: { 69 | ep_start: 0, 70 | ep_end: 9999, 71 | id 72 | } 73 | }); 74 | const $ = load(response.data); 75 | const ul = $('ul'); 76 | const lastEp = ul.children().first().find('div.name').text(); 77 | const firstEp = ul.children().last().find('div.name').text(); 78 | const [last, first] = [lastEp, firstEp].map((val) => Number(val.replace('EP ', ''))); 79 | // const type = first === 0 ? "zero" : "normal"; 80 | return Array.from({ length: last - first + 1 }, (_, index) => { 81 | const number = index + 1; 82 | return { number, id: number.toString() }; 83 | }); 84 | } 85 | } 86 | 87 | export default GogoProvider; 88 | -------------------------------------------------------------------------------- /src/lib/server/services/anime/index.ts: -------------------------------------------------------------------------------- 1 | import { MAL } from '$lib/common/mal'; 2 | import db from '@server/database'; 3 | import UserService from '../user'; 4 | import type { MalGenre } from '$lib/common/mal/search/genre'; 5 | import { Kitsu } from '$lib/common/kitsu'; 6 | import Jikan from '$lib/common/jikan'; 7 | import { getImageUrl, getTitle } from '$lib/utils/anime'; 8 | import { getAnimeRating } from '$lib/utils'; 9 | 10 | export class AnimeService { 11 | private malId: number; 12 | constructor(malId: number) { 13 | this.malId = malId; 14 | } 15 | 16 | async getInfo() { 17 | const anime = await db.anime.findUnique({ 18 | where: { 19 | malId: this.malId 20 | } 21 | }); 22 | const cacheExpire = new Date().getTime() - (86400 * 1000 * 3) // cache for 3 days. 23 | if (anime && anime.createdAt.getTime() > cacheExpire) 24 | return anime; 25 | const { data } = await Jikan.getAnime(this.malId); 26 | const { imageUrl } = getImageUrl(data); 27 | const jikanData = { 28 | episodes: data.episodes, 29 | image: imageUrl, 30 | malId: this.malId, 31 | rating: getAnimeRating(data.rating), 32 | score: data.score, 33 | synopsis: data.synopsis.slice(0, 512), 34 | title: getTitle(data), 35 | type: data.type, 36 | } 37 | const newAnime = await db.anime.upsert({ 38 | create: jikanData, 39 | update: jikanData, 40 | where: { 41 | malId: this.malId 42 | } 43 | }) 44 | return newAnime; 45 | } 46 | 47 | static async getMostPopular() { 48 | const result = await MAL.getMostPopular(); 49 | return result; 50 | } 51 | 52 | static async getTrending() { 53 | const lastWeek = Date.now() - 86400 * 7 * 1000; 54 | const trending = await db.trendingAnime.findMany(); 55 | if (trending.length) { 56 | if (trending[0].createdAt.getTime() > lastWeek) return trending; 57 | console.log('Deleting animes available in cache.'); 58 | await db.trendingAnime.deleteMany(); // delete available trending anime and update with new ones. 59 | } 60 | const animeList = await Jikan.getTrending(1); 61 | const posterList = await Promise.all( 62 | animeList.data.slice(0, 6).map(async (anime, index) => { 63 | const poster = await Kitsu.getPoster(anime.mal_id) 64 | const title = getTitle(anime); 65 | const rating = getAnimeRating(anime.rating); 66 | return { malId: anime.mal_id, poster, index, title, rating, synopsis: anime.synopsis.slice(0, 100), episodes: anime.episodes || -1 }; 67 | }) 68 | ); 69 | await db.trendingAnime.createMany({ data: posterList }); 70 | 71 | return posterList; 72 | } 73 | 74 | async getTrendingList(page: number) { 75 | const data = await MAL.getTrending(page); 76 | return data; 77 | } 78 | 79 | async getEpisodes() { 80 | const provider = UserService.getProvider(this.malId); 81 | 82 | const cachedEpisodes = await db.episodeProvider.findMany({ 83 | where: { 84 | provider: provider.identifier, 85 | animeId: this.malId 86 | }, 87 | orderBy: { 88 | episodeNumber: 'asc' 89 | } 90 | }); 91 | 92 | if (cachedEpisodes.length) { 93 | console.log('cache hit'); 94 | return cachedEpisodes; 95 | } 96 | 97 | const episodes = await provider.getEpisodes(); 98 | 99 | // return episodes; 100 | 101 | const providerEpisodes = await db.$transaction( 102 | episodes.map((ep) => 103 | db.episodeProvider.create({ 104 | data: { 105 | episodeNumber: ep.number, 106 | title: ep.title, 107 | episodeProviderId: ep.id, 108 | provider: provider.identifier, 109 | exactLength: ep.length, 110 | animeId: this.malId 111 | } 112 | }) 113 | ) 114 | ); 115 | return providerEpisodes; 116 | } 117 | 118 | static async getGenre(genre: MalGenre) { 119 | const searchResult = await Jikan.getGenre(genre); 120 | return searchResult; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | enum AnimeStatus { 14 | FINISHED_AIRING 15 | CURRENTLY_AIRING 16 | UPCOMING 17 | UNKNOWN 18 | } 19 | 20 | model Anime { 21 | malId Int @id 22 | title String 23 | synopsis String @db.VarChar(512) 24 | image String 25 | score Float 26 | rating String 27 | type String 28 | episodes Int @map("episodeCount") 29 | 30 | genre String? 31 | status AnimeStatus? 32 | 33 | createdAt DateTime @default(now()) 34 | lastUpdated DateTime @updatedAt 35 | 36 | episode Episode[] 37 | genreAnime GenreRecommendation[] 38 | } 39 | 40 | model GenreRecommendation { 41 | malId Int 42 | genre String 43 | index Int 44 | anime Anime @relation(fields: [malId], references: [malId]) 45 | 46 | @@id([genre, index]) 47 | } 48 | 49 | model TrendingAnime { 50 | index Int @id 51 | malId Int 52 | poster String 53 | createdAt DateTime @default(now()) 54 | 55 | title String 56 | synopsis String 57 | episodes Int 58 | rating String 59 | } 60 | 61 | model AnimeProvider { 62 | malId Int 63 | provider String 64 | providerId String 65 | 66 | @@id([malId, provider]) 67 | } 68 | 69 | model Episode { 70 | id String @id @default(uuid()) 71 | number Int 72 | animeMalId Int 73 | length Int 74 | episodeHistory EpisodeHistory[] 75 | anime Anime @relation(fields: [animeMalId], references: [malId]) 76 | 77 | @@unique([animeMalId, number, length]) 78 | } 79 | 80 | model EpisodeProvider { 81 | id String @id @default(uuid()) 82 | provider String 83 | episodeProviderId String // episodeId in provider 84 | episodeNumber Int 85 | animeId Int 86 | 87 | source String? @db.VarChar(512) 88 | episodeId String? 89 | exactLength Int? 90 | title String? 91 | 92 | skipTimes SkipTime[] 93 | 94 | @@unique([provider, animeId, episodeProviderId]) 95 | } 96 | 97 | enum SkipType { 98 | OPENING 99 | ENDING 100 | } 101 | 102 | model SkipTime { 103 | episodeProviderId String 104 | 105 | type SkipType 106 | start Int 107 | end Int 108 | episodeProvider EpisodeProvider? @relation(fields: [episodeProviderId], references: [id]) 109 | 110 | @@id([episodeProviderId, type]) 111 | @@index([episodeProviderId]) 112 | } 113 | 114 | model EpisodeHistory { 115 | episodeId String 116 | userId String 117 | watchTime Int 118 | episode Episode @relation(fields: [episodeId], references: [id]) 119 | 120 | @@id([episodeId, userId]) 121 | @@index([episodeId]) 122 | } 123 | 124 | model AuthUser { 125 | id String @id @unique 126 | auth_session AuthSession[] 127 | auth_key AuthKey[] 128 | // here you can add custom fields for your user 129 | // e.g. name, email, username, roles, etc. 130 | 131 | @@map("auth_user") 132 | } 133 | 134 | model AuthSession { 135 | id String @id @unique 136 | user_id String 137 | active_expires BigInt 138 | idle_expires BigInt 139 | auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) 140 | 141 | @@index([user_id]) 142 | @@map("auth_session") 143 | } 144 | 145 | model AuthKey { 146 | id String @id @unique 147 | hashed_password String? 148 | user_id String 149 | primary_key Boolean 150 | expires BigInt? 151 | auth_user AuthUser @relation(references: [id], fields: [user_id], onDelete: Cascade) 152 | 153 | @@index([user_id]) 154 | @@map("auth_key") 155 | } 156 | -------------------------------------------------------------------------------- /src/lib/common/mal/search.ts: -------------------------------------------------------------------------------- 1 | import { load } from 'cheerio'; 2 | import { getOriginalImageUrl } from './utils'; 3 | import genres, { type MalGenre } from './search/genre'; 4 | import type { AnimeStatus, Anime } from '@prisma/client'; 5 | import { createAxios } from '$lib/utils/proxy'; 6 | 7 | const sortKeys = { 8 | 'episode-count': 4, 9 | popularity: 7, 10 | score: 3 11 | }; 12 | 13 | const statusKeys: Record = { 14 | CURRENTLY_AIRING: 1, 15 | FINISHED_AIRING: 2, 16 | UPCOMING: 3, 17 | UNKNOWN: -1 18 | }; 19 | 20 | export type AnimeSlim = Omit; 21 | 22 | type AnimeString = Record; 23 | 24 | interface SearchFilter { 25 | query: string; 26 | sort: { type: keyof typeof sortKeys; order: 'desc' | 'asc' }; 27 | status: AnimeStatus; 28 | genre: MalGenre[]; 29 | page: number; 30 | } 31 | 32 | export class MALSearch { 33 | private static baseUrl = 'https://myanimelist.net'; 34 | private static client = createAxios({ 35 | baseURL: this.baseUrl 36 | }); 37 | 38 | static async getSearch(filter: Partial) { 39 | const params = this.getSearchParams(filter); 40 | console.log(params.toString()); 41 | 42 | const response = await this.client.get('/anime.php', { 43 | params 44 | }); 45 | 46 | console.log(response.request.res.responseUrl); 47 | const $page = load(response.data); 48 | 49 | const currentPage = filter.page || 1; 50 | const lastPage = Number( 51 | $page('#content > div.normal_header.clearfix.pt16 > div > div > span > a').last().text() 52 | ); 53 | 54 | const table = $page('tbody').last(); 55 | const rows = table.find('tr'); 56 | 57 | const headers = [ 58 | 'image', 59 | 'malId', 60 | 'title', 61 | 'synopsis', 62 | 'type', 63 | 'episodeCount', 64 | 'score', 65 | // 'members', 66 | 'rating' 67 | ]; 68 | 69 | const result: AnimeSlim[] = []; 70 | 71 | rows.each((index, row) => { 72 | if (index === 0) 73 | // header row 74 | return; 75 | 76 | const $row = $page(row); 77 | 78 | const valueArr: string[] = []; 79 | 80 | $row.find('td').each((index, td) => { 81 | const data$ = $page(td); 82 | if (index === 0) { 83 | const src = data$.find('img').data('src') as string | undefined; 84 | if (!src) throw new Error('Missing image in anime row'); 85 | const img = getOriginalImageUrl(src); 86 | valueArr.push(img); 87 | } else if (index == 1) { 88 | const link = data$.find('div.title > a').attr('href'); 89 | if (!link) throw new Error('Missing link for anime'); 90 | const linkMatch = link?.match(/anime\/(\d+)/); 91 | if (!linkMatch || !linkMatch[1]) throw new Error('ID was not found in the link'); 92 | const malId = linkMatch[1]; 93 | const title = data$.find('div.title > a > strong').text(); 94 | const synopsis = data$.find('div.pt4').text(); 95 | 96 | valueArr.push(malId); 97 | valueArr.push(title); 98 | valueArr.push(synopsis); 99 | } else { 100 | const value = data$.text().trim(); 101 | 102 | valueArr.push(value); 103 | } 104 | }); 105 | 106 | const animeStr = headers.reduce((prev, current, index) => { 107 | prev[current as keyof AnimeString] = valueArr[index]; 108 | return prev; 109 | }, {} as AnimeString); 110 | 111 | const anime: AnimeSlim = { 112 | ...animeStr, 113 | score: Number(animeStr.score), 114 | malId: Number(animeStr.malId), 115 | episodeCount: Number(animeStr.episodeCount === '-' ? '-1' : animeStr.episodeCount) 116 | }; 117 | 118 | result.push(anime); 119 | }); 120 | 121 | return { currentPage, lastPage, data: result }; 122 | } 123 | 124 | static getSearchParams(filter: Partial) { 125 | const params = new URLSearchParams(); 126 | 127 | if (filter.query) params.set('q', filter.query); 128 | 129 | if (filter.page) { 130 | const animeShown = (filter.page - 1) * 50; 131 | params.set('show', animeShown.toString()); 132 | } 133 | 134 | if (filter.sort) { 135 | const sortCol = sortKeys[filter.sort.type]; 136 | const order = filter.sort.order === 'asc' ? 0 : 1; 137 | params.set('o', sortCol.toString()); 138 | params.set('w', order.toString()); 139 | } 140 | 141 | if (filter.genre) { 142 | filter.genre.forEach((genre) => { 143 | const id = genres[genre]; 144 | params.append('genre[]', id.toString()); 145 | }); 146 | } 147 | 148 | if (filter.status) { 149 | const key = statusKeys[filter.status]; 150 | params.set('status', key.toString()); 151 | } 152 | 153 | const columns = ['a', 'b', 'c', 'g']; 154 | 155 | columns.forEach((col) => params.append('c[]', col)); 156 | 157 | return params; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/lib/server/services/episode/index.ts: -------------------------------------------------------------------------------- 1 | import db from '@server/database'; 2 | import UserService from '../user'; 3 | import { AnimeSkip } from '$lib/common/aniskip'; 4 | import type { EpisodeProvider, SkipTime } from '@prisma/client'; 5 | 6 | class EpisodeService { 7 | private malId: number; 8 | 9 | constructor(malId: number) { 10 | this.malId = malId; 11 | } 12 | 13 | async getWatchTime(userId: string, episodeId: string) { 14 | const history = await db.episodeHistory.findUnique({ 15 | where: { 16 | episodeId_userId: { 17 | episodeId, 18 | userId 19 | } 20 | } 21 | }); 22 | if (!history) return 0; 23 | return history.watchTime; 24 | } 25 | 26 | async updateWatchTime(userId: string, episodeId: string, watchTime: number) { 27 | await db.episodeHistory.upsert({ 28 | create: { 29 | watchTime, 30 | episodeId, 31 | userId 32 | }, 33 | update: { 34 | watchTime 35 | }, 36 | where: { 37 | episodeId_userId: { 38 | episodeId, 39 | userId 40 | } 41 | } 42 | }); 43 | } 44 | 45 | async getEpisodes(page = 1) { 46 | const provider = UserService.getProvider(this.malId); 47 | const episodes = await db.episodeProvider.findMany({ 48 | where: { 49 | animeId: this.malId, 50 | provider: provider.identifier, 51 | }, 52 | orderBy: { 53 | episodeNumber: 'asc' 54 | }, 55 | take: 30, 56 | skip: (page - 1) * 30 57 | }); 58 | if (episodes.length) return episodes; 59 | const eps = await provider.getEpisodes(page); 60 | const newEpisodes = await db.$transaction( 61 | eps.map((ep) => { 62 | return db.episodeProvider.create({ 63 | data: { 64 | animeId: this.malId, 65 | episodeNumber: ep.number, 66 | episodeProviderId: ep.id, 67 | provider: provider.identifier 68 | } 69 | }); 70 | }) 71 | ); 72 | console.log(newEpisodes); 73 | return newEpisodes; 74 | } 75 | 76 | async getSource(episodeId: string): Promise { 77 | const provider = UserService.getProvider(this.malId); 78 | const episodeProvider = await db.episodeProvider.findUnique({ 79 | where: { 80 | provider_animeId_episodeProviderId: { 81 | provider: provider.identifier, 82 | animeId: this.malId, 83 | episodeProviderId: episodeId 84 | } 85 | }, 86 | include: { 87 | skipTimes: true 88 | } 89 | }); 90 | if (!episodeProvider) throw new Error('No such episode'); // TODO add episodes again. 91 | if (episodeProvider.source && episodeProvider.skipTimes.length) { 92 | return episodeProvider; 93 | } 94 | 95 | let skipTimes: 96 | | { 97 | type: 'OPENING' | 'ENDING'; 98 | start: number; 99 | end: number; 100 | }[] 101 | | null; 102 | 103 | let info: { 104 | url: string; 105 | length: number; 106 | }; 107 | if (episodeProvider.exactLength != null) { 108 | const length = episodeProvider.exactLength; 109 | [skipTimes, info] = await Promise.all([ 110 | AnimeSkip.getSkipTimes(this.malId, episodeProvider.episodeNumber, length), 111 | provider.getSourceInfo(episodeId, false) 112 | ]); 113 | } else { 114 | info = await provider.getSourceInfo(episodeId); 115 | skipTimes = await AnimeSkip.getSkipTimes( 116 | this.malId, 117 | episodeProvider.episodeNumber, 118 | info.length 119 | ); 120 | } 121 | const closestLength = info.length - (info.length % 100); 122 | const exactLength = info.length; 123 | console.log({ closestLength, exactLength, malID: this.malId }); 124 | const episode = await db.episode.upsert({ 125 | create: { 126 | animeMalId: this.malId, 127 | length: closestLength, 128 | number: episodeProvider.episodeNumber 129 | }, 130 | where: { 131 | animeMalId_number_length: { 132 | animeMalId: this.malId, 133 | length: closestLength, 134 | number: episodeProvider.episodeNumber 135 | } 136 | }, 137 | update: {} 138 | }); 139 | const result = await db.episodeProvider.update({ 140 | where: { 141 | provider_animeId_episodeProviderId: { 142 | provider: provider.identifier, 143 | animeId: this.malId, 144 | episodeProviderId: episodeId 145 | } 146 | }, 147 | data: { 148 | episodeId: episode.id, 149 | source: info.url, 150 | exactLength 151 | } 152 | }); 153 | if (skipTimes) { 154 | const times = await db.$transaction( 155 | skipTimes.map((skip) => 156 | db.skipTime.create({ 157 | data: { 158 | type: skip.type, 159 | end: skip.end, 160 | start: skip.start, 161 | episodeProviderId: result.id 162 | } 163 | }) 164 | ) 165 | ); 166 | return { ...result, skipTimes: times }; 167 | } else return result; 168 | } 169 | } 170 | 171 | export default EpisodeService; 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > The development of this project has been paused indefinitely due to time constraints of the maintainers. However, you can fork this repo and continue its development. 3 | 4 |

5 | Animos 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | License: CC BY-NC-ND 4.0 15 | 16 |

17 | 18 | animos is a streaming application for all major desktop platforms that lets users discover and stream anime with ease. 19 | It does not clutter the interface with annoying ads, and allows the user to watch the episode in peace. Leverages native APIs to perform special tasks that are just not possible using a website. 20 | 21 | # Table of contents 22 | 23 | - [Support](#support) 24 | - [Screenshots](#screenshots) 25 | - [Installation](#installation) 26 | - [Download links](#download) 27 | - [Features](#features) 28 | - [Roadmap](#roadmap) 29 | - [License](#license) 30 | 31 | ## Support 32 | 33 | To support development, request features, report features and more, join the official animos discord server: [Join](https://discord.gg/TdfwMSXjeP) 34 | 35 | # Features 36 | 37 | - Clean and minimal user interface 🌟 38 | - Cross-platform support 💻 39 | - No ads, no clutter. Enjoy a premium experience. 🛑 40 | - Discover trending and popular anime 🌍 41 | - Secure streaming 🔒 42 | - Local caching with SQLite for fast refetches ⚡ 43 | - Multiple streaming sources for reliable performance 🐳 44 | - Uses platform native APIs to give users an integrated experience 🤝 45 | 46 | ## Screenshots 47 | 48 |
49 |
50 |
51 |
52 | 53 |
54 | 55 |

56 | 57 |

58 | 59 | # Installation 60 | 61 | You can get the download links here: [Download](#download) 62 | 63 | Take a look at the platform specific installation instructions for your platform: 64 | 65 | ## Windows: 66 | 67 | - Download the .exe file from the download link 68 | - Run the setup file 69 | - Follow the on-screen instructions 70 | - Finish the setup 71 | - animos has been installed in your device. 72 | 73 | ## Mac: 74 | 75 | - DMG: 76 | - Download the DMG file from the download links. 77 | - Double-click the DMG file to mount it 78 | - Drag the application from the DMG Window into the applications directory to install it 79 | - After copying is complete, eject the DMG 80 | - Zip: 81 | - Download the zip 82 | - Open the zip file 83 | - Drag the application into the applications directory 84 | 85 | ## Linux 86 | 87 | - If you have snap installed: 88 | - Download the snap file from the install link 89 | - Open a terminal in the Downloads directory 90 | - Install the package using the command: 91 | - sudo snap install Animos-x.x.x.snap 92 | - You can also double click the downloaded file and use the GUI to install the package. 93 | - AppImage: 94 | - Download the AppImage file 95 | - Right click the file and click Properties 96 | - Under the Permissions tab, enable 'Allow executing file as program' checkbox 97 | - Double click the file to run the app 98 | 99 | # Download 100 | 101 | The download links for various platforms can be found here: 102 | 103 | - Windows 104 | - [Exe](https://github.com/Nectres/animos/releases/download/v0.2.2/Animos-Setup-0.2.2.exe) 105 | - Mac 106 | - [Zip](https://github.com/Nectres/animos/releases/download/v0.2.2/Animos-0.2.2-mac.zip) 107 | - [DMG](https://github.com/Nectres/animos/releases/download/v0.2.2/Animos-0.2.2.dmg) 108 | - Linux 109 | - [AppImage](https://github.com/Nectres/animos/releases/download/v0.2.2/Animos-0.2.2.AppImage) 110 | - [Snap](https://github.com/Nectres/animos/releases/download/v0.2.2/animos_0.2.2_amd64.snap) 111 | 112 | # Roadmap 113 | 114 | - [ ] Ability to download single or a batch of episodes 115 | - [ ] Provide recommendations based on watch history 116 | - [ ] Alert and taskbar icon to perform various functions 117 | - [ ] Streamline the API used for fetching information and sources. 118 | - [ ] Ability to import MAL 119 | - [ ] Sync across devices with user accounts 120 | - [ ] Mobile apps that sync with desktop apps. 121 | 122 | 123 | # License 124 | 125 | This work is licensed under a [Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.](https://creativecommons.org/licenses/by-nc-nd/4.0/) 126 | -------------------------------------------------------------------------------- /prisma/migrations/20230511134933_initial_migration/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "AnimeStatus" AS ENUM ('FINISHED_AIRING', 'CURRENTLY_AIRING', 'UPCOMING', 'UNKNOWN'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "SkipType" AS ENUM ('OPENING', 'ENDING'); 6 | 7 | -- CreateTable 8 | CREATE TABLE "Anime" ( 9 | "malId" INTEGER NOT NULL, 10 | "title" TEXT NOT NULL, 11 | "synopsis" VARCHAR(256) NOT NULL, 12 | "image" TEXT NOT NULL, 13 | "score" DOUBLE PRECISION NOT NULL, 14 | "rating" TEXT NOT NULL, 15 | "type" TEXT NOT NULL, 16 | "episodeCount" INTEGER NOT NULL, 17 | "genre" TEXT, 18 | "status" "AnimeStatus", 19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "lastUpdated" TIMESTAMP(3) NOT NULL, 21 | 22 | CONSTRAINT "Anime_pkey" PRIMARY KEY ("malId") 23 | ); 24 | 25 | -- CreateTable 26 | CREATE TABLE "GenreRecommendation" ( 27 | "malId" INTEGER NOT NULL, 28 | "genre" TEXT NOT NULL, 29 | "index" INTEGER NOT NULL, 30 | 31 | CONSTRAINT "GenreRecommendation_pkey" PRIMARY KEY ("genre","index") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "TrendingAnime" ( 36 | "index" INTEGER NOT NULL, 37 | "malId" INTEGER NOT NULL, 38 | "poster" TEXT NOT NULL, 39 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 | "title" TEXT NOT NULL, 41 | "synopsis" TEXT NOT NULL, 42 | "episodes" INTEGER NOT NULL, 43 | "rating" TEXT NOT NULL, 44 | 45 | CONSTRAINT "TrendingAnime_pkey" PRIMARY KEY ("index") 46 | ); 47 | 48 | -- CreateTable 49 | CREATE TABLE "AnimeProvider" ( 50 | "malId" INTEGER NOT NULL, 51 | "provider" TEXT NOT NULL, 52 | "providerId" TEXT NOT NULL, 53 | 54 | CONSTRAINT "AnimeProvider_pkey" PRIMARY KEY ("malId","provider") 55 | ); 56 | 57 | -- CreateTable 58 | CREATE TABLE "Episode" ( 59 | "id" TEXT NOT NULL, 60 | "number" INTEGER NOT NULL, 61 | "animeMalId" INTEGER NOT NULL, 62 | "length" INTEGER NOT NULL, 63 | 64 | CONSTRAINT "Episode_pkey" PRIMARY KEY ("id") 65 | ); 66 | 67 | -- CreateTable 68 | CREATE TABLE "EpisodeProvider" ( 69 | "id" TEXT NOT NULL, 70 | "provider" TEXT NOT NULL, 71 | "episodeProviderId" TEXT NOT NULL, 72 | "episodeNumber" INTEGER NOT NULL, 73 | "animeId" INTEGER NOT NULL, 74 | "source" VARCHAR(512), 75 | "episodeId" TEXT, 76 | "exactLength" INTEGER, 77 | "title" TEXT, 78 | 79 | CONSTRAINT "EpisodeProvider_pkey" PRIMARY KEY ("id") 80 | ); 81 | 82 | -- CreateTable 83 | CREATE TABLE "SkipTime" ( 84 | "episodeProviderId" TEXT NOT NULL, 85 | "type" "SkipType" NOT NULL, 86 | "start" INTEGER NOT NULL, 87 | "end" INTEGER NOT NULL, 88 | 89 | CONSTRAINT "SkipTime_pkey" PRIMARY KEY ("episodeProviderId","type") 90 | ); 91 | 92 | -- CreateTable 93 | CREATE TABLE "EpisodeHistory" ( 94 | "episodeId" TEXT NOT NULL, 95 | "userId" TEXT NOT NULL, 96 | "watchTime" INTEGER NOT NULL, 97 | 98 | CONSTRAINT "EpisodeHistory_pkey" PRIMARY KEY ("episodeId","userId") 99 | ); 100 | 101 | -- CreateTable 102 | CREATE TABLE "auth_user" ( 103 | "id" TEXT NOT NULL, 104 | 105 | CONSTRAINT "auth_user_pkey" PRIMARY KEY ("id") 106 | ); 107 | 108 | -- CreateTable 109 | CREATE TABLE "auth_session" ( 110 | "id" TEXT NOT NULL, 111 | "user_id" TEXT NOT NULL, 112 | "active_expires" BIGINT NOT NULL, 113 | "idle_expires" BIGINT NOT NULL, 114 | 115 | CONSTRAINT "auth_session_pkey" PRIMARY KEY ("id") 116 | ); 117 | 118 | -- CreateTable 119 | CREATE TABLE "auth_key" ( 120 | "id" TEXT NOT NULL, 121 | "hashed_password" TEXT, 122 | "user_id" TEXT NOT NULL, 123 | "primary_key" BOOLEAN NOT NULL, 124 | "expires" BIGINT, 125 | 126 | CONSTRAINT "auth_key_pkey" PRIMARY KEY ("id") 127 | ); 128 | 129 | -- CreateIndex 130 | CREATE UNIQUE INDEX "Episode_animeMalId_number_length_key" ON "Episode"("animeMalId", "number", "length"); 131 | 132 | -- CreateIndex 133 | CREATE UNIQUE INDEX "EpisodeProvider_provider_animeId_episodeProviderId_key" ON "EpisodeProvider"("provider", "animeId", "episodeProviderId"); 134 | 135 | -- CreateIndex 136 | CREATE INDEX "SkipTime_episodeProviderId_idx" ON "SkipTime"("episodeProviderId"); 137 | 138 | -- CreateIndex 139 | CREATE INDEX "EpisodeHistory_episodeId_idx" ON "EpisodeHistory"("episodeId"); 140 | 141 | -- CreateIndex 142 | CREATE UNIQUE INDEX "auth_user_id_key" ON "auth_user"("id"); 143 | 144 | -- CreateIndex 145 | CREATE UNIQUE INDEX "auth_session_id_key" ON "auth_session"("id"); 146 | 147 | -- CreateIndex 148 | CREATE INDEX "auth_session_user_id_idx" ON "auth_session"("user_id"); 149 | 150 | -- CreateIndex 151 | CREATE UNIQUE INDEX "auth_key_id_key" ON "auth_key"("id"); 152 | 153 | -- CreateIndex 154 | CREATE INDEX "auth_key_user_id_idx" ON "auth_key"("user_id"); 155 | 156 | -- AddForeignKey 157 | ALTER TABLE "GenreRecommendation" ADD CONSTRAINT "GenreRecommendation_malId_fkey" FOREIGN KEY ("malId") REFERENCES "Anime"("malId") ON DELETE RESTRICT ON UPDATE CASCADE; 158 | 159 | -- AddForeignKey 160 | ALTER TABLE "Episode" ADD CONSTRAINT "Episode_animeMalId_fkey" FOREIGN KEY ("animeMalId") REFERENCES "Anime"("malId") ON DELETE RESTRICT ON UPDATE CASCADE; 161 | 162 | -- AddForeignKey 163 | ALTER TABLE "SkipTime" ADD CONSTRAINT "SkipTime_episodeProviderId_fkey" FOREIGN KEY ("episodeProviderId") REFERENCES "EpisodeProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 164 | 165 | -- AddForeignKey 166 | ALTER TABLE "EpisodeHistory" ADD CONSTRAINT "EpisodeHistory_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "Episode"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 167 | 168 | -- AddForeignKey 169 | ALTER TABLE "auth_session" ADD CONSTRAINT "auth_session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 170 | 171 | -- AddForeignKey 172 | ALTER TABLE "auth_key" ADD CONSTRAINT "auth_key_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 173 | --------------------------------------------------------------------------------