├── .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 |
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 |
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 |
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 |
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 |
44 |
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 |
(mobileSearchOpen = false)}
16 | >
17 |
18 |
19 |
20 |
21 |
40 |
41 | (mobileSearchOpen = true)}>
42 |
43 |
44 |
45 |
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 |
22 |
23 |
24 |
{anime.title}
25 |
26 |
27 | Type:
28 | {anime.type}
29 |
30 |
31 |
32 | {anime.synopsis}
33 |
34 |
35 |
36 | Watch
39 |
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 |
43 |
44 |
45 |
46 | {#each Array.from({ length: 10 }) as _, index}
47 | {index * 30} - {(index + 1) * 30}
48 | {/each}}
49 |
50 |
51 |
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 |
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 |
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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
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 |
--------------------------------------------------------------------------------