first commit

This commit is contained in:
Stratum 2024-04-16 20:20:30 +02:00
commit bc2e2db2f9
52 changed files with 45407 additions and 0 deletions

11
.eslintrc.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
root: true,
extends: ['@nuxtjs/eslint-config-typescript', 'plugin:prettier/recommended'],
rules: {
'vue/no-v-html': 'off',
'no-console': 'off',
'no-unmodified-loop-condition': 'off',
'no-throw-literal': 'off',
'import/no-named-as-default': 'off'
}
}

19
.gitignore vendored Normal file
View File

@ -0,0 +1,19 @@
# Nuxt
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
.DS_Store
build/
# Electron
crunchyroll-downloader-output
debug.log
debug.log
*.log
debug.json
errors.json

4
.npmrc Normal file
View File

@ -0,0 +1,4 @@
node-linker=hoisted
shamefully-hoist=true
public-hoist-pattern=*
strict-peer-dependencies=false

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
16.19.1

20
.prettierrc Normal file
View File

@ -0,0 +1,20 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "strict",
"insertPragma": false,
"jsxSingleQuote": false,
"proseWrap": "never",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"printWidth": 180,
"endOfLine": "auto"
}

8
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"search.exclude": {
"**/.git": true,
"**/__extensions": true
},
"scss.lint.unknownAtRules": "ignore",
"css.lint.unknownAtRules": "ignore"
}

1
.yarnrc.yaml Normal file
View File

@ -0,0 +1 @@
nodeLinker: "node-modules"

17
README.md Normal file
View File

@ -0,0 +1,17 @@
## Crunchyroll Downloader
A simple tool for downloading videos from Crunchyroll and ADN.
## Supported Platforms
- Windows
- Linux
## Credits
- This is literally just an improved version of [hama3254's](https://github.com/hama3254/Crunchyroll-Downloader-v3.0) [Crunchyroll-Downloader-v3.0](https://github.com/hama3254/Crunchyroll-Downloader-v3.0)
## User Interface
#### Home
![Screenshot 2024-04-06 180439](https://github.com/Junon401/CR-Downloader/assets/166554835/b45c5799-3716-49c9-8abf-5fc7c9fe08c9)
#### Implemented Crunchyroll Login
![Screenshot 2024-04-09 210936](https://github.com/Junon401/CR-Downloader/assets/166554835/fc3e8ff2-d9b8-437c-b4e9-aefa1e9edf0b)
#### Add Anime (with implemented anime search)
![Screenshot 2024-04-09 194139](https://github.com/Junon401/CR-Downloader/assets/166554835/975a6be4-01eb-4909-9db4-3f555541aa07)
![Screenshot 2024-04-09 194242](https://github.com/Junon401/CR-Downloader/assets/166554835/87ae1a60-ffd9-416c-aec9-02ab7dd2c829)
#### Settings (soon)
![Screenshot 2024-04-09 194320](https://github.com/Junon401/CR-Downloader/assets/166554835/23298a97-3d8b-4b30-8f0e-fdd7f73db3ba)

7
binding.gyp Normal file
View File

@ -0,0 +1,7 @@
{
"targets": [
{
"target_name": "binding",
}
]
}

66
build.js Normal file
View File

@ -0,0 +1,66 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const builder = require('electron-builder')
const Platform = builder.Platform
/**
* @type {import('electron-builder').Configuration}
*/
const options = {
appId: 'com.app.id',
productName: 'Crunchyroll Downloader',
compression: 'maximum',
removePackageScripts: true,
nodeGypRebuild: true,
buildDependenciesFromSource: true,
directories: {
output: 'crunchyroll-downloader-output'
},
win: {
artifactName: 'crunchyd-Setup-${version}.${ext}',
icon: "public/favicon.ico",
target: [
{
target: 'nsis',
arch: ['x64', 'ia32']
}
]
},
nsis: {
deleteAppDataOnUninstall: true
},
mac: {
category: 'public.app-category.entertainment',
hardenedRuntime: false,
gatekeeperAssess: false,
target: [
{
target: 'default',
arch: ['x64', 'arm64']
}
]
},
linux: {
maintainer: 'Stratum',
desktop: {
StartupNotify: 'false',
Encoding: 'UTF-8',
MimeType: 'x-scheme-handler/deeplink'
},
target: ['AppImage', 'rpm', 'deb']
}
}
const platform = 'WINDOWS'
builder
.build({
targets: Platform[platform].createTarget(),
config: options
})
.then((result) => {
console.log('----------------------------')
console.log('Platform:', platform)
console.log('Output:', JSON.stringify(result, null, 2))
})

View File

@ -0,0 +1,25 @@
import type { ADNSearchFetch } from "./Types";
export async function searchADN(q: string) {
const { data, error } = await useFetch<ADNSearchFetch>(
`https://gw.api.animationdigitalnetwork.fr/show/catalog`,
{
method: "GET",
headers: {
"x-target-distribution": "de",
},
query: {
"maxAgeCategory": "18",
"search": q
}
}
);
if (error.value) {
throw new Error(error.value?.data.message as string)
}
if (!data.value) return
return data.value.shows
}

9
components/ADN/Types.ts Normal file
View File

@ -0,0 +1,9 @@
export interface ADNSearchFetch {
shows: Array<{
id: number
url: string
title: string
image2x: string
episodeCount: number
}>
}

View File

@ -0,0 +1,29 @@
import type { CrunchyLogin } from './Types'
export async function crunchyLogin() {
const { data, error } = await useFetch<CrunchyLogin>('http://localhost:8080/api/crunchyroll/login', {
method: 'POST'
})
return { data, error }
}
export async function checkAccount() {
const { data, error } = await useFetch<CrunchyLogin>('http://localhost:8080/api/crunchyroll/check', {
method: 'GET'
})
return { data, error }
}
export async function loginAccount(user: string, password: string) {
const { data, error } = await useFetch<CrunchyLogin>('http://localhost:8080/api/crunchyroll/login/login', {
method: 'POST',
body: {
user: user,
password: password
}
})
return { data, error }
}

View File

@ -0,0 +1,51 @@
import type { CrunchyrollSearchResults } from '../Search/Types'
import { crunchyLogin } from './Account'
import type { CrunchySearchFetch } from './Types'
export async function searchCrunchy(q: string) {
const { data: token, error: tokenerror } = await crunchyLogin()
if (!token.value) {
return
}
const { data, error } = await useFetch<CrunchySearchFetch>(`https://beta-api.crunchyroll.com/content/v2/discover/search`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token.value.access_token}`
},
query: {
q: q,
n: 100,
type: 'series',
ratings: false
}
})
if (error.value) {
console.error(error.value)
throw new Error(JSON.stringify(error.value))
}
if (!data.value) return
var results: CrunchyrollSearchResults = []
for (const result of data.value.data[0].items) {
results.push({
ID: result.id,
Url: `https://www.crunchyroll.com/series/${result.id}/${result.slug_title}`,
Title: result.title,
Description: result.description,
Dubs: result.series_metadata.audio_locales,
Subs: result.series_metadata.subtitle_locales,
Episodes: result.series_metadata.episode_count,
Seasons: result.series_metadata.season_count,
PEGI: result.series_metadata.maturity_ratings,
Year: result.series_metadata.series_launch_year,
Images: result.images
})
}
return results
}

View File

@ -0,0 +1,26 @@
import { crunchyLogin } from './Account'
import type { CrunchyEpisodesFetch } from './Types'
export async function listEpisodeCrunchy(q: string) {
const { data: token, error: tokenerror } = await crunchyLogin()
if (!token.value) {
return
}
const { data, error } = await useFetch<CrunchyEpisodesFetch>(`https://beta-api.crunchyroll.com/content/v2/cms/seasons/${q}/episodes`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token.value.access_token}`
}
})
if (error.value) {
console.error(error.value)
throw new Error(JSON.stringify(error.value))
}
if (!data.value) return
return data.value.data
}

View File

@ -0,0 +1,28 @@
import { crunchyLogin } from './Account'
import type { CrunchySeasonsFetch } from './Types'
export async function listSeasonCrunchy(q: string) {
const { data: token, error: tokenerror } = await crunchyLogin()
if (!token.value) {
return
}
console.log(q)
const { data, error } = await useFetch<CrunchySeasonsFetch>(`https://beta-api.crunchyroll.com/content/v2/cms/series/${q}/seasons`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token.value.access_token}`
}
})
if (error.value) {
console.error(error.value)
throw new Error(JSON.stringify(error.value))
}
if (!data.value) return
return data.value.data
}

View File

@ -0,0 +1,185 @@
export interface CrunchySearchFetch {
total: number
data: Array<{
type: string
count: number
items: Array<{
promo_description: string
title: string
promo_title: string
channel_id: string
slug_title: string
search_metadata: {
score: number
}
series_metadata: {
audio_locales: Array<string>
availability_notes: string
episode_count: number
extended_description: string
extended_maturity_rating: string
is_dubbed: boolean
is_mature: boolean
is_simulcast: boolean
is_subbed: boolean
mature_blocked: boolean
maturity_ratings: Array<string>
season_count: number
series_launch_year: number
subtitle_locales: Array<string>
}
id: string
slug: string
external_id: string
description: string
new: boolean
images: {
poster_tall: Array<
Array<{
height: number
source: string
type: string
width: number
}>
>
poster_wide: Array<
Array<{
height: number
source: string
type: string
width: number
}>
>
}
linked_resource_key: string
type: string
}>
}>
}
export interface CrunchyLogin {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope: string
country: string
account_id: string
profile_id: string
}
export interface CrunchySeasonsFetch {
total: number
data: Array<{
identifier: string
description: string
is_simulcast: boolean
subtitle_locales: Array<string>
series_id: string
id: string
audio_locales: Array<string>
title: string
versions: Array<{
audio_locale: string
guid: string
original: boolean
variant: string
}>
season_sequence_number: number
season_number: number
maturity_ratings: Array<string>
mature_blocked: boolean
channel_id: string
is_subbed: boolean
audio_locale: string
season_display_number: string
is_complete: boolean
season_tags: Array<string>
is_mature: boolean
is_dubbed: boolean
slug_title: string
availability_notes: string
number_of_episodes: boolean
}>
meta: {
versions_considered: boolean
}
}
export interface CrunchyEpisodesFetch {
total: number
data: Array<{
closed_captions_available: boolean
availability_notes: string
next_episode_title: string
upload_date: string
versions: Array<{
audio_locale: string
guid: string
is_premium_only: boolean
media_guid: string
original: boolean
season_guid: string
variant: string
}>
season_slug_title: string
series_title: string
season_title: string
sequence_number: number
maturity_ratings: Array<string>
slug_title: string
is_premium_only: boolean
availability_ends: string
identifier: string
recent_variant: string
free_available_date: string
subtitle_locales: Array<string>
series_id: string
mature_blocked: boolean
duration_ms: number
availability_starts: string
audio_locale: string
images: {
thumbnail: Array<
Array<{
height: number
source: string
type: string
width: number
}>
>
}
season_sequence_number: number
season_id: string
episode_number: number
listing_id: string
available_date: string
channel_id: string
season_number: number
hd_flag: boolean
recent_audio_locale: string
available_offline: boolean
episode: string
is_subbed: boolean
media_type: string
is_clip: boolean
title: string
streams_link: string
slug: string
id: string
production_episode_id: string
is_dubbed: boolean
next_episode_id: string
series_slug_title: string
season_tags: Array<string>
premium_date: string
is_mature: boolean
premium_available_date: string
description: string
episode_air_date: string
eligible_region: string
}>
meta: {
versions_considered: boolean
}
}

View File

@ -0,0 +1,71 @@
export interface CrunchyEpisode {
closed_captions_available: boolean,
availability_notes: string,
next_episode_title: string,
upload_date: string,
versions: Array<{
audio_locale: string,
guid: string,
is_premium_only: boolean,
media_guid: string,
original: boolean,
season_guid: string,
variant: string
}>,
season_slug_title: string,
series_title: string,
season_title: string,
sequence_number: number,
maturity_ratings: Array<string>,
slug_title: string,
is_premium_only: boolean,
availability_ends: string,
identifier: string,
recent_variant: string,
free_available_date: string,
subtitle_locales: Array<string>,
series_id: string,
mature_blocked: boolean,
duration_ms: number,
availability_starts: string,
audio_locale: string,
images: {
thumbnail: Array<Array<{
height: number,
source: string,
type: string,
width: number
}>>
},
season_sequence_number: number,
season_id: string,
episode_number: number,
listing_id: string,
available_date: string,
channel_id: string,
season_number: number,
hd_flag: boolean,
recent_audio_locale: string,
available_offline: boolean,
episode: string,
is_subbed: boolean,
media_type: string,
is_clip: boolean,
title: string,
streams_link: string,
slug: string,
id: string,
production_episode_id: string,
is_dubbed: boolean,
next_episode_id: string,
series_slug_title: string,
season_tags: Array<string>,
premium_date: string,
is_mature: boolean,
premium_available_date: string,
description: string,
episode_air_date: string,
eligible_region: string
}
export interface CrunchyEpisodes extends Array<CrunchyEpisode> {}

View File

@ -0,0 +1,8 @@
const isProduction = process.env.NODE_ENV !== 'development'
export function openNewWindow(urlprod: string, urldev: string, w: string) {
const newWindow = window.open(isProduction ? urlprod : urldev, '_blank', w)
if (newWindow) {
newWindow.focus()
}
}

65
components/MainHeader.vue Normal file
View File

@ -0,0 +1,65 @@
<template>
<div class="flex flex-row bg-[#111111] h-16" style="-webkit-app-region: drag">
<div class="w-full flex gap-10 flex-row items-center justify-center px-">
<button @click="openAddAnime" class="px-6 py-0.5 border-2 border-[#ce6104] rounded-xl" style="-webkit-app-region: no-drag">
<Icon name="ph:plus-bold" class="h-7 w-7 text-[#ce6104]" />
</button>
<button class="px-6 py-0.5 border-2 border-[#ce6104] rounded-xl" style="-webkit-app-region: no-drag">
<Icon name="material-symbols:globe" class="h-7 w-7 text-[#ce6104]" />
</button>
</div>
<div class="w-full flex flex-row items-center justify-center">
<div class="text-2xl text-white select-none">Crunchyroll Downloader</div>
</div>
<div class="w-full flex gap-4 flex-row items-center justify-start px-5">
<button class="px-6 py-0.5 border-2 border-[#ce6104] rounded-xl" style="-webkit-app-region: no-drag">
<Icon name="iconamoon:playlist" class="h-7 w-7 text-[#ce6104]" />
</button>
<button @click="openSettings" class="px-6 py-0.5 border-2 border-[#ce6104] rounded-xl" style="-webkit-app-region: no-drag">
<Icon name="ic:round-settings" class="h-7 w-7 text-[#ce6104]" />
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { checkAccount, crunchyLogin } from './Crunchyroll/Account'
import { openNewWindow } from './Functions/WindowHandler'
const isProduction = process.env.NODE_ENV !== 'development'
async function openSettings() {
(window as any).myAPI.openWindow({
title: "Settings",
url: isProduction ? 'http://localhost:8079/settings' : 'http://localhost:3000/settings',
width: 600,
height: 700,
backgroundColor: "#111111"
})
}
async function openAddAnime() {
const { data, error } = await checkAccount()
if (error.value) {
(window as any).myAPI.openWindow({
title: "Crunchyroll Login",
url: isProduction ? 'http://localhost:8079/crunchylogin' : 'http://localhost:3000/crunchylogin',
width: 600,
height: 300,
backgroundColor: "#111111"
})
return
}
(window as any).myAPI.openWindow({
title: "Add Anime",
url: isProduction ? 'http://localhost:8079/addanime' : 'http://localhost:3000/addanime',
width: 700,
height: 400,
backgroundColor: "#111111"
})
}
</script>
<style></style>

View File

@ -0,0 +1,43 @@
export interface CrunchyrollSearchResult {
ID: string;
Url: string;
Title: string;
Description: string;
Dubs: Array<string>;
Subs: Array<string>;
Episodes: number;
Seasons: number;
PEGI: Array<string>;
Year: number;
Images: {
poster_tall: Array<
Array<{
height: number;
source: string;
type: string;
width: number;
}>
>;
poster_wide: Array<
Array<{
height: number;
source: string;
type: string;
width: number;
}>
>;
};
}
export interface CrunchyrollSearchResults
extends Array<CrunchyrollSearchResult> {}
export interface ADNSearchResult {
id: number;
url: string;
title: string;
image2x: string;
episodeCount: number;
}
export interface ADNSearchResults extends Array<ADNSearchResult> {}

View File

@ -0,0 +1,33 @@
export interface CrunchySeason {
identifier: string
description: string
is_simulcast: boolean
subtitle_locales: Array<string>
series_id: string
id: string
audio_locales: Array<string>
title: string
versions: Array<{
audio_locale: string
guid: string
original: boolean
variant: string
}>
season_sequence_number: number
season_number: number
maturity_ratings: Array<string>
mature_blocked: boolean
channel_id: string
is_subbed: boolean
audio_locale: string
season_display_number: string
is_complete: boolean
season_tags: Array<string>
is_mature: boolean
is_dubbed: boolean
slug_title: string
availability_notes: string
number_of_episodes: boolean
}
export interface CrunchySeasons extends Array<CrunchySeason> {}

10
nuxt.config.ts Normal file
View File

@ -0,0 +1,10 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
typescript: {
shim: false
},
ssr: false,
modules: ['@nuxtjs/tailwindcss', 'nuxt-icon'],
})

20087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "crunchyroll-downloader",
"author": "Stratum",
"description": "Crunchyroll Downloader",
"version": "1.0.0",
"private": true,
"main": ".output/src/electron/background.js",
"scripts": {
"dev": "nuxt dev -o",
"build": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare && electron-builder install-app-deps",
"transpile-src": "tsc -p ./src --outDir .output/src",
"dev:electron": "NODE_ENV=development concurrently --kill-others \"nuxt dev\" \"tsc-watch -p ./src --outDir .output/src --onSuccess 'electron ./.output/src/electron/background.js'\"",
"dev:electron:win": "set NODE_ENV=development& concurrently --kill-others \"nuxt dev\" \"tsc-watch -p ./src --outDir .output/src --onSuccess run.electron\"",
"build:electron": "pnpm build && pnpm transpile-src && node build.js"
},
"devDependencies": {
"@nuxtjs/eslint-config-typescript": "^12.1.0",
"@nuxtjs/tailwindcss": "^6.11.4",
"@types/express": "^4.17.21",
"concurrently": "^8.2.2",
"electron": "^23.3.13",
"electron-builder": "^23.6.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-prettier": "^4.2.1",
"nuxt": "^3.11.2",
"nuxt-icon": "^0.6.10",
"prettier": "^2.8.8",
"sass": "^1.74.1",
"sass-loader": "^13.3.3",
"tsc-watch": "^6.2.0",
"typescript": "^5.4.4",
"wait-on": "^7.2.0"
},
"dependencies": {
"@fastify/cors": "^9.0.1",
"@pinia/nuxt": "^0.4.11",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/node-cron": "^3.0.11",
"@types/uuid": "^9.0.8",
"ass-compiler": "^0.1.11",
"electron-log": "^5.1.2",
"electron-updater": "^5.3.0",
"express": "^4.19.2",
"fastify": "^4.26.2",
"fluent-ffmpeg": "^2.1.2",
"mpd-parser": "^1.3.0",
"node-cache": "^5.1.2",
"node-cron": "^3.0.3",
"sequelize": "^6.37.2",
"sqlite3": "5.1.6",
"uuid": "^9.0.1"
}
}

419
pages/addanime.vue Normal file
View File

@ -0,0 +1,419 @@
<template>
<div class="h-screen overflow-hidden bg-[#111111] flex flex-col p-5 text-white" style="-webkit-app-region: drag">
<div class="relative flex flex-row items-center justify-center">
<button
v-if="tab === 2"
@click="tab = 1"
class="absolute left-0 bg-[#5c5b5b] py-1 px-3 rounded-xl flex flex-row items-center justify-center gap-0.5 hover:bg-[#4b4a4a] transition-all"
style="-webkit-app-region: no-drag"
>
<Icon name="formkit:arrowleft" class="h-5 w-5" />
Back
</button>
<div class="text-2xl">Add Video</div>
</div>
<div v-if="tab === 1" class="flex flex-col mt-5 gap-3.5 h-full" style="-webkit-app-region: no-drag">
<div class="relative flex flex-col">
<select v-model="service" name="service" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option value="adn">ADN</option>
<option value="crunchyroll">Crunchyroll</option>
</select>
</div>
<div class="relative flex flex-col">
<input
v-model="search"
@input="handleInputChange"
type="search"
name="search"
placeholder="Search"
class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center"
/>
<div v-if="isFetchingResults" class="absolute top-full left-0 h-28 w-full bg-[#868585] rounded-xl z-10 flex items-center justify-center">
<Icon name="mdi:loading" class="h-8 w-8 animate-spin" />
</div>
<div
v-if="searchActive"
class="absolute top-full left-0 h-60 w-full bg-[#868585] rounded-xl overflow-y-scroll grid grid-cols-2 gap-3 p-2 z-10"
style="-webkit-app-region: no-drag"
>
<button v-for="result in crunchySearchResults" @click="selectShow(result)" class="flex flex-row gap-3 px-3 py-3 hover:bg-[#747474] rounded-xl">
<div class="min-w-10 w-10 bg-gray-700">
<img :src="result.Images.poster_tall[0].find((p) => p.height === 720)?.source" alt="Image Banner" class="h-full w-full object-cover" />
</div>
<div class="flex flex-col items-start text-start">
<div class="text-sm line-clamp-1">
{{ result.Title }}
</div>
<div v-if="service === 'crunchyroll'" class="text-xs"> Seasons: {{ result.Seasons }} </div>
<div class="text-xs"> Episodes: {{ result.Episodes }} </div>
</div>
</button>
<button v-for="result in adnSearchResults" @click="selectShow(result)" class="flex flex-row gap-3 px-3 py-3 hover:bg-[#747474] rounded-xl h-20">
<div class="min-w-10 w-10 h-14 bg-gray-700">
<img :src="result.image2x" alt="Image Banner" class="h-full w-full object-cover" />
</div>
<div class="flex flex-col items-start text-start">
<div class="text-sm line-clamp-1">
{{ result.title }}
</div>
<div class="text-xs"> Episodes: {{ result.episodeCount }} </div>
</div>
</button>
</div>
</div>
<div class="relative flex flex-col">
<input v-model="url" type="text" name="text" placeholder="URL" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center" />
</div>
<div class="relative flex flex-col">
<input
@click="getFolderPath()"
v-model="path"
type="text"
name="text"
placeholder="Path"
class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer"
readonly
/>
</div>
<div class="relative flex flex-col mt-auto">
<button @click="switchToSeason" class="relative py-3 border-2 rounded-xl flex flex-row items-center justify-center">
<div class="flex flex-row items-center justify-center transition-all" :class="isFetchingSeasons ? 'opacity-0' : 'opacity-100'">
<div class="text-xl">Next</div>
</div>
<div class="absolute flex flex-row items-center justify-center gap-1 transition-all" :class="isFetchingSeasons ? 'opacity-100' : 'opacity-0'">
<Icon name="mdi:loading" class="h-6 w-6 animate-spin" />
<div class="text-xl">Loading</div>
</div>
</button>
</div>
<!-- {{ searchresults }} -->
</div>
<div v-if="tab === 2" class="flex flex-col mt-5 gap-3.5 h-full" style="-webkit-app-region: no-drag">
<div class="relative flex flex-col">
<select v-model="selectedSeason" name="seasons" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option v-for="season in seasons" :value="season" class="text-sm text-slate-200"
>S{{ season.season_display_number ? season.season_display_number : season.season_number }} - {{ season.title }}</option
>
</select>
</div>
<div class="relative flex flex-col">
<select v-model="selectedStartEpisode" name="episode" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option v-for="episode in episodes" :value="episode" class="text-sm text-slate-200"
>E{{ episode.episode_number ? episode.episode_number : episode.episode }} - {{ episode.title }}</option
>
</select>
<div
class="absolute w-full h-9 bg-[#afadad] rounded-xl transition-all flex flex-row items-center justify-center gap-1 text-black"
:class="isFetchingEpisodes ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<Icon name="mdi:loading" class="h-6 w-6 animate-spin" />
<div class="text-sm">Loading</div>
</div>
</div>
<div class="relative flex flex-col">
<select v-model="selectedEndEpisode" name="episode" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option
v-if="episodes && selectedStartEpisode"
v-for="(episode, index) in episodes"
:value="episode"
class="text-sm text-slate-200"
:disabled="index < episodes.findIndex((i) => i.id === selectedStartEpisode?.id)"
>E{{ episode.episode_number ? episode.episode_number : episode.episode }} - {{ episode.title }}</option
>
</select>
<div
class="absolute w-full h-9 bg-[#afadad] rounded-xl transition-all flex flex-row items-center justify-center gap-1 text-black"
:class="isFetchingEpisodes ? 'opacity-100' : 'opacity-0 pointer-events-none'"
>
<Icon name="mdi:loading" class="h-6 w-6 animate-spin" />
<div class="text-sm">Loading</div></div
>
</div>
<div class="relative flex flex-col select-none">
<div @click="selectDub ? (selectDub = false) : (selectDub = true)" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
Dubs:
{{ selectedDubs.map((t) => t.name).join(', ') }}
</div>
<div v-if="selectDub" class="absolute top-full left-0 w-full bg-[#868585] rounded-xl grid grid-cols-12 gap-1 p-1 z-10">
<button
v-for="l in CRselectedShow?.Dubs.map((s) => {
return { name: locales.find((l) => l.locale === s) ? locales.find((l) => l.locale === s)?.name : s, locale: s }
})"
@click="toggleDub(l)"
class="flex flex-row items-center justify-center gap-3 py-2 rounded-xl text-sm"
:class="selectedDubs.find((i) => i.locale === l.locale) ? 'bg-[#585858]' : 'hover:bg-[#747474]'"
>
{{ l.name }}
</button>
</div>
</div>
<div class="relative flex flex-col select-none">
<div @click="selectSub ? (selectSub = false) : (selectSub = true)" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
Subs:
{{ selectedSubs.map((t) => t.name).join(', ') }}
</div>
<div v-if="selectSub" class="absolute top-full left-0 w-full bg-[#868585] rounded-xl grid grid-cols-12 gap-1 p-1 z-10">
<button
v-for="l in CRselectedShow?.Subs.map((s) => {
return { name: locales.find((l) => l.locale === s) ? locales.find((l) => l.locale === s)?.name : s, locale: s }
})"
@click="toggleSub(l)"
class="flex flex-row items-center justify-center gap-3 py-2 rounded-xl text-sm"
:class="selectedSubs.find((i) => i.locale === l.locale) ? 'bg-[#585858]' : 'hover:bg-[#747474]'"
>
{{ l.name }}
</button>
</div>
</div>
<!-- {{ CRselectedShow?.Subs.map(s=> { return locales.find(l => l.locale === s)?.name }) }}
{{ CRselectedShow?.Dubs.map(s=> { return locales.find(l => l.locale === s)?.name }) }} -->
<div class="relative flex flex-col mt-auto">
<button @click="addToPlaylist" class="relative py-3 border-2 rounded-xl flex flex-row items-center justify-center">
<div class="flex flex-row items-center justify-center transition-all" :class="isFetchingSeasons ? 'opacity-0' : 'opacity-100'">
<div class="text-xl">Add to Queue</div>
</div>
<div class="absolute flex flex-row items-center justify-center gap-1 transition-all" :class="isFetchingSeasons ? 'opacity-100' : 'opacity-0'">
<Icon name="mdi:loading" class="h-6 w-6 animate-spin" />
<div class="text-xl">Loading</div>
</div>
</button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { searchADN } from '~/components/ADN/ListAnimes'
import { searchCrunchy } from '~/components/Crunchyroll/ListAnimes'
import { listEpisodeCrunchy } from '~/components/Crunchyroll/ListEpisodes'
import { listSeasonCrunchy } from '~/components/Crunchyroll/ListSeasons'
import type { CrunchyEpisode, CrunchyEpisodes } from '~/components/Episode/Types'
import type { ADNSearchResult, ADNSearchResults, CrunchyrollSearchResult, CrunchyrollSearchResults } from '~/components/Search/Types'
import type { CrunchySeason, CrunchySeasons } from '~/components/Season/Types'
let timeoutId: ReturnType<typeof setTimeout> | null = null
const locales = ref<Array<{ locale: string; name: string }>>([
{ locale: 'ja-JP', name: 'JP' },
{ locale: 'de-DE', name: 'DE' },
{ locale: 'hi-IN', name: 'HI' },
{ locale: 'ru-RU', name: 'RU' },
{ locale: 'en-US', name: 'EN' },
{ locale: 'fr-FR', name: 'FR' },
{ locale: 'pt-BR', name: 'PT' },
{ locale: 'es-419', name: 'LA-ES' },
{ locale: 'en-IN', name: 'EN-IN' },
{ locale: 'it-IT', name: 'IT' },
{ locale: 'es-ES', name: 'ES' },
{ locale: 'ta-IN', name: 'TA' },
{ locale: 'te-IN', name: 'TE' },
{ locale: 'ar-SA', name: 'AR' },
{ locale: 'ms-MY', name: 'MS' },
{ locale: 'th-TH', name: 'TH' },
{ locale: 'vi-VN', name: 'VI' },
{ locale: 'id-ID', name: 'ID' },
{ locale: 'ko-KR', name: 'KO' }
])
const selectDub = ref<boolean>(false)
const selectedDubs = ref<Array<{ name: string | undefined; locale: string }>>([{ locale: 'ja-JP', name: 'JP' }])
const selectSub = ref<boolean>(false)
const selectedSubs = ref<Array<{ name: string | undefined; locale: string }>>([{ locale: 'en-US', name: 'EN' }])
const tab = ref<number>(1)
const search = ref<string>('')
const searchActive = ref<boolean>(false)
const crunchySearchResults = ref<CrunchyrollSearchResults>()
const adnSearchResults = ref<ADNSearchResults>()
const CRselectedShow = ref<CrunchyrollSearchResult>()
const ADNselectedShow = ref<ADNSearchResult>()
const url = ref<string>('')
const path = ref<string>()
const service = ref<'adn' | 'crunchyroll'>('crunchyroll')
const seasons = ref<CrunchySeasons>()
const episodes = ref<CrunchyEpisodes>()
const selectedSeason = ref<CrunchySeason>()
const selectedStartEpisode = ref<CrunchyEpisode>()
const selectedEndEpisode = ref<CrunchyEpisode>()
const isFetchingSeasons = ref<number>(0)
const isFetchingEpisodes = ref<number>(0)
const isFetchingResults = ref<number>(0)
const fetchSearch = async () => {
if (!search.value || search.value.length === 0) {
adnSearchResults.value = []
crunchySearchResults.value = []
searchActive.value = false
return
}
isFetchingResults.value++
if (service.value === 'adn') {
adnSearchResults.value = await searchADN(search.value)
}
if (service.value === 'crunchyroll') {
crunchySearchResults.value = await searchCrunchy(search.value)
}
isFetchingResults.value--
searchActive.value = true
}
const debounceFetchSearch = () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(fetchSearch, 500)
}
const handleInputChange = () => {
debounceFetchSearch()
}
if (process.client) {
;(window as any).myAPI.getFolder().then((result: any) => {
path.value = result
})
}
const getFolderPath = () => {
if (process.client) {
;(window as any).myAPI.selectFolder().then((result: any) => {
path.value = result
})
}
}
const selectShow = async (show: any) => {
if (service.value === 'adn') {
ADNselectedShow.value = show
url.value = show.url
}
if (service.value === 'crunchyroll') {
CRselectedShow.value = show
url.value = show.Url
}
search.value = ''
crunchySearchResults.value = []
adnSearchResults.value = []
searchActive.value = false
}
watch(selectedSeason, () => {
refetchEpisodes()
})
watch(selectedStartEpisode, () => {
if (!selectedEndEpisode.value) return
if (!episodes.value) return
const indexA = episodes.value.findIndex((i) => i === selectedStartEpisode.value)
const indexE = episodes.value.findIndex((i) => i === selectedEndEpisode.value)
if (indexA > indexE) {
selectedEndEpisode.value = episodes.value[indexA]
}
})
const refetchEpisodes = async () => {
isFetchingEpisodes.value++
if (!selectedSeason.value) {
isFetchingEpisodes.value--
return
}
episodes.value = await listEpisodeCrunchy(selectedSeason.value.id)
if (episodes.value) {
selectedStartEpisode.value = episodes.value[0]
selectedEndEpisode.value = episodes.value[0]
}
isFetchingEpisodes.value--
}
const switchToSeason = async () => {
isFetchingSeasons.value++
if (!ADNselectedShow.value && !CRselectedShow.value) {
isFetchingSeasons.value--
return
}
if (CRselectedShow.value) {
seasons.value = await listSeasonCrunchy(CRselectedShow.value.ID)
if (!seasons.value) {
isFetchingSeasons.value--
return
}
selectedSeason.value = seasons.value[0]
episodes.value = await listEpisodeCrunchy(selectedSeason.value.id)
if (episodes.value) {
selectedStartEpisode.value = episodes.value[0]
selectedEndEpisode.value = episodes.value[0]
}
tab.value = 2
}
selectedDubs.value = [{ locale: 'ja-JP', name: 'JP' }],
selectedSubs.value = [{ locale: 'en-US', name: 'EN' }],
isFetchingSeasons.value--
}
const toggleDub = (lang: { name: string | undefined; locale: string }) => {
const index = selectedDubs.value.findIndex((i) => i.locale === lang.locale)
if (index !== -1) {
if (selectedDubs.value.length !== 1) {
selectedDubs.value.splice(index, 1)
return
}
}
if (index === -1) {
selectedDubs.value.push(lang)
return
}
}
const toggleSub = (lang: { name: string | undefined; locale: string }) => {
const index = selectedSubs.value.findIndex((i) => i.locale === lang.locale)
if (index !== -1) {
if (selectedSubs.value.length !== 1) {
selectedSubs.value.splice(index, 1)
return
}
}
if (index === -1) {
selectedSubs.value.push(lang)
return
}
}
const addToPlaylist = async () => {
const data = {
episodes: [selectedStartEpisode.value],
dubs: selectedDubs.value,
subs: selectedSubs.value,
dir: path.value
}
const { error } = await useFetch('http://localhost:8080/api/crunchyroll/playlist', {
method: "POST",
body: JSON.stringify(data)
})
if (error.value) {
alert(error.value)
}
}
</script>
<style></style>

70
pages/crunchylogin.vue Normal file
View File

@ -0,0 +1,70 @@
<template>
<div class="h-screen overflow-hidden bg-[#111111] flex flex-col p-5 text-white" style="-webkit-app-region: drag">
<div class="relative flex flex-row items-center justify-center">
<div class="text-2xl">Crunchyroll Login</div>
</div>
<div class="flex flex-col mt-5 gap-3.5 h-full" style="-webkit-app-region: no-drag">
<div class="relative flex flex-col">
<input v-model="username" type="text" name="text" placeholder="Email" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center" />
</div>
<div class="relative flex flex-col">
<input v-model="password" type="password" name="text" placeholder="Password" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center" />
</div>
</div>
<div class="relative flex flex-col mt-auto">
<button @click="login" class="relative py-3 border-2 rounded-xl flex flex-row items-center justify-center" style="-webkit-app-region: no-drag">
<div class="flex flex-row items-center justify-center transition-all" :class="isLoggingIn ? 'opacity-0' : 'opacity-100'">
<div class="text-xl">Login</div>
</div>
<div class="absolute flex flex-row items-center justify-center gap-1 transition-all" :class="isLoggingIn ? 'opacity-100' : 'opacity-0'">
<Icon name="mdi:loading" class="h-6 w-6 animate-spin" />
<div class="text-xl">Logging in</div>
</div>
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { loginAccount } from '~/components/Crunchyroll/Account'
const isProduction = process.env.NODE_ENV !== 'development'
const username = ref<string>()
const password = ref<string>()
const isLoggingIn = ref<number>(0)
const openAddAnime = () => {
(window as any).myAPI.openWindow({
title: "Add Anime",
url: isProduction ? 'http://localhost:8079/addanime' : 'http://localhost:3000/addanime',
width: 700,
height: 400,
backgroundColor: "#111111"
})
}
const login = async () => {
isLoggingIn.value++
if (!username.value) {
isLoggingIn.value--
return
}
if (!password.value) {
isLoggingIn.value--
return
}
const { data, error } = await loginAccount(username.value, password.value)
if (error.value) {
isLoggingIn.value--
return
}
isLoggingIn.value--
openAddAnime()
close()
}
</script>
<style></style>

101
pages/index.vue Normal file
View File

@ -0,0 +1,101 @@
<template>
<div>
<MainHeader />
<div class="flex flex-col text-white">
<div v-for="p in playlist" class="flex flex-row gap-4 h-40 p-5 bg-[#636363]">
<div class="flex min-w-52 w-52">
<img :src="p.media.images.thumbnail[0].find((p) => p.height === 1080)?.source" alt="Image" class="object-cover rounded-xl" />
</div>
<div class="flex flex-col w-full">
<div class="text-sm capitalize">
{{ p.status }}
</div>
<div class="text-base capitalize"> {{ p.media.series_title }} Season {{ p.media.season_number }} Episode {{ p.media.episode_number }} </div>
<div class="relative w-full min-h-5 bg-[#bdbbbb] mt-1 rounded">
<div v-if="p.partsleft && p.status === 'downloading'" class="w-full h-full rounded bg-[#4e422d] transition-all duration-300" :style="`width: calc((${p.partsdownloaded} / ${p.partsleft}) * 100%);`"></div>
<div v-if="p.status === 'completed'" class="w-full h-full rounded bg-[#79ff77] transition-all duration-300"></div>
<div v-if="p.status === 'merging'" class="absolute top-0 w-20 h-full rounded bg-[#293129] transition-all duration-300 loading-a"></div>
</div>
<div class="flex h-full"> </div>
<div class="flex flex-row gap-2 mt-2">
<div class="text-sm">1080p</div>
<div class="text-sm">Dubs: {{ p.dub.map((t) => t.name).join(', ') }}</div>
<div class="text-sm">Subs: {{ p.sub.map((t) => t.name).join(', ') }}</div>
<div v-if="p.partsleft && p.status === 'downloading'" class="text-sm">{{ p.partsdownloaded }}/{{ p.partsleft }}</div>
</div>
</div>
</div> </div
>s
</div>
</template>
<script lang="ts" setup>
import type { CrunchyEpisode } from '~/components/Episode/Types'
const playlist = ref<
Array<{
id: number
status: string
media: CrunchyEpisode
dub: Array<{ locale: string; name: string }>
sub: Array<{ locale: string; name: string }>
dir: string,
partsleft: number,
partsdownloaded: number
}>
>()
const getPlaylist = async () => {
const { data, error } = await useFetch<
Array<{
id: number
status: string
media: CrunchyEpisode
dub: Array<{ locale: string; name: string }>
sub: Array<{ locale: string; name: string }>
dir: string,
partsleft: number,
partsdownloaded: number
}>
>('http://localhost:8080/api/crunchyroll/playlist')
if (error.value) {
alert(error.value)
return
}
if (!data.value) {
return
}
playlist.value = data.value.reverse()
}
onMounted(() => {
getPlaylist()
setInterval(getPlaylist, 1000)
})
</script>
<style>
.loading-a {
animation: animation infinite 3s;
}
@keyframes animation {
0% {
left: 0%;
}
50% {
left: 88%;
}
100% {
left: 0%;
}
}
</style>

31
pages/settings.vue Normal file
View File

@ -0,0 +1,31 @@
<template>
<div
class="h-screen bg-[#111111] flex flex-col p-5 text-white"
style="-webkit-app-region: drag"
>
<div class="flex flex-row items-center justify-center">
<div class="text-2xl">Settings</div>
</div>
<div class="flex flex-row mt-2" style="-webkit-app-region: no-drag">
<button
v-for="(option, index) in options"
@click="activeIndex = index"
class="w-full flex items-center justify-center py-2 border-b-2 transition-all"
:class="
activeIndex === index
? 'border-[#ce6104]'
: 'border-[#ce620428]'
"
>
{{ option }}
</button>
</div>
</div>
</template>
<script lang="ts" setup>
const options = ref(["Main", "Output", "Naming", "Crunchyroll", "About"]);
const activeIndex = ref(0);
</script>
<style></style>

11457
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

2
run.electron.bat Normal file
View File

@ -0,0 +1,2 @@
@echo off
wait-on http://localhost:3000 && electron ./.output/src/electron/background.js

57
src/api/api.ts Normal file
View File

@ -0,0 +1,57 @@
import fastify from "fastify";
import cors from "@fastify/cors";
import NodeCache from "node-cache";
import crunchyrollRoutes from "./routes/crunchyroll/crunchyroll.route";
import { sequelize } from "./db/database";
(async () => {
try {
await sequelize.authenticate();
console.log("Connection has been established successfully.");
} catch (error) {
console.error("Unable to connect to the database:", error);
}
try {
await sequelize.sync();
console.log("All models were synchronized successfully.");
} catch (error) {
console.log("Failed to synchronize Models");
}
})();
const CacheController = new NodeCache({ stdTTL: 100, checkperiod: 120 });
export const server = fastify();
// Cors registration
server.register(cors, {
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
});
// Cache Controller Type
declare module "fastify" {
interface FastifyInstance {
CacheController: NodeCache;
}
}
// Cache Controller
server.decorate("CacheController", CacheController);
// Routes
server.register(crunchyrollRoutes, { prefix: 'api/crunchyroll' })
function startAPI() {
server.listen({ port: 8080 }, (err, address) => {
if (err) {
console.error(err);
return;
}
console.log(`Server is listening on ${address}`);
});
}
export default startAPI;

130
src/api/db/database.ts Normal file
View File

@ -0,0 +1,130 @@
import { app } from 'electron'
import { Sequelize, DataTypes, ModelDefined } from 'sequelize'
import { CrunchyEpisode } from '../types/crunchyroll'
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: app.getPath('documents') + '/db.sqlite'
})
interface AccountAttributes {
id: number
username: string
password: string
service: string
}
interface AccountCreateAttributes {
username: string
password: string
service: string
}
interface AccountCreateAttributes {
username: string
password: string
service: string
}
interface PlaylistAttributes {
id: number
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed'
media: CrunchyEpisode
dub: Array<string>
sub: Array<string>
hardsub: boolean,
dir: string,
partsdownloaded: number,
partsleft: number,
failedreason: string
}
interface PlaylistCreateAttributes {
media: CrunchyEpisode
dub: Array<string>
sub: Array<string>
dir: string
}
const Account: ModelDefined<AccountAttributes, AccountCreateAttributes> = sequelize.define('Accounts', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
username: {
allowNull: false,
type: DataTypes.STRING
},
password: {
allowNull: false,
type: DataTypes.STRING
},
service: {
allowNull: false,
type: DataTypes.STRING
},
createdAt: {
allowNull: false,
type: DataTypes.DATE
},
updatedAt: {
allowNull: false,
type: DataTypes.DATE
}
})
const Playlist: ModelDefined<PlaylistAttributes, PlaylistCreateAttributes> = sequelize.define('Playlist', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER
},
status: {
allowNull: false,
type: DataTypes.STRING,
defaultValue: 'waiting'
},
media: {
allowNull: false,
type: DataTypes.JSON
},
dub: {
allowNull: false,
type: DataTypes.JSON
},
sub: {
allowNull: false,
type: DataTypes.JSON
},
dir: {
allowNull: false,
type: DataTypes.STRING
},
partsdownloaded: {
allowNull: true,
type: DataTypes.BOOLEAN,
defaultValue: 0
},
partsleft: {
allowNull: true,
type: DataTypes.BOOLEAN,
defaultValue: 0
},
failedreason: {
allowNull: true,
type: DataTypes.STRING
},
createdAt: {
allowNull: false,
type: DataTypes.DATE
},
updatedAt: {
allowNull: false,
type: DataTypes.DATE
}
})
export { sequelize, Account, Playlist }

View File

@ -0,0 +1,93 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { crunchyLogin, checkIfLoggedInCR, safeLoginData, addEpisodeToPlaylist, getPlaylist } from './crunchyroll.service'
import { dialog } from 'electron'
import { messageBox } from '../../../electron/background'
import { CrunchyEpisodes, CrunchySeason } from '../../types/crunchyroll'
export async function loginController(request: FastifyRequest, reply: FastifyReply) {
const account = await checkIfLoggedInCR('crunchyroll')
if (!account) {
return reply.code(401).send({ message: 'Not Logged in' })
}
const { data, error } = await crunchyLogin(account.username, account.password)
if (error) {
reply.code(400).send(error)
}
return reply.code(200).send(data)
}
export async function checkLoginController(request: FastifyRequest, reply: FastifyReply) {
const account = await checkIfLoggedInCR('crunchyroll')
if (!account) {
return reply.code(401).send({ message: 'Not Logged in' })
}
return reply.code(200).send({ message: 'Logged in' })
}
export async function loginLoginController(
request: FastifyRequest<{
Body: {
user: string
password: string
}
}>,
reply: FastifyReply
) {
const body = request.body
const account = await checkIfLoggedInCR('crunchyroll')
if (account) {
return reply.code(404).send({ message: 'Already Logged In' })
}
const { data, error } = await crunchyLogin(body.user, body.password)
if (error || !data) {
return reply.code(404).send({
message: 'Invalid Email or Password'
})
}
await safeLoginData(body.user, body.password, 'crunchyroll')
return reply.code(200).send()
}
export async function addPlaylistController(
request: FastifyRequest<{
Body: {
episodes: CrunchyEpisodes
dubs: Array<string>
subs: Array<string>
dir: string
}
}>,
reply: FastifyReply
) {
const body = request.body;
for (const e of body.episodes) {
await addEpisodeToPlaylist(e, body.subs, body.dubs, body.dir)
}
return reply.code(201).send()
}
export async function getPlaylistController(
request: FastifyRequest,
reply: FastifyReply
) {
const playlist = await getPlaylist()
return reply.code(200).send(playlist)
}

View File

@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify'
import { addPlaylistController, checkLoginController, getPlaylistController, loginController, loginLoginController } from './crunchyroll.controller'
async function crunchyrollRoutes(server: FastifyInstance) {
server.post(
'/login',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
loginController
),
server.post(
'/login/login',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
loginLoginController
),
server.get(
'/check',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
checkLoginController
),
server.post(
'/playlist',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
addPlaylistController
)
server.get(
'/playlist',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
getPlaylistController
)
}
export default crunchyrollRoutes

View File

@ -0,0 +1,712 @@
import { messageBox } from '../../../electron/background'
import { server } from '../../api'
import { Account, Playlist } from '../../db/database'
import { CrunchyEpisode, VideoPlaylist } from '../../types/crunchyroll'
import { useFetch } from '../useFetch'
import fs from 'fs'
import path from 'path'
import Ffmpeg from 'fluent-ffmpeg'
import { parse as mpdParse } from 'mpd-parser'
import { parse, stringify } from 'ass-compiler'
import { Readable } from 'stream'
import { finished } from 'stream/promises'
import { v4 as uuidv4 } from 'uuid'
import { app } from 'electron'
var cron = require('node-cron')
const crErrors = [
{
error: 'invalid_grant',
response: 'Email/Password is wrong'
}
]
export async function crunchyLogin(user: string, passw: string) {
const cachedData: {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope: string
country: string
account_id: string
profile_id: string
} | undefined = server.CacheController.get('crtoken')
if (!cachedData) {
var { data, error } = await crunchyLoginFetch(user, passw)
if (error) {
messageBox(
'error',
['Cancel'],
2,
'Failed to login',
'Failed to login to Crunchyroll',
crErrors.find((r) => r.error === (error?.error as string)) ? crErrors.find((r) => r.error === (error?.error as string))?.response : (error.error as string)
)
return { data: null, error: error.error }
}
if (!data) {
messageBox('error', ['Cancel'], 2, 'Failed to login', 'Failed to login to Crunchyroll', 'Crunchyroll returned null')
return { data: null, error: 'Crunchyroll returned null' }
}
if (!data.access_token) {
messageBox('error', ['Cancel'], 2, 'Failed to login', 'Failed to login to Crunchyroll', 'Crunchyroll returned malformed data')
return { data: null, error: 'Crunchyroll returned malformed data' }
}
server.CacheController.set('crtoken', data, data.expires_in - 30)
return { data: data, error: null }
}
return { data: cachedData, error: null }
}
async function crunchyLoginFetch(user: string, passw: string) {
const headers = {
Authorization: 'Basic bm12anNoZmtueW14eGtnN2ZiaDk6WllJVnJCV1VQYmNYRHRiRDIyVlNMYTZiNFdRb3Mzelg=',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Crunchyroll/3.46.2 Android/13 okhttp/4.12.0'
}
const body: any = {
username: user,
password: passw,
grant_type: 'password',
scope: 'offline_access',
device_name: 'RMX2170',
device_type: 'realme RMX2170'
}
const { data, error } = await useFetch<{
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope: string
country: string
account_id: string
profile_id: string
}>('https://beta-api.crunchyroll.com/auth/v1/token', {
type: 'POST',
body: new URLSearchParams(body).toString(),
header: headers,
credentials: 'same-origin'
})
if (error) {
return { data: null, error: error }
}
if (!data) {
return { data: null, error: null }
}
return { data: data, error: null }
}
export async function checkIfLoggedInCR(service: string) {
const login = await Account.findOne({
where: {
service: service
}
})
return login?.get()
}
export async function safeLoginData(user: string, password: string, service: string) {
const login = await Account.create({
username: user,
password: password,
service: service
})
return login?.get()
}
export async function addEpisodeToPlaylist(e: CrunchyEpisode, s: Array<string>, d: Array<string>, dir: string) {
const episode = await Playlist.create({
media: e,
sub: s,
dub: d,
dir: dir
})
return episode.get()
}
export async function getPlaylist() {
const episodes = await Playlist.findAll()
return episodes
}
export async function updatePlaylistByID(id: number, status: 'waiting' | 'preparing' | 'downloading' | 'completed' | 'merging' | 'failed') {
await Playlist.update({ status: status }, { where: { id: id } })
}
export async function updatePlaylistToDownloadPartsByID(id: number, parts: number) {
await Playlist.update({ partsleft: parts }, { where: { id: id } })
}
export async function updatePlaylistToDownloadedPartsByID(id: number, parts: number) {
await Playlist.update({ partsdownloaded: parts }, { where: { id: id } })
}
async function checkPlaylists() {
const episodes = await Playlist.findAll({ where: { status: 'waiting' } })
for (const e of episodes) {
await updatePlaylistByID(e.dataValues.id, 'preparing')
await downloadPlaylist(e.dataValues.media.id, e.dataValues.dub, e.dataValues.sub, e.dataValues.hardsub, e.dataValues.id)
}
}
cron.schedule('* * * * * *', () => {
checkPlaylists()
})
export async function crunchyGetPlaylist(q: string) {
const account = await checkIfLoggedInCR('crunchyroll')
if (!account) return
const { data, error } = await crunchyLogin(account.username, account.password)
if (!data) return
const headers = {
Authorization: `Bearer ${data.access_token}`,
'X-Cr-Disable-Drm': 'true'
}
const query: any = {
q: q,
n: 100,
type: 'series',
ratings: false,
locale: 'de-DE'
}
try {
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}/console/switch/play`, {
method: 'GET',
headers: headers
})
if (response.ok) {
const data: VideoPlaylist = JSON.parse(await response.text())
data.hardSubs = Object.values((data as any).hardSubs)
data.subtitles = Object.values((data as any).subtitles)
return data
} else {
throw new Error(await response.text())
}
} catch (e) {
throw new Error(e as string)
}
}
async function createFolder() {
const tempFolderPath = path.join(app.getPath('documents'), (Math.random() + 1).toString(36).substring(2))
try {
await fs.promises.mkdir(tempFolderPath, { recursive: true })
return tempFolderPath
} catch (error) {
console.error('Error creating temporary folder:', error)
throw error
}
}
async function deleteFolder(folderPath: string) {
fs.rmSync(folderPath, { recursive: true, force: true })
}
export async function crunchyGetPlaylistMPD(q: string) {
const account = await checkIfLoggedInCR('crunchyroll')
if (!account) return
const { data, error } = await crunchyLogin(account.username, account.password)
if (!data) return
const headers = {
Authorization: `Bearer ${data.access_token}`,
'X-Cr-Disable-Drm': 'true'
}
try {
const response = await fetch(q, {
method: 'GET',
headers: headers
})
if (response.ok) {
const parsed = mpdParse(await response.text())
return parsed
} else {
throw new Error(await response.text())
}
} catch (e) {
throw new Error(e as string)
}
}
export async function downloadPlaylist(e: string, dubs: Array<string>, subs: Array<string>, hardsub: boolean, downloadID: number) {
var playlist = await crunchyGetPlaylist(e)
if (!playlist) return
if (playlist.audioLocale !== subs[0]) {
const found = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
if (found) {
playlist = await crunchyGetPlaylist(found.guid)
}
}
if (!playlist) return
const subFolder = await createFolder()
const audioFolder = await createFolder()
const dubDownloadList: Array<{
audio_locale: string
guid: string
is_premium_only: boolean
media_guid: string
original: boolean
season_guid: string
variant: string
}> = []
const subDownloadList: Array<{
format: string
language: string
url: string
}> = []
for (const s of subs) {
const found = playlist.subtitles.find((sub) => sub.language === s)
if (found) {
subDownloadList.push(found)
console.log(`Subtitle ${s}.ass found, adding to download`)
} else {
console.warn(`Subtitle ${s}.ass not found, skipping`)
}
}
for (const d of dubs) {
const found = playlist.versions.find((p) => p.audio_locale === d)
if (found) {
dubDownloadList.push(found)
console.log(`Audio ${d}.aac found, adding to download`)
} else {
console.warn(`Audio ${d}.aac not found, skipping`)
}
}
if (dubDownloadList.length === 0) {
const jpVersion = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
if (jpVersion) {
console.log('Using ja-JP Audio because no Audio in download list')
dubDownloadList.push(jpVersion)
}
}
const subDownload = async () => {
const sbs: Array<string> = []
for (const sub of subDownloadList) {
const name = await downloadSub(sub, subFolder)
sbs.push(name)
}
return sbs
}
const audioDownload = async () => {
const audios: Array<string> = []
for (const v of dubDownloadList) {
const list = await crunchyGetPlaylist(v.guid)
if (!list) return
const playlist = await crunchyGetPlaylistMPD(list.url)
if (!playlist) return
var p: { filename: string; url: string }[] = []
p.push({
filename: (playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
url: playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].map.resolvedUri
})
for (const s of playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments) {
p.push({
filename: (s.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
url: s.resolvedUri
})
}
const path = await downloadAudio(p, audioFolder, list.audioLocale)
audios.push(path as string)
}
return audios
}
const downloadVideo = async () => {
var code
if (!playlist) return
if (playlist.versions.find((p) => p.audio_locale === dubs[0])) {
code = playlist.versions.find((p) => p.audio_locale === dubs[0])?.guid
} else {
code = playlist.versions.find((p) => p.audio_locale === 'ja-JP')?.guid
}
if (!code) return console.error('No clean stream found')
const play = await crunchyGetPlaylist(code)
if (!play) return
var mdp = await crunchyGetPlaylistMPD(play.url)
if (hardsub) {
const findjpplaylist = playlist.versions.find((p) => p.audio_locale === 'ja-JP')?.guid
if (!findjpplaylist) return
const hsplaylist = await crunchyGetPlaylist(findjpplaylist)
if (!hsplaylist) return
const hsurl = hsplaylist.hardSubs.find((h) => h.hlang === subs[0])
if (hsurl) {
mdp = await crunchyGetPlaylistMPD(hsurl.url)
}
}
if (!mdp) return
var hq = mdp.playlists.find((i) => i.attributes.RESOLUTION?.width === 1920)
if (!hq) return
var p: { filename: string; url: string }[] = []
p.push({
filename: (hq.segments[0].map.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
url: hq.segments[0].map.resolvedUri
})
for (const s of hq.segments) {
p.push({
filename: (s.uri.match(/([^\/]+)\?/) as RegExpMatchArray)[1],
url: s.resolvedUri
})
}
await updatePlaylistByID(downloadID, "downloading")
await updatePlaylistToDownloadPartsByID(downloadID, p.length)
const file = await downloadParts(p, downloadID)
return file
}
const [subss, audios, file] = await Promise.all([subDownload(), audioDownload(), downloadVideo()])
if (!audios) return
await updatePlaylistByID(downloadID, "merging")
await mergeFile(file as string, audios, subss, String(playlist.assetId))
await deleteFolder(subFolder)
await deleteFolder(audioFolder)
await updatePlaylistByID(downloadID, "completed")
return playlist
}
async function downloadAudio(parts: { filename: string; url: string }[], dir: string, name: string) {
const path = await createFolder()
const downloadPromises = []
for (const [index, part] of parts.entries()) {
const stream = fs.createWriteStream(`${path}/${part.filename}`)
const downloadPromise = fetchAndPipe(part.url, stream, index + 1)
downloadPromises.push(downloadPromise)
}
await Promise.all(downloadPromises)
return await mergePartsAudio(parts, path, dir, name)
}
async function fetchAndPipe(url: string, stream: fs.WriteStream, index: number) {
const { body } = await fetch(url)
const readableStream = Readable.from(body as any)
return new Promise<void>((resolve, reject) => {
readableStream
.pipe(stream)
.on('finish', () => {
console.log(`Fragment ${index} downloaded`)
resolve()
})
.on('error', (error) => {
reject(error)
})
})
}
async function downloadParts(parts: { filename: string; url: string }[], downloadID: number) {
var partsdownloaded = 0
const path = await createFolder()
for (const [index, part] of parts.entries()) {
const stream = fs.createWriteStream(`${path}/${part.filename}`)
const { body } = await fetch(part.url)
await finished(Readable.fromWeb(body as any).pipe(stream))
console.log(`Fragment ${index + 1} downloaded`)
partsdownloaded++
updatePlaylistToDownloadedPartsByID(downloadID, partsdownloaded)
}
return await mergeParts(parts, path)
}
async function downloadSub(
sub: {
format: string
language: string
url: string
},
dir: string
) {
const path = `${dir}/${sub.language}.${sub.format}`
const stream = fs.createWriteStream(path)
const response = await fetch(sub.url)
var parsedASS = parse(await response.text())
// Disabling Changing ASS because still broken in vlc
// parsedASS.info.PlayResX = "1920";
// parsedASS.info.PlayResY = "1080";
// for (const s of parsedASS.styles.style) {
// (s.Fontsize = "54"), (s.Outline = "4");
// }
const fixed = stringify(parsedASS)
const readableStream = Readable.from([fixed])
await finished(readableStream.pipe(stream))
console.log(`Sub ${sub.language}.${sub.format} downloaded`)
return path
}
async function concatenateTSFiles(inputFiles: Array<string>, outputFile: string) {
return new Promise<void>((resolve, reject) => {
const writeStream = fs.createWriteStream(outputFile)
writeStream.on('error', (error) => {
reject(error)
})
writeStream.on('finish', () => {
console.log('TS files concatenated successfully!')
resolve()
})
const processNextFile = (index: number) => {
if (index >= inputFiles.length) {
writeStream.end()
return
}
const readStream = fs.createReadStream(inputFiles[index])
readStream.on('error', (error) => {
reject(error)
})
readStream.pipe(writeStream, { end: false })
readStream.on('end', () => {
processNextFile(index + 1)
})
}
processNextFile(0)
})
}
async function mergeParts(parts: { filename: string; url: string }[], tmp: string) {
const tempname = (Math.random() + 1).toString(36).substring(2)
try {
const list: Array<string> = []
for (const [index, part] of parts.entries()) {
list.push(`${tmp}/${part.filename}`)
}
const concatenatedFile = `${tmp}/main.m4s`
await concatenateTSFiles(list, concatenatedFile)
return new Promise((resolve, reject) => {
Ffmpeg()
.input(concatenatedFile)
.outputOptions('-c copy')
.save(app.getPath('documents') + `/${tempname}.mp4`)
.on('end', async () => {
console.log('Merging finished')
await deleteFolder(tmp)
return resolve(app.getPath('documents') + `/${tempname}.mp4`)
})
})
} catch (error) {
console.error('Error merging parts:', error)
}
}
async function mergePartsAudio(parts: { filename: string; url: string }[], tmp: string, dir: string, name: string) {
try {
const list: Array<string> = []
for (const [index, part] of parts.entries()) {
list.push(`${tmp}/${part.filename}`)
}
const concatenatedFile = `${tmp}/main.m4s`
await concatenateTSFiles(list, concatenatedFile)
return new Promise((resolve, reject) => {
Ffmpeg()
.input(concatenatedFile)
.outputOptions('-c copy')
.save(`${dir}/${name}.aac`)
.on('end', async () => {
console.log('Merging finished')
await deleteFolder(tmp)
return resolve(`${dir}/${name}.aac`)
})
})
} catch (error) {
console.error('Error merging parts:', error)
}
}
async function mergeFile(video: string, audios: Array<string>, subs: Array<string>, name: string) {
const locales: Array<{
locale: string
name: string
iso: string
title: string
}> = [
{ locale: 'ja-JP', name: 'JP', iso: 'jpn', title: 'Japanese' },
{ locale: 'de-DE', name: 'DE', iso: 'deu', title: 'German' },
{ locale: 'hi-IN', name: 'HI', iso: 'hin', title: 'Hindi' },
{ locale: 'ru-RU', name: 'RU', iso: 'rus', title: 'Russian' },
{ locale: 'en-US', name: 'EN', iso: 'eng', title: 'English' },
{ locale: 'fr-FR', name: 'FR', iso: 'fra', title: 'French' },
{ locale: 'pt-BR', name: 'PT', iso: 'por', title: 'Portugese' },
{ locale: 'es-419', name: 'LA-ES', iso: 'spa', title: 'SpanishLatin' },
{ locale: 'en-IN', name: 'EN-IN', iso: 'eng', title: 'IndianEnglish' },
{ locale: 'it-IT', name: 'IT', iso: 'ita', title: 'Italian' },
{ locale: 'es-ES', name: 'ES', iso: 'spa', title: 'Spanish' },
{ locale: 'ta-IN', name: 'TA', iso: 'tam', title: 'Tamil' },
{ locale: 'te-IN', name: 'TE', iso: 'tel', title: 'Telugu' },
{ locale: 'ar-SA', name: 'AR', iso: 'ara', title: 'ArabicSA' },
{ locale: 'ms-MY', name: 'MS', iso: 'msa', title: 'Malay' },
{ locale: 'th-TH', name: 'TH', iso: 'tha', title: 'Thai' },
{ locale: 'vi-VN', name: 'VI', iso: 'vie', title: 'Vietnamese' },
{ locale: 'id-ID', name: 'ID', iso: 'ind', title: 'Indonesian' },
{ locale: 'ko-KR', name: 'KO', iso: 'kor', title: 'Korean' }
]
return new Promise((resolve, reject) => {
var output = Ffmpeg()
var ffindex = 1
output.addInput(video)
var options = ['-c copy', '-map 0']
for (const [index, a] of audios.entries()) {
output.addInput(a)
options.push(`-map ${ffindex}:a:0`)
options.push(
`-metadata:s:a:${index} language=${
locales.find((l) => l.locale === a.split('/')[1].split('.aac')[0])
? locales.find((l) => l.locale === a.split('/')[1].split('.aac')[0])?.iso
: a.split('/')[1].split('.aac')[0]
}`
)
ffindex++
// Somehow not working
// options.push(
// `-metadata:s:a:${index} language="${
// locales.find(
// (l) => l.locale === a.split("/")[1].split(".aac")[0]
// ) ? locales.find(
// (l) => l.locale === a.split("/")[1].split(".aac")[0]
// )?.title : a.split("/")[1].split(".aac")[0]
// }"`
// );
}
if (subs) {
for (const [index, s] of subs.entries()) {
output.addInput(s)
options.push(`-map ${ffindex}:s`)
options.push(
`-metadata:s:s:${index} language=${
locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])?.iso
: s.split('/')[1].split('.ass')[0]
}`
)
ffindex++
}
}
output
.addOptions(options)
.saveToFile(app.getPath('documents') + `/${name}.mkv`)
.on('error', (error) => {
console.log(error)
reject(error)
})
.on('end', async () => {
console.log('Download finished')
return resolve('combined')
})
})
}

View File

@ -0,0 +1,38 @@
type ErrorType = {
error: string
} | null
export async function useFetch<T>(
url: string,
options: {
type: 'GET' | 'PUT' | 'POST' | 'DELETE'
header: HeadersInit
body: BodyInit
query?: { [key: string]: string }
credentials?: RequestCredentials
}
): Promise<{ data: T | null; error: ErrorType }> {
const querystring = new URLSearchParams(options.query)
const raw = await fetch(`${url}${querystring ? querystring : ''}`, {
method: options.type,
headers: options.header,
body: options.body,
credentials: options.credentials
})
if (!raw.ok) {
const errorText = await raw.text()
let errorData: ErrorType = null
try {
errorData = JSON.parse(errorText)
} catch (error) {
console.error('Error parsing error text:', error)
}
return { data: null, error: errorData }
}
const data = await raw.json()
return { data: data, error: null }
}

View File

@ -0,0 +1,146 @@
export interface CrunchySeason {
identifier: string
description: string
is_simulcast: boolean
subtitle_locales: Array<string>
series_id: string
id: string
audio_locales: Array<string>
title: string
versions: Array<{
audio_locale: string
guid: string
original: boolean
variant: string
}>
season_sequence_number: number
season_number: number
maturity_ratings: Array<string>
mature_blocked: boolean
channel_id: string
is_subbed: boolean
audio_locale: string
season_display_number: string
is_complete: boolean
season_tags: Array<string>
is_mature: boolean
is_dubbed: boolean
slug_title: string
availability_notes: string
number_of_episodes: boolean
}
export interface CrunchySeasons extends Array<CrunchySeason> {}
export interface CrunchyEpisode {
closed_captions_available: boolean
availability_notes: string
next_episode_title: string
upload_date: string
versions: Array<{
audio_locale: string
guid: string
is_premium_only: boolean
media_guid: string
original: boolean
season_guid: string
variant: string
}>
season_slug_title: string
series_title: string
season_title: string
sequence_number: number
maturity_ratings: Array<string>
slug_title: string
is_premium_only: boolean
availability_ends: string
identifier: string
recent_variant: string
free_available_date: string
subtitle_locales: Array<string>
series_id: string
mature_blocked: boolean
duration_ms: number
availability_starts: string
audio_locale: string
images: {
thumbnail: Array<
Array<{
height: number
source: string
type: string
width: number
}>
>
}
season_sequence_number: number
season_id: string
episode_number: number
listing_id: string
available_date: string
channel_id: string
season_number: number
hd_flag: boolean
recent_audio_locale: string
available_offline: boolean
episode: string
is_subbed: boolean
media_type: string
is_clip: boolean
title: string
streams_link: string
slug: string
id: string
production_episode_id: string
is_dubbed: boolean
next_episode_id: string
series_slug_title: string
season_tags: Array<string>
premium_date: string
is_mature: boolean
premium_available_date: string
description: string
episode_air_date: string
eligible_region: string
}
export interface CrunchyEpisodes extends Array<CrunchyEpisode> {}
export interface VideoPlaylist {
assetId: number
audioLocale: string
bifs: string
burnedInLocale: string
captions: string
hardSubs: Array<{
hlang: string
url: string
quality: string
}>
playbackType: string
session: {
renewSeconds: number
noNetworkRetryIntervalSeconds: number
noNetworkTimeoutSeconds: number
maximumPauseSeconds: number
endOfVideoUnloadSeconds: number
sessionExpirationSeconds: number
usesStreamLimits: boolean
}
subtitles: Array<{
format: string
language: string
url: string
}>
token: string
url: string
versions: Array<{
audio_locale: string
guid: string
is_premium_only: boolean
media_guid: string
original: boolean
season_guid: string
variant: string
}>
}

71
src/api/types/parser.ts Normal file
View File

@ -0,0 +1,71 @@
declare module 'mpd-parser' {
export type Segment = {
uri: string
timeline: number
duration: number
resolvedUri: string
map: {
uri: string
resolvedUri: string
}
number: number
presentationTime: number
}
export type Playlist = {
attributes: {
NAME: string
BANDWIDTH: number
CODECS: string
'PROGRAM-ID': number
// Following for video only
'FRAME-RATE'?: number
AUDIO?: string // audio stream name
SUBTITLES?: string
RESOLUTION?: {
width: number
height: number
}
}
uri: string
endList: boolean
timeline: number
resolvedUri: string
targetDuration: number
discontinuitySequence: number
discontinuityStarts: []
timelineStarts: {
start: number
timeline: number
}[]
mediaSequence: number
contentProtection?: {
[type: string]: {
pssh?: Uint8Array
}
}
segments: Segment[]
}
export type Manifest = {
allowCache: boolean
discontinuityStarts: []
segments: []
endList: true
duration: number
playlists: Playlist[]
mediaGroups: {
AUDIO: {
audio: {
[name: string]: {
language: string
autoselect: boolean
default: boolean
playlists: Playlist[]
}
}
}
}
}
export function parse(manifest: string): Manifest
}

208
src/electron/background.ts Normal file
View File

@ -0,0 +1,208 @@
import * as path from 'path'
import * as os from 'os'
import { app, BrowserWindow, dialog, ipcMain, session } from 'electron'
import singleInstance from './singleInstance'
import dynamicRenderer from './dynamicRenderer'
import titleBarActionsModule from './modules/titleBarActions'
import updaterModule from './modules/updater'
import macMenuModule from './modules/macMenu'
import startAPI from '../api/api'
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'
const isProduction = process.env.NODE_ENV !== 'development'
const platform: 'darwin' | 'win32' | 'linux' = process.platform as any
const architucture: '64' | '32' = os.arch() === 'x64' ? '64' : '32'
const headerSize = 32
const modules = [titleBarActionsModule, macMenuModule, updaterModule]
function createWindow() {
console.log('System info', { isProduction, platform, architucture })
const mainWindow = new BrowserWindow({
title: 'Crunchyroll Downloader',
icon: __dirname + '/icon/favicon.ico',
width: 950,
height: 700,
backgroundColor: '#111111',
webPreferences: {
devTools: true,
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#111111',
symbolColor: '#ffffff',
height: 40
},
resizable: false
})
mainWindow.webContents.setWindowOpenHandler(() => {
return {
action: 'allow',
overrideBrowserWindowOptions: {
icon: __dirname + '/icon/favicon.ico',
backgroundColor: '#111111',
webPreferences: {
devTools: true,
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#111111',
symbolColor: '#ffffff',
height: 40
},
resizable: false
}
}
})
// Lock app to single instance
if (singleInstance(app, mainWindow)) return
// Open the DevTools.
!isProduction &&
mainWindow.webContents.openDevTools({
mode: 'bottom'
})
return mainWindow
}
// App events
// ==========
app.whenReady().then(async () => {
if (!isProduction) {
try {
await session.defaultSession.loadExtension(path.join(__dirname, '../..', '__extensions', 'vue-devtools'))
} catch (err) {
console.log('[Electron::loadExtensions] An error occurred: ', err)
}
}
startAPI()
const mainWindow = createWindow()
if (!mainWindow) return
// Load renderer process
dynamicRenderer(mainWindow)
// Initialize modules
console.log('-'.repeat(30) + '\n[+] Loading modules...')
modules.forEach((module) => {
try {
module(mainWindow)
} catch (err: any) {
console.log('[!] Module error: ', err.message || err)
}
})
console.log('[!] Loading modules: Done.' + '\r\n' + '-'.repeat(30))
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
// if (BrowserWindow.getAllWindows().length === 0) createWindow()
mainWindow.show()
})
})
export async function messageBox(
type: 'none' | 'info' | 'error' | 'question' | 'warning' | undefined,
buttons: Array<'Cancel'>,
defaultId: number,
title: string,
message: string,
detail: string | undefined
) {
const options = {
type: type as 'none' | 'info' | 'error' | 'question' | 'warning' | undefined,
buttons: buttons,
defaultId: defaultId,
title: title,
message: message,
detail: detail
}
const response = dialog.showMessageBox(options)
console.log(response)
}
ipcMain.handle('dialog:openDirectory', async () => {
const window = BrowserWindow.getFocusedWindow()
if (!window) {
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(window, {
properties: ['openDirectory']
})
if (canceled) {
return
} else {
return filePaths[0]
}
})
ipcMain.handle('dialog:defaultDirectory', async () => {
const path = app.getPath('documents')
return path
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// Open New Window
ipcMain.handle(
'window:openNewWindow',
async (
events,
opt: {
title: string
url: string
width: number
height: number
backgroundColor: string
}
) => {
const mainWindow = new BrowserWindow({
title: opt.title,
icon: __dirname + '/icon/favicon.ico',
width: opt.width,
height: opt.height,
backgroundColor: opt.backgroundColor,
webPreferences: {
devTools: true,
nodeIntegration: true,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#111111',
symbolColor: '#ffffff',
height: 40
},
resizable: false
})
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.show();
});
mainWindow.loadURL(opt.url)
}
)

View File

@ -0,0 +1,23 @@
// This is the dynamic renderer script for Electron.
// You can implement your custom renderer process configuration etc. here!
// --------------------------------------------
import * as path from 'path'
import { BrowserWindow } from 'electron'
import express, { static as serveStatic } from 'express'
// Internals
// =========
const isProduction = process.env.NODE_ENV !== 'development'
// Dynamic Renderer
// ================
export default function (mainWindow: BrowserWindow) {
if (!isProduction) return mainWindow.loadURL('http://localhost:3000/')
const app = express()
app.use('/', serveStatic(path.join(__dirname, '../../public')))
const listener = app.listen(8079, 'localhost', () => {
const port = (listener.address() as any).port
console.log('Dynamic-Renderer Listening on', port)
mainWindow.loadURL(`http://localhost:${port}`)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,60 @@
import { app, BrowserWindow, Menu } from 'electron'
// Helpers
// =======
const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = []
// Module
// ======
export default (mainWindow: BrowserWindow) => {
const isDevelopment = process.env.NODE_ENV === 'development'
if (process.platform === 'darwin') {
// OS X
const name = 'electron-nuxt3'
template.unshift({
label: name,
submenu: [
{
label: 'About ' + name,
role: 'about'
},
{
label: 'Quit',
accelerator: 'Command+Q',
click() {
app.quit()
}
},
{
label: 'Reload',
accelerator: 'Command+R',
click() {
// Reload the current window
if (mainWindow) {
mainWindow.reload()
}
}
},
...(isDevelopment
? [
{
label: 'Toggle Developer Tools',
accelerator: 'Alt+Command+I',
click() {
// Open the DevTools.
if (mainWindow) {
mainWindow.webContents.toggleDevTools()
}
}
}
]
: [])
]
})
const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
console.log('[-] MODULE::macMenu Initialized')
}
}

View File

@ -0,0 +1,50 @@
import { ipcMain, BrowserWindow } from 'electron'
// Helpers
// =======
const getWindowFromEvent = (event: Electron.IpcMainInvokeEvent) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
return win
}
// Module
// ======
export default (mainWindow: BrowserWindow) => {
ipcMain.handle('isMaximized:app', (event) => {
const win = getWindowFromEvent(event)
return win?.isMaximized()
})
ipcMain.handle('titlebar:action', (event, action: 'toggleMaximize' | 'minimize') => {
const win = getWindowFromEvent(event)
if (!win) return
switch (action) {
case 'toggleMaximize':
win.isMaximized() ? win.unmaximize() : win.maximize()
break
case 'minimize':
win.minimize()
break
}
})
ipcMain.handle('close:app', (event) => {
const win = getWindowFromEvent(event)
if (!win) return
win.close()
})
ipcMain.handle('get:windowVisible', (_event) => {
return mainWindow.isVisible()
})
mainWindow.on('maximize', () => mainWindow.webContents.send('window:maximizeChanged', true))
mainWindow.on('unmaximize', () => mainWindow.webContents.send('window:maximizeChanged', false))
mainWindow.on('enter-full-screen', () => mainWindow.webContents.send('window:fullscreenChanged', true))
mainWindow.on('leave-full-screen', () => mainWindow.webContents.send('window:fullscreenChanged', false))
console.log('[-] MODULE::titleBarActions Initialized')
}
// https://www.electronjs.org/docs/latest/tutorial/ipc

View File

@ -0,0 +1,71 @@
import { BrowserWindow, ipcMain } from 'electron'
import { autoUpdater } from 'electron-updater'
import log from 'electron-log'
// Logger
// ======
autoUpdater.logger = log
;(autoUpdater.logger as typeof log).transports.file.level = 'info'
// Config
// ======
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
// Module
// ======
export default (mainWindow: BrowserWindow) => {
const isMac = process.platform === 'darwin'
if (isMac) {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = false
}
// Helpers
// =======
let readyToInstall = false
function sendUpdaterStatus(...args: any[]) {
mainWindow.webContents.send('updater:statusChanged', args)
}
autoUpdater.on('checking-for-update', () => {
sendUpdaterStatus('check-for-update')
})
autoUpdater.on('update-available', (_info) => {
sendUpdaterStatus('update-available')
})
autoUpdater.on('update-not-available', (_info) => {
sendUpdaterStatus('update-not-available')
})
autoUpdater.on('error', (_err) => {
sendUpdaterStatus('update-error')
})
autoUpdater.on('download-progress', (progress) => {
sendUpdaterStatus('downloading', progress)
})
autoUpdater.on('update-downloaded', (_info) => {
sendUpdaterStatus('update-downloaded')
mainWindow.webContents.send('updater:readyToInstall')
readyToInstall = true
})
// IPC Listeners
// =============
ipcMain.handle('updater:check', async (_event) => {
return await autoUpdater.checkForUpdates()
})
ipcMain.handle('updater:quitAndInstall', (_event) => {
if (!readyToInstall) return
autoUpdater.quitAndInstall()
})
autoUpdater.checkForUpdates()
// Check for updates every 2 hours
setInterval(() => {
autoUpdater.checkForUpdates()
}, 1000 * 60 * 60 * 2)
console.log('[-] MODULE::updater Initialized')
}

24
src/electron/preload.ts Normal file
View File

@ -0,0 +1,24 @@
// This is the preload script for Electron.
// It runs in the renderer process before the page is loaded.
// --------------------------------------------
// import { contextBridge } from 'electron'
// process.once('loaded', () => {
// - Exposed variables will be accessible at "window.versions".
// contextBridge.exposeInMainWorld('versions', process.env)
// })
import {contextBridge, ipcRenderer} from 'electron'
contextBridge.exposeInMainWorld('myAPI', {
selectFolder: () => ipcRenderer.invoke('dialog:openDirectory'),
getFolder: () => ipcRenderer.invoke('dialog:defaultDirectory'),
openWindow: (opt: {
title: string,
url: string,
width: number,
height: number,
backgroundColor: string
}) => ipcRenderer.invoke('window:openNewWindow', opt),
})

View File

@ -0,0 +1,23 @@
import { App, BrowserWindow } from 'electron'
export default (app: App, win: BrowserWindow) => {
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
return true
}
app.on('second-instance', (_, _argv) => {
if (win) {
win.show()
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.on('open-url', function (event, url) {
event.preventDefault()
win.webContents.send('deeplink', url)
})
}

103
src/tsconfig.json Normal file
View File

@ -0,0 +1,103 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
"declaration": false, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "../.output/src" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

9
tailwind.config.ts Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {},
},
plugins: [],
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"types": [
"@nuxtjs/i18n",
]
}
}

10543
yarn.lock Normal file

File diff suppressed because it is too large Load Diff