added format selector and more adn stuff

This commit is contained in:
Daniel Haller 2024-04-22 03:12:21 +02:00
parent 195b7aee15
commit 8ece48d52a
18 changed files with 1496 additions and 1286 deletions

View File

@ -8,16 +8,16 @@ export async function crunchyLogin() {
return { data, error }
}
export async function checkAccount() {
const { data, error } = await useFetch<CrunchyLogin>('http://localhost:8080/api/crunchyroll/check', {
export async function checkAccount(service: string) {
const { data, error } = await useFetch<CrunchyLogin>(`http://localhost:8080/api/service/check/${service}`, {
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', {
export async function loginAccount(user: string, password: string, service: string) {
const { data, error } = await useFetch<CrunchyLogin>(`http://localhost:8080/api/service/login/${service}`, {
method: 'POST',
body: {
user: user,

View File

@ -39,18 +39,18 @@ async function openSettings() {
}
async function openAddAnime() {
const { data, error } = await checkAccount()
// 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
}
// 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",

View File

@ -15,11 +15,11 @@
<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>
<option value="adn">ADN</option>
</select>
</div>
<div class="relative flex flex-col">
<div v-if="isLoggedInCR && service === 'crunchyroll' || !isLoggedInCR && service === 'adn'" class="relative flex flex-col">
<input
v-model="search"
@input="handleInputChange"
@ -61,10 +61,10 @@
</button>
</div>
</div>
<div class="relative flex flex-col">
<div v-if="isLoggedInCR && service === 'crunchyroll'" 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">
<div v-if="isLoggedInCR && service === 'crunchyroll'" class="relative flex flex-col">
<input
@click="getFolderPath()"
v-model="path"
@ -75,6 +75,11 @@
readonly
/>
</div>
<div v-if="!isLoggedInCR && service === 'crunchyroll'" class="relative flex flex-col">
<button @click="openCRLogin"
class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer"
>Click to Login</button>
</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'">
@ -165,7 +170,7 @@
</button>
</div>
</div>
<div class="flex flex-row gap-5">
<div class="flex flex-row gap-3">
<div class="relative flex flex-col w-full">
<select v-model="hardsub" name="episode" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer" :disabled="isHardsubDisabled">
<option :value="false" class="text-sm text-slate-200">Hardsub: false</option>
@ -188,6 +193,12 @@
<option :value="240" class="text-sm text-slate-200">240p</option>
</select>
</div>
<div class="relative flex flex-col w-full">
<select v-model="format" name="format" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option value="mp4" class="text-sm text-slate-200">MP4</option>
<option value="mkv" class="text-sm text-slate-200">MKV</option>
</select>
</div>
</div>
<!-- {{ CRselectedShow?.Subs.map(s=> { return locales.find(l => l.locale === s)?.name }) }}
@ -226,6 +237,7 @@
<script lang="ts" setup>
import { searchADN } from '~/components/ADN/ListAnimes'
import { checkAccount } from '~/components/Crunchyroll/Account'
import { getCRSeries, searchCrunchy } from '~/components/Crunchyroll/ListAnimes'
import { listEpisodeCrunchy } from '~/components/Crunchyroll/ListEpisodes'
import { listSeasonCrunchy } from '~/components/Crunchyroll/ListSeasons'
@ -257,6 +269,7 @@ const locales = ref<Array<{ locale: string; name: string }>>([
{ locale: 'ko-KR', name: 'KO' }
])
const isProduction = process.env.NODE_ENV !== 'development'
const selectDub = ref<boolean>(false)
const selectedDubs = ref<Array<{ name: string | undefined; locale: string }>>([{ locale: 'ja-JP', name: 'JP' }])
@ -282,11 +295,44 @@ const hardsub = ref<boolean>(false)
const added = ref<boolean>(false)
const isHardsubDisabled = ref<boolean>(true)
const quality = ref<1080 | 720 | 480 | 360 | 240>(1080)
const format = ref<'mp4' | 'mkv'>('mkv')
const isFetchingSeasons = ref<number>(0)
const isFetchingEpisodes = ref<number>(0)
const isFetchingResults = ref<number>(0)
const isLoggedInCR = ref<boolean>(false)
let interval: NodeJS.Timeout;
const checkIfLoggedInCR = async () => {
const { data, error } = await checkAccount('CR')
if (error.value) {
isLoggedInCR.value = false
return
}
if (interval) {
clearInterval(interval)
}
isLoggedInCR.value = true
}
const openCRLogin = () => {
(window as any).myAPI.openWindow({
title: "Crunchyroll Login",
url: isProduction ? 'http://localhost:8079/crunchylogin' : 'http://localhost:3000/crunchylogin',
width: 600,
height: 300,
backgroundColor: "#111111"
})
interval = setInterval(checkIfLoggedInCR, 1000)
}
checkIfLoggedInCR()
const fetchSearch = async () => {
if (!search.value || search.value.length === 0) {
adnSearchResults.value = []
@ -343,7 +389,7 @@ const selectShow = async (show: any) => {
if (service.value === 'crunchyroll') {
CRselectedShow.value = show
url.value = show.Url
url.value = show.Url + '/'
}
search.value = ''
@ -471,6 +517,18 @@ const addToPlaylist = async () => {
return
}
var selService
if (service.value === 'crunchyroll') {
selService = 'CR'
}
if (service.value === 'adn') {
selService = 'ADN'
}
if (!selService) return
const selectedEpisodes = episodes.value.slice(startEpisodeIndex, endEpisodeIndex + 1)
const data = {
@ -479,10 +537,12 @@ const addToPlaylist = async () => {
subs: selectedSubs.value,
dir: path.value,
hardsub: hardsub.value,
quality: quality.value
quality: quality.value,
service: selService,
format: format.value
}
const { error } = await useFetch('http://localhost:8080/api/crunchyroll/playlist', {
const { error } = await useFetch('http://localhost:8080/api/service/playlist', {
method: 'POST',
body: JSON.stringify(data)
})

View File

@ -34,16 +34,6 @@ 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: 450,
backgroundColor: "#111111"
})
}
const login = async () => {
isLoggingIn.value++
if (!username.value) {
@ -55,14 +45,13 @@ const login = async () => {
return
}
const { data, error } = await loginAccount(username.value, password.value)
const { data, error } = await loginAccount(username.value, password.value, 'CR')
if (error.value) {
isLoggingIn.value--
return
}
isLoggingIn.value--
openAddAnime()
close()
}
</script>

View File

@ -10,9 +10,14 @@
<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="flex flex-row">
<div class="text-sm capitalize">
{{ p.status }}
</div>
<div class="text-sm capitalize ml-auto">
{{ p.service === 'CR' ? 'Crunchyroll' : 'ADN' }}
</div>
</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
@ -26,9 +31,10 @@
<div class="flex h-full"> </div>
<div class="flex flex-row gap-2 mt-2">
<div class="text-sm">{{ p.quality }}p</div>
<div class="text-sm uppercase">{{ p.format }}</div>
<div class="text-sm">Dubs: {{ p.dub.map((t) => t.name).join(', ') }}</div>
<div class="text-sm">Subs: {{ p.sub.length !== 0 ? 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 v-if="p.partsleft && p.status === 'downloading'" class="text-sm ml-auto">{{ p.partsdownloaded }}/{{ p.partsleft }}</div>
<div v-if="p.downloadspeed && p.status === 'downloading'" class="text-sm">{{ p.downloadspeed }} MB/s</div>
</div>
</div>
@ -52,6 +58,8 @@ const playlist = ref<
partsdownloaded: number
downloadspeed: number
quality: number
service: string
format: string
}>
>()
@ -68,8 +76,10 @@ const getPlaylist = async () => {
partsdownloaded: number
downloadspeed: number
quality: number
service: string
format: string
}>
>('http://localhost:8080/api/crunchyroll/playlist')
>('http://localhost:8080/api/service/playlist')
if (error.value) {
alert(error.value)
@ -84,7 +94,7 @@ const getPlaylist = async () => {
}
const deletePlaylist = async () => {
const { data, error } = await useFetch('http://localhost:8080/api/crunchyroll/playlist', {
const { data, error } = await useFetch('http://localhost:8080/api/service/playlist', {
method: 'delete'
})

View File

@ -3,6 +3,7 @@ import cors from "@fastify/cors";
import NodeCache from "node-cache";
import crunchyrollRoutes from "./routes/crunchyroll/crunchyroll.route";
import { sequelize } from "./db/database";
import serviceRoutes from "./routes/service/service.route";
(async () => {
try {
@ -43,6 +44,7 @@ server.decorate("CacheController", CacheController);
// Routes
server.register(crunchyrollRoutes, { prefix: 'api/crunchyroll' })
server.register(serviceRoutes, { prefix: 'api/service' })
function startAPI() {
server.listen({ port: 8080 }, (err, address) => {

View File

@ -4,7 +4,7 @@ import { CrunchyEpisode } from '../types/crunchyroll'
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: app.getPath('documents') + '/crd-dbv1.db'
storage: app.getPath('documents') + '/crd-dbv2.db'
})
interface AccountAttributes {
@ -35,7 +35,9 @@ interface PlaylistAttributes {
hardsub: boolean
quality: 1080 | 720 | 480 | 360 | 240
dir: string
failedreason: string
failedreason: string,
service: 'CR' | 'ADN',
format: 'mp4' | 'mkv'
}
interface PlaylistCreateAttributes {
@ -45,7 +47,9 @@ interface PlaylistCreateAttributes {
dir: string
quality: 1080 | 720 | 480 | 360 | 240
hardsub: boolean
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed'
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed',
service: 'CR' | 'ADN',
format: 'mp4' | 'mkv'
}
const Account: ModelDefined<AccountAttributes, AccountCreateAttributes> = sequelize.define('Accounts', {
@ -116,6 +120,14 @@ const Playlist: ModelDefined<PlaylistAttributes, PlaylistCreateAttributes> = seq
allowNull: true,
type: DataTypes.BOOLEAN
},
service: {
allowNull: true,
type: DataTypes.STRING
},
format: {
allowNull: true,
type: DataTypes.STRING
},
createdAt: {
allowNull: false,
type: DataTypes.DATE

View File

@ -2,376 +2,468 @@ import JSEncrypt from 'jsencrypt'
import CryptoJS from 'crypto-js'
import { server } from '../../api'
import { ADNPlayerConfig } from '../../types/adn'
import { messageBox } from '../../../electron/background'
import { useFetch } from '../useFetch'
export async function getShowADN(q: number) {
const cachedData = server.CacheController.get(`getshowadn-${q}`)
// export async function getShowADN(q: number) {
// const cachedData = server.CacheController.get(`getshowadn-${q}`)
if (cachedData) {
return cachedData
// if (cachedData) {
// return cachedData
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/show/${q}`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de'
// }
// })
// if (response.ok) {
// const data: {
// show: Array<any>
// } = JSON.parse(await response.text())
// server.CacheController.set(`getshowadn-${q}`, data.show, 1000)
// return data.show
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function getEpisodesWithShowIdADN(q: number) {
// const cachedData = server.CacheController.get(`getepisodesadn-${q}`)
// if (cachedData) {
// return cachedData
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/video/show/${q}?offset=0&limit=-1&order=asc`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de'
// }
// })
// if (response.ok) {
// const data: {
// videos: Array<any>
// } = JSON.parse(await response.text())
// server.CacheController.set(`getepisodesadn-${q}`, data.videos, 1000)
// return data.videos
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function getEpisodeADN(q: number) {
// const cachedData = server.CacheController.get(`getepisodeadn-${q}`)
// if (cachedData) {
// return cachedData
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/video/${q}/public`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de'
// }
// })
// if (response.ok) {
// const data: {
// video: Array<any>
// } = JSON.parse(await response.text())
// server.CacheController.set(`getepisodeadn-${q}`, data.video, 1000)
// return data.video
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function searchADN(q: string) {
// const cachedData = server.CacheController.get(`searchadn-${q}`)
// if (cachedData) {
// return cachedData
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/show/catalog?maxAgeCategory=18&search=${q}`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de'
// }
// })
// if (response.ok) {
// const data: {
// shows: Array<any>
// } = JSON.parse(await response.text())
// server.CacheController.set(`searchadn-${q}`, data.shows, 1000)
// return data.shows
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function loginADN() {
// const cachedData = server.CacheController.get('adnlogin')
// const cachedToken = server.CacheController.get('adntoken')
// if (cachedData) {
// return cachedData
// }
// if (cachedToken) {
// const data = await loginADNToken(cachedToken as string)
// return data
// }
// const body = {
// source: 'Web',
// rememberMe: true
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/authentication/login`, {
// method: 'POST',
// headers: {
// 'x-target-distribution': 'de',
// 'Content-Type': 'application/json'
// },
// body: JSON.stringify(body)
// })
// if (response.ok) {
// const data: {
// accessToken: string
// } = JSON.parse(await response.text())
// server.CacheController.set('adnlogin', data, 100)
// server.CacheController.set('adntoken', data.accessToken, 300)
// server.CacheController.del('adnplayerconfig')
// return data
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// async function loginADNToken(t: string) {
// const body = {
// source: 'Web',
// rememberMe: true
// }
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/authentication/refresh`, {
// method: 'POST',
// headers: {
// 'x-target-distribution': 'de',
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${t}`,
// 'X-Access-Token': t
// },
// body: JSON.stringify(body)
// })
// if (response.ok) {
// const data: {
// accessToken: string
// } = JSON.parse(await response.text())
// server.CacheController.set('adnlogin', data, 100)
// server.CacheController.set('adntoken', data.accessToken, 300)
// server.CacheController.del('adnplayerconfig')
// return data
// } else {
// console.log(await response.text())
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function getPlayerConfigADN() {
// const cachedData: ADNPlayerConfig | undefined = server.CacheController.get('adnplayerconfig')
// if (cachedData) {
// return cachedData
// }
// const token: any = await loginADN()
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/video/19830/configuration`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de',
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${token.accessToken}`
// }
// })
// if (response.ok) {
// const data: ADNPlayerConfig = JSON.parse(await response.text())
// server.CacheController.set('adnplayerconfig', data, 300)
// return data
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// async function getPlayerToken() {
// const r = await getPlayerConfigADN()
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/refresh/token`, {
// method: 'POST',
// headers: {
// 'x-target-distribution': 'de',
// 'Content-Type': 'application/json',
// 'X-Player-Refresh-Token': r.player.options.user.refreshToken
// }
// })
// if (response.ok) {
// const data: {
// token: string
// accessToken: string
// refreshToken: string
// } = JSON.parse(await response.text())
// return data
// } else {
// throw new Error('Failed to fetch ADN')
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// function randomHexaString(length: number) {
// const characters = '0123456789abcdef'
// let result = ''
// for (let i = 0; i < length; i++) {
// result += characters[Math.floor(Math.random() * characters.length)]
// }
// return result
// }
// async function getPlayerEncryptedToken() {
// const token = await getPlayerToken()
// var key = new JSEncrypt()
// var random = randomHexaString(16)
// key.setPublicKey(
// '-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssgnumHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6KhS+IFEqwvZqgbBpKuwIDAQAB-----END PUBLIC KEY-----'
// )
// const data = {
// k: random,
// t: String(token.token)
// }
// const finisheddata = JSON.stringify(data)
// const encryptedData = key.encrypt(finisheddata) || ''
// return { data: encryptedData, random: random }
// }
// export async function getPlayerPlaylists(animeid: number) {
// const token = await getPlayerEncryptedToken()
// try {
// const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/video/${animeid}/link`, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de',
// 'X-Player-Token': token.data
// }
// })
// if (response.ok) {
// const data: {
// links: {
// subtitles: {
// all: string
// }
// }
// } = await JSON.parse(await response.text())
// const subtitlelink = await fetch(data.links.subtitles.all, {
// method: 'GET',
// headers: {
// 'x-target-distribution': 'de'
// }
// })
// const link: {
// location: string
// } = await JSON.parse(await subtitlelink.text())
// const subs = await parseSubs(link.location, token.random)
// return subs
// } else {
// const data: {
// token: string
// accessToken: string
// refreshToken: string
// } = JSON.parse(await response.text())
// return data
// }
// } catch (e) {
// throw new Error(e as string)
// }
// }
// export async function parseSubs(url: string, grant: string) {
// const response = await fetch(url)
// const data = await response.text()
// var key = grant + '7fac1178830cfe0c'
// console.log(key)
// var parsedSubtitle = CryptoJS.enc.Base64.parse(data.substring(0, 24))
// var sec = CryptoJS.enc.Hex.parse(key)
// var som = data.substring(24)
// try {
// // Fuck You ADN
// var decrypted: any = CryptoJS.AES.decrypt(som, sec, { iv: parsedSubtitle })
// decrypted = decrypted.toString(CryptoJS.enc.Utf8)
// return decrypted
// } catch (error) {
// console.error('Error decrypting subtitles:', error)
// return null
// }
// }
export async function adnyLogin(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('adntoken')
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/show/${q}`, {
method: 'GET',
headers: {
'x-target-distribution': 'de'
}
})
if (!cachedData) {
var { data, error } = await adnLoginFetch(user, passw)
if (response.ok) {
const data: {
show: Array<any>
} = JSON.parse(await response.text())
server.CacheController.set(`getshowadn-${q}`, data.show, 1000)
return data.show
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
export async function getEpisodesWithShowIdADN(q: number) {
const cachedData = server.CacheController.get(`getepisodesadn-${q}`)
if (cachedData) {
return cachedData
}
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/video/show/${q}?offset=0&limit=-1&order=asc`, {
method: 'GET',
headers: {
'x-target-distribution': 'de'
}
})
if (response.ok) {
const data: {
videos: Array<any>
} = JSON.parse(await response.text())
server.CacheController.set(`getepisodesadn-${q}`, data.videos, 1000)
return data.videos
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
export async function getEpisodeADN(q: number) {
const cachedData = server.CacheController.get(`getepisodeadn-${q}`)
if (cachedData) {
return cachedData
}
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/video/${q}/public`, {
method: 'GET',
headers: {
'x-target-distribution': 'de'
}
})
if (response.ok) {
const data: {
video: Array<any>
} = JSON.parse(await response.text())
server.CacheController.set(`getepisodeadn-${q}`, data.video, 1000)
return data.video
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
export async function searchADN(q: string) {
const cachedData = server.CacheController.get(`searchadn-${q}`)
if (cachedData) {
return cachedData
}
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/show/catalog?maxAgeCategory=18&search=${q}`, {
method: 'GET',
headers: {
'x-target-distribution': 'de'
}
})
if (response.ok) {
const data: {
shows: Array<any>
} = JSON.parse(await response.text())
server.CacheController.set(`searchadn-${q}`, data.shows, 1000)
return data.shows
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
export async function loginADN() {
const cachedData = server.CacheController.get('adnlogin')
const cachedToken = server.CacheController.get('adntoken')
if (cachedData) {
return cachedData
}
if (cachedToken) {
const data = await loginADNToken(cachedToken as string)
return data
}
const body = {
source: 'Web',
rememberMe: true
}
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/authentication/login`, {
method: 'POST',
headers: {
'x-target-distribution': 'de',
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (response.ok) {
const data: {
accessToken: string
} = JSON.parse(await response.text())
server.CacheController.set('adnlogin', data, 100)
server.CacheController.set('adntoken', data.accessToken, 300)
server.CacheController.del('adnplayerconfig')
return data
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
async function loginADNToken(t: string) {
const body = {
source: 'Web',
rememberMe: true
}
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/authentication/refresh`, {
method: 'POST',
headers: {
'x-target-distribution': 'de',
'Content-Type': 'application/json',
Authorization: `Bearer ${t}`,
'X-Access-Token': t
},
body: JSON.stringify(body)
})
if (response.ok) {
const data: {
accessToken: string
} = JSON.parse(await response.text())
server.CacheController.set('adnlogin', data, 100)
server.CacheController.set('adntoken', data.accessToken, 300)
server.CacheController.del('adnplayerconfig')
return data
} else {
console.log(await response.text())
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
export async function getPlayerConfigADN() {
const cachedData: ADNPlayerConfig | undefined = server.CacheController.get('adnplayerconfig')
if (cachedData) {
return cachedData
}
const token: any = await loginADN()
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/video/19830/configuration`, {
method: 'GET',
headers: {
'x-target-distribution': 'de',
'Content-Type': 'application/json',
Authorization: `Bearer ${token.accessToken}`
}
})
if (response.ok) {
const data: ADNPlayerConfig = JSON.parse(await response.text())
server.CacheController.set('adnplayerconfig', data, 300)
return data
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
async function getPlayerToken() {
const r = await getPlayerConfigADN()
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/refresh/token`, {
method: 'POST',
headers: {
'x-target-distribution': 'de',
'Content-Type': 'application/json',
'X-Player-Refresh-Token': r.player.options.user.refreshToken
}
})
if (response.ok) {
const data: {
token: string
accessToken: string
refreshToken: string
} = JSON.parse(await response.text())
return data
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
function randomHexaString(length: number) {
const characters = '0123456789abcdef'
let result = ''
for (let i = 0; i < length; i++) {
result += characters[Math.floor(Math.random() * characters.length)]
}
return result
}
async function getPlayerEncryptedToken() {
const token = await getPlayerToken()
var key = new JSEncrypt()
var random = randomHexaString(16)
key.setPublicKey(
'-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbQrCJBRmaXM4gJidDmcpWDssgnumHinCLHAgS4buMtdH7dEGGEUfBofLzoEdt1jqcrCDT6YNhM0aFCqbLOPFtx9cg/X2G/G5bPVu8cuFM0L+ehp8s6izK1kjx3OOPH/kWzvstM5tkqgJkNyNEvHdeJl6KhS+IFEqwvZqgbBpKuwIDAQAB-----END PUBLIC KEY-----'
if (error) {
messageBox(
'error',
['Cancel'],
2,
'Failed to login',
'Failed to login to ADN',
(error.error as string)
)
const data = {
k: random,
t: String(token.token)
return { data: null, error: error.error }
}
const finisheddata = JSON.stringify(data)
const encryptedData = key.encrypt(finisheddata) || ''
return { data: encryptedData, random: random }
if (!data) {
messageBox('error', ['Cancel'], 2, 'Failed to login', 'Failed to login to ADN', 'ADN returned null')
return { data: null, error: 'ADN returned null' }
}
export async function getPlayerPlaylists(animeid: number) {
const token = await getPlayerEncryptedToken()
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/video/${animeid}/link`, {
method: 'GET',
headers: {
'x-target-distribution': 'de',
'X-Player-Token': token.data
if (!data.access_token) {
messageBox('error', ['Cancel'], 2, 'Failed to login', 'Failed to login to ADN', 'ADN returned malformed data')
return { data: null, error: 'ADN returned malformed data' }
}
server.CacheController.set('adntoken', data, data.expires_in - 30)
return { data: data, error: null }
}
return { data: cachedData, error: null }
}
async function adnLoginFetch(user: string, passw: string) {
const headers = {
Authorization: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=',
'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 (response.ok) {
const data: {
links: {
subtitles: {
all: string
}
}
} = await JSON.parse(await response.text())
const subtitlelink = await fetch(data.links.subtitles.all, {
method: 'GET',
headers: {
'x-target-distribution': 'de'
}
})
const link: {
location: string
} = await JSON.parse(await subtitlelink.text())
const subs = await parseSubs(link.location, token.random)
return subs
} else {
const data: {
token: string
accessToken: string
refreshToken: string
} = JSON.parse(await response.text())
return data
}
} catch (e) {
throw new Error(e as string)
}
if (error) {
return { data: null, error: error }
}
export async function parseSubs(url: string, grant: string) {
const response = await fetch(url)
const data = await response.text()
var key = grant + '7fac1178830cfe0c'
console.log(key)
var parsedSubtitle = CryptoJS.enc.Base64.parse(data.substring(0, 24))
var sec = CryptoJS.enc.Hex.parse(key)
var som = data.substring(24)
try {
// Fuck You ADN
var decrypted: any = CryptoJS.AES.decrypt(som, sec, { iv: parsedSubtitle })
decrypted = decrypted.toString(CryptoJS.enc.Utf8)
return decrypted
} catch (error) {
console.error('Error decrypting subtitles:', error)
return null
if (!data) {
return { data: null, error: null }
}
return { data: data, error: null }
}

View File

@ -1,11 +1,12 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { crunchyLogin, checkIfLoggedInCR, safeLoginData, addEpisodeToPlaylist, getPlaylist, getDownloading, deletePlaylist } from './crunchyroll.service'
import { crunchyLogin } from './crunchyroll.service'
import { dialog } from 'electron'
import { messageBox } from '../../../electron/background'
import { CrunchyEpisodes, CrunchySeason } from '../../types/crunchyroll'
import { loggedInCheck } from '../service/service.service'
export async function loginController(request: FastifyRequest, reply: FastifyReply) {
const account = await checkIfLoggedInCR('crunchyroll')
const account = await loggedInCheck('CR')
if (!account) {
return reply.code(401).send({ message: 'Not Logged in' })
@ -19,91 +20,3 @@ export async function loginController(request: FastifyRequest, reply: FastifyRep
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
hardsub: boolean
quality: 1080 | 720 | 480 | 360 | 240
}
}>,
reply: FastifyReply
) {
const body = request.body
for (const e of body.episodes) {
await addEpisodeToPlaylist(e, body.subs, body.dubs, body.dir, body.hardsub, 'waiting', body.quality)
}
return reply.code(201).send()
}
export async function deleteCompletePlaylistController(request: FastifyRequest, reply: FastifyReply) {
await deletePlaylist()
return reply.code(200).send()
}
export async function getPlaylistController(request: FastifyRequest, reply: FastifyReply) {
const playlist = await getPlaylist()
for (const v of playlist) {
if (v.dataValues.status === 'downloading') {
const found = await getDownloading(v.dataValues.id)
if (found) {
;(v as any).dataValues = {
...v.dataValues,
partsleft: found.partsToDownload,
partsdownloaded: found.downloadedParts,
downloadspeed: found.downloadSpeed.toFixed(2)
}
}
}
}
return reply.code(200).send(playlist.reverse())
}

View File

@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify'
import { addPlaylistController, checkLoginController, deleteCompletePlaylistController, getPlaylistController, loginController, loginLoginController } from './crunchyroll.controller'
import { loginController } from './crunchyroll.controller'
async function crunchyrollRoutes(server: FastifyInstance) {
server.post(
@ -15,77 +15,6 @@ async function crunchyrollRoutes(server: FastifyInstance) {
}
},
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
)
server.delete(
'/playlist',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
deleteCompletePlaylistController
)
}

View File

@ -1,20 +1,9 @@
import { messageBox } from '../../../electron/background'
import { server } from '../../api'
import { Account, Playlist } from '../../db/database'
import { CrunchyEpisode, VideoPlaylist } from '../../types/crunchyroll'
import { 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 ffmpegPath = require('ffmpeg-static').replace('app.asar', 'app.asar.unpacked')
const ffprobePath = require('ffprobe-static').path.replace('app.asar', 'app.asar.unpacked')
import { loggedInCheck } from '../service/service.service'
const crErrors = [
{
@ -113,103 +102,8 @@ async function crunchyLoginFetch(user: string, passw: string) {
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,
hardsub: boolean,
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed',
quality: 1080 | 720 | 480 | 360 | 240
) {
const episode = await Playlist.create({
media: e,
sub: s,
dub: d,
dir: dir,
hardsub: hardsub,
status: status,
quality: quality
})
return episode.get()
}
export async function getPlaylist() {
const episodes = await Playlist.findAll()
return episodes
}
export async function deletePlaylist() {
await Playlist.truncate()
return true
}
export async function getDownloading(id: number) {
const found = downloading.find((i) => i.id === id)
if (found) return found
return null
}
export async function updatePlaylistByID(id: number, status: 'waiting' | 'preparing' | 'downloading' | 'completed' | 'merging' | 'failed') {
await Playlist.update({ status: status }, { where: { id: id } })
}
var isDownloading: number = 0
async function checkPlaylists() {
const eps = await Playlist.findAll({ where: { status: 'waiting' } })
for (const e of eps) {
if (isDownloading < 3 && e.dataValues.status === 'waiting') {
updatePlaylistByID(e.dataValues.id, 'preparing')
isDownloading++
downloadPlaylist(
e.dataValues.media.id,
(e as any).dataValues.dub.map((s: { locale: any }) => s.locale),
(e as any).dataValues.sub.map((s: { locale: any }) => s.locale),
e.dataValues.hardsub,
e.dataValues.id,
e.dataValues.media.series_title,
e.dataValues.media.season_number,
e.dataValues.media.episode_number,
e.dataValues.quality,
e.dataValues.dir
)
}
}
}
cron.schedule('*/2 * * * * *', () => {
checkPlaylists()
})
export async function crunchyGetPlaylist(q: string) {
const account = await checkIfLoggedInCR('crunchyroll')
const account = await loggedInCheck('CR')
if (!account) return
@ -252,59 +146,8 @@ export async function crunchyGetPlaylist(q: 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 checkDirectoryExistence(dir: string) {
try {
await fs.promises.access(dir)
console.log(`Directory ${dir} exists.`)
return true
} catch (error) {
console.log(`Directory ${dir} does not exist.`)
return false
}
}
async function createFolderName(name: string, dir: string) {
var folderPath
const dirExists = await checkDirectoryExistence(dir)
if (dirExists) {
folderPath = path.join(dir, name)
} else {
folderPath = path.join(app.getPath('documents'), name)
}
try {
await fs.promises.access(folderPath)
return folderPath
} catch (error) {
try {
await fs.promises.mkdir(folderPath, { recursive: true })
return folderPath
} catch (mkdirError) {
console.error('Error creating season folder:', mkdirError)
throw mkdirError
}
}
}
async function deleteFolder(folderPath: string) {
fs.rmSync(folderPath, { recursive: true, force: true })
}
export async function crunchyGetPlaylistMPD(q: string) {
const account = await checkIfLoggedInCR('crunchyroll')
const account = await loggedInCheck('CR')
if (!account) return
@ -334,613 +177,3 @@ export async function crunchyGetPlaylistMPD(q: string) {
throw new Error(e as string)
}
}
var downloading: Array<{
id: number
downloadedParts: number
partsToDownload: number
downloadSpeed: number
}> = []
export async function downloadPlaylist(
e: string,
dubs: Array<string>,
subs: Array<string>,
hardsub: boolean,
downloadID: number,
name: string,
season: number,
episode: number,
quality: 1080 | 720 | 480 | 360 | 240,
downloadPath: string
) {
downloading.push({
id: downloadID,
downloadedParts: 0,
partsToDownload: 0,
downloadSpeed: 0
})
await updatePlaylistByID(downloadID, 'downloading')
var playlist = await crunchyGetPlaylist(e)
console.log(playlist)
if (!playlist) {
console.log('Playlist not found')
return
}
if (playlist.versions && playlist.versions.length !== 0) {
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) {
console.log('Exact Playlist not found')
return
}
const subFolder = await createFolder()
const audioFolder = await createFolder()
const videoFolder = await createFolder()
const seasonFolder = await createFolderName(`${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season}`, downloadPath)
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
isDub: boolean
}> = []
for (const s of subs) {
var subPlaylist
if (playlist.audioLocale !== 'ja-JP') {
const foundStream = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
if (foundStream) {
subPlaylist = await crunchyGetPlaylist(foundStream.guid)
}
} else {
subPlaylist = playlist
}
if (!subPlaylist) {
console.log('Subtitle Playlist not found')
return
}
const found = subPlaylist.subtitles.find((sub) => sub.language === s)
if (found) {
subDownloadList.push({ ...found, isDub: false })
console.log(`Subtitle ${s}.ass found, adding to download`)
} else {
console.warn(`Subtitle ${s}.ass not found, skipping`)
}
}
for (const d of dubs) {
var found
if (playlist.versions) {
found = playlist.versions.find((p) => p.audio_locale === d)
}
if (found) {
const list = await crunchyGetPlaylist(found.guid)
if (list) {
const foundSub = list.subtitles.find((sub) => sub.language === d)
if (foundSub) {
subDownloadList.push({ ...foundSub, isDub: true })
} else {
console.log(`No Dub Sub Found for ${d}`)
}
}
dubDownloadList.push(found)
console.log(`Audio ${d}.aac found, adding to download`)
} else if (playlist.versions.length === 0) {
const foundSub = playlist.subtitles.find((sub) => sub.language === d)
if (foundSub) {
subDownloadList.push({ ...foundSub, isDub: true })
} else {
console.log(`No Dub Sub Found for ${d}`)
}
dubDownloadList.push({
audio_locale: 'ja-JP',
guid: e,
is_premium_only: true,
media_guid: 'adas',
original: false,
season_guid: 'asdasd',
variant: 'asd'
})
} 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 && playlist.versions.length !== 0) {
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
}
} else {
code = e
}
if (!code) return console.error('No clean stream found')
const play = await crunchyGetPlaylist(code)
if (!play) return
var downloadURL
if (hardsub) {
const hardsubURL = play.hardSubs.find((h) => h.hlang === subs[0])?.url
if (hardsubURL) {
downloadURL = hardsubURL
console.log('Hardsub Playlist found')
} else {
downloadURL = play.url
console.log('Hardsub Playlist not found')
}
} else {
downloadURL = play.url
console.log('Hardsub disabled, skipping')
}
var mdp = await crunchyGetPlaylistMPD(downloadURL)
if (!mdp) return
var hq = mdp.playlists.find((i) => i.attributes.RESOLUTION?.height === quality)
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 updatePlaylistToDownloadPartsByID(downloadID, p.length)
const dn = downloading.find((i) => i.id === downloadID)
if (dn) {
dn.partsToDownload = p.length
}
const file = await downloadParts(p, downloadID, videoFolder)
return file
}
const [subss, audios, file] = await Promise.all([subDownload(), audioDownload(), downloadVideo()])
if (!audios) return
await mergeFile(file as string, audios, subss, String(playlist.assetId), seasonFolder, `${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season} Episode ${episode}`)
await updatePlaylistByID(downloadID, 'completed')
await deleteFolder(subFolder)
await deleteFolder(audioFolder)
await deleteFolder(videoFolder)
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, dir: string) {
const path = await createFolder()
const dn = downloading.find((i) => i.id === downloadID)
let totalDownloadedBytes = 0
let startTime = Date.now()
for (const [index, part] of parts.entries()) {
let success = false
while (!success) {
try {
const stream = fs.createWriteStream(`${path}/${part.filename}`)
const { body } = await fetch(part.url)
const readableStream = Readable.from(body as any)
let partDownloadedBytes = 0
readableStream.on('data', (chunk) => {
partDownloadedBytes += chunk.length
totalDownloadedBytes += chunk.length
})
await finished(readableStream.pipe(stream))
console.log(`Fragment ${index + 1} downloaded`)
if (dn) {
dn.downloadedParts++
const endTime = Date.now()
const durationInSeconds = (endTime - startTime) / 1000
dn.downloadSpeed = totalDownloadedBytes / 1024 / 1024 / durationInSeconds
}
success = true
} catch (error) {
console.error(`Error occurred during download of fragment ${index + 1}:`, error)
console.log(`Retrying download of fragment ${index + 1}...`)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
return await mergeParts(parts, downloadID, path, dir)
}
async function downloadSub(
sub: {
format: string
language: string
url: string
isDub: boolean
},
dir: string
) {
const path = `${dir}/${sub.language}${sub.isDub ? `-FORCED` : ''}.${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 }[], downloadID: number, tmp: string, dir: string) {
const tempname = (Math.random() + 1).toString(36).substring(2)
try {
const list: Array<string> = []
await updatePlaylistByID(downloadID, 'merging')
isDownloading--
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()
.setFfmpegPath(ffmpegPath)
.setFfprobePath(ffprobePath)
.input(concatenatedFile)
.outputOptions('-c copy')
.save(dir + `/${tempname}.mp4`)
.on('end', async () => {
console.log('Merging finished')
await deleteFolder(tmp)
return resolve(dir + `/${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()
.setFfmpegPath(ffmpegPath)
.setFfprobePath(ffprobePath)
.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, path: string, filename: 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().setFfmpegPath(ffmpegPath).setFfprobePath(ffprobePath)
var ffindex = 1
output.addInput(video)
var options = ['-map_metadata -1', '-c copy', '-metadata:s:v:0 VARIANT_BITRATE=0', '-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++
options.push(
`-metadata:s:a:${index} title=${
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]
}`
)
options.push(`-metadata:s:a:${index} VARIANT_BITRATE=0`)
}
options.push(`-disposition:a:0 default`)
if (subs) {
for (const [index, s] of subs.entries()) {
output.addInput(s)
options.push(`-map ${ffindex}:s`)
if (s.includes('-FORCED')) {
options.push(
`-metadata:s:s:${index} language=${
locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])?.iso
: s.split('/')[1].split('-FORCED.ass')[0]
}`
)
} else {
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]
}`
)
}
if (s.includes('-FORCED')) {
options.push(
`-metadata:s:s:${index} title=${
locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])?.title
: s.split('/')[1].split('-FORCED.ass')[0]
}[FORCED]`
)
} else {
options.push(
`-metadata:s:s:${index} title=${
locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])?.title
: s.split('/')[1].split('.ass')[0]
}`
)
}
ffindex++
}
options.push(`-disposition:s:0 default`)
}
output
.addOptions(options)
.saveToFile(path + `/${filename}.mkv`)
.on('error', (error) => {
console.log(error)
reject(error)
})
.on('end', async () => {
console.log('Download finished')
return resolve('combined')
})
})
}

View File

@ -0,0 +1,109 @@
import { FastifyReply, FastifyRequest } from "fastify"
import { crunchyLogin } from "../crunchyroll/crunchyroll.service"
import { addEpisodeToPlaylist, getDownloading, getPlaylist, loggedInCheck, safeLoginData } from "./service.service"
import { CrunchyEpisodes } from "../../types/crunchyroll"
export async function checkLoginController(request: FastifyRequest<{
Params: {
id: string
}
}>, reply: FastifyReply) {
const account = await loggedInCheck(request.params.id)
if (!account) {
return reply.code(401).send({ message: 'Not Logged in' })
}
return reply.code(200).send({ message: 'Logged in' })
}
export async function loginController(
request: FastifyRequest<{
Body: {
user: string
password: string
},
Params: {
id: string
}
}>,
reply: FastifyReply
) {
const body = request.body
const params = request.params
const account = await loggedInCheck(params.id)
if (account) {
return reply.code(404).send({ message: 'Already Logged In' })
}
var responseData
var responseError
if (params.id === 'CR') {
const { data, error } = await crunchyLogin(body.user, body.password)
responseError = error,
responseData = data
}
if (params.id === 'ADN') {
// const { data, error } = await adnLogin(body.user, body.password)
// responseError = error,
// responseData = data
}
if (responseError || !responseData) {
return reply.code(404).send({
message: 'Invalid Email or Password'
})
}
await safeLoginData(body.user, body.password, params.id)
return reply.code(200).send()
}
export async function addPlaylistController(
request: FastifyRequest<{
Body: {
episodes: CrunchyEpisodes
dubs: Array<string>
subs: Array<string>
dir: string
hardsub: boolean
quality: 1080 | 720 | 480 | 360 | 240
service: 'CR' | 'ADN'
format: 'mp4' | 'mkv'
}
}>,
reply: FastifyReply
) {
const body = request.body
for (const e of body.episodes) {
await addEpisodeToPlaylist(e, body.subs, body.dubs, body.dir, body.hardsub, 'waiting', body.quality, body.service, body.format)
}
return reply.code(201).send()
}
export async function getPlaylistController(request: FastifyRequest, reply: FastifyReply) {
const playlist = await getPlaylist()
for (const v of playlist) {
if (v.dataValues.status === 'downloading') {
const found = await getDownloading(v.dataValues.id)
if (found) {
;(v as any).dataValues = {
...v.dataValues,
partsleft: found.partsToDownload,
partsdownloaded: found.downloadedParts,
downloadspeed: found.downloadSpeed.toFixed(2)
}
}
}
}
return reply.code(200).send(playlist.reverse())
}

View File

@ -0,0 +1,63 @@
import { FastifyInstance } from "fastify"
import { addPlaylistController, checkLoginController, getPlaylistController, loginController } from "./service.controller"
async function serviceRoutes(server: FastifyInstance) {
server.post(
'/login/:id',
{
schema: {
response: {
'4xx': {
error: { type: 'string' },
message: { type: 'string' }
}
}
}
},
loginController
),
server.get(
'/check/:id',
{
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 serviceRoutes

View File

@ -0,0 +1,599 @@
import { Account, Playlist } from '../../db/database'
import { downloadMPDAudio } from '../../services/audio'
import { concatenateTSFiles } from '../../services/concatenate'
import { createFolder, createFolderName, deleteFolder } from '../../services/folder'
import { downloadCRSub } from '../../services/subs'
import { CrunchyEpisode } from '../../types/crunchyroll'
import { crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
import fs from 'fs'
var cron = require('node-cron')
import { Readable } from 'stream'
import { finished } from 'stream/promises'
import Ffmpeg from 'fluent-ffmpeg'
const ffmpegPath = require('ffmpeg-static').replace('app.asar', 'app.asar.unpacked')
const ffprobePath = require('ffprobe-static').path.replace('app.asar', 'app.asar.unpacked')
// DB Account existence check
export async function loggedInCheck(service: string) {
const login = await Account.findOne({
where: {
service: service
}
})
return login?.get()
}
// Save Login Data in DB
export async function safeLoginData(user: string, password: string, service: string) {
const login = await Account.create({
username: user,
password: password,
service: service
})
return login?.get()
}
// Get Playlist
export async function getPlaylist() {
const episodes = await Playlist.findAll()
return episodes
}
// Update Playlist Item
export async function updatePlaylistByID(id: number, status: 'waiting' | 'preparing' | 'downloading' | 'completed' | 'merging' | 'failed') {
await Playlist.update({ status: status }, { where: { id: id } })
}
// Add Episode to Playlist
export async function addEpisodeToPlaylist(
e: CrunchyEpisode,
s: Array<string>,
d: Array<string>,
dir: string,
hardsub: boolean,
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed',
quality: 1080 | 720 | 480 | 360 | 240,
service: 'CR' | 'ADN',
format: 'mp4' | 'mkv'
) {
const episode = await Playlist.create({
media: e,
sub: s,
dub: d,
dir: dir,
hardsub: hardsub,
status: status,
quality: quality,
service: service,
format: format
})
return episode.get()
}
// Define Downloading Array
var downloading: Array<{
id: number
downloadedParts: number
partsToDownload: number
downloadSpeed: number
}> = []
// Get Downloading Episodes
export async function getDownloading(id: number) {
const found = downloading.find((i) => i.id === id)
if (found) return found
return null
}
// Define IsDownloading Count
var isDownloading: number = 0
// Check Playlist every 2 seconds for new items
async function checkPlaylists() {
const eps = await Playlist.findAll({ where: { status: 'waiting' } })
for (const e of eps) {
if (isDownloading < 3 && e.dataValues.status === 'waiting') {
updatePlaylistByID(e.dataValues.id, 'preparing')
isDownloading++
if (e.dataValues.service === 'CR') {
downloadCrunchyrollPlaylist(
e.dataValues.media.id,
(e as any).dataValues.dub.map((s: { locale: any }) => s.locale),
(e as any).dataValues.sub.map((s: { locale: any }) => s.locale),
e.dataValues.hardsub,
e.dataValues.id,
e.dataValues.media.series_title,
e.dataValues.media.season_number,
e.dataValues.media.episode_number,
e.dataValues.quality,
e.dataValues.dir,
e.dataValues.format
)
}
}
}
}
cron.schedule('*/2 * * * * *', () => {
checkPlaylists()
})
// Download Crunchyroll Playlist
export async function downloadCrunchyrollPlaylist(
e: string,
dubs: Array<string>,
subs: Array<string>,
hardsub: boolean,
downloadID: number,
name: string,
season: number,
episode: number,
quality: 1080 | 720 | 480 | 360 | 240,
downloadPath: string,
format: 'mp4' | 'mkv'
) {
downloading.push({
id: downloadID,
downloadedParts: 0,
partsToDownload: 0,
downloadSpeed: 0
})
await updatePlaylistByID(downloadID, 'downloading')
var playlist = await crunchyGetPlaylist(e)
console.log(playlist)
if (!playlist) {
console.log('Playlist not found')
return
}
if (playlist.versions && playlist.versions.length !== 0) {
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) {
console.log('Exact Playlist not found')
return
}
const subFolder = await createFolder()
const audioFolder = await createFolder()
const videoFolder = await createFolder()
const seasonFolder = await createFolderName(`${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season}`, downloadPath)
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
isDub: boolean
}> = []
for (const s of subs) {
var subPlaylist
if (playlist.audioLocale !== 'ja-JP') {
const foundStream = playlist.versions.find((v) => v.audio_locale === 'ja-JP')
if (foundStream) {
subPlaylist = await crunchyGetPlaylist(foundStream.guid)
}
} else {
subPlaylist = playlist
}
if (!subPlaylist) {
console.log('Subtitle Playlist not found')
return
}
const found = subPlaylist.subtitles.find((sub) => sub.language === s)
if (found) {
subDownloadList.push({ ...found, isDub: false })
console.log(`Subtitle ${s}.ass found, adding to download`)
} else {
console.warn(`Subtitle ${s}.ass not found, skipping`)
}
}
for (const d of dubs) {
var found
if (playlist.versions) {
found = playlist.versions.find((p) => p.audio_locale === d)
}
if (found) {
const list = await crunchyGetPlaylist(found.guid)
if (list) {
const foundSub = list.subtitles.find((sub) => sub.language === d)
if (foundSub) {
subDownloadList.push({ ...foundSub, isDub: true })
} else {
console.log(`No Dub Sub Found for ${d}`)
}
}
dubDownloadList.push(found)
console.log(`Audio ${d}.aac found, adding to download`)
} else if (playlist.versions.length === 0) {
const foundSub = playlist.subtitles.find((sub) => sub.language === d)
if (foundSub) {
subDownloadList.push({ ...foundSub, isDub: true })
} else {
console.log(`No Dub Sub Found for ${d}`)
}
dubDownloadList.push({
audio_locale: 'ja-JP',
guid: e,
is_premium_only: true,
media_guid: 'adas',
original: false,
season_guid: 'asdasd',
variant: 'asd'
})
} 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 downloadCRSub(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 downloadMPDAudio(p, audioFolder, list.audioLocale)
audios.push(path as string)
}
return audios
}
const downloadVideo = async () => {
var code
if (!playlist) return
if (playlist.versions && playlist.versions.length !== 0) {
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
}
} else {
code = e
}
if (!code) return console.error('No clean stream found')
const play = await crunchyGetPlaylist(code)
if (!play) return
var downloadURL
if (hardsub) {
const hardsubURL = play.hardSubs.find((h) => h.hlang === subs[0])?.url
if (hardsubURL) {
downloadURL = hardsubURL
console.log('Hardsub Playlist found')
} else {
downloadURL = play.url
console.log('Hardsub Playlist not found')
}
} else {
downloadURL = play.url
console.log('Hardsub disabled, skipping')
}
var mdp = await crunchyGetPlaylistMPD(downloadURL)
if (!mdp) return
var hq = mdp.playlists.find((i) => i.attributes.RESOLUTION?.height === quality)
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
})
}
const dn = downloading.find((i) => i.id === downloadID)
if (dn) {
dn.partsToDownload = p.length
}
const file = await downloadParts(p, downloadID, videoFolder)
return file
}
const [subss, audios, file] = await Promise.all([subDownload(), audioDownload(), downloadVideo()])
if (!audios) return
await mergeVideoFile(file as string, audios, subss, String(playlist.assetId), seasonFolder, `${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season} Episode ${episode}`, format)
await updatePlaylistByID(downloadID, 'completed')
await deleteFolder(subFolder)
await deleteFolder(audioFolder)
await deleteFolder(videoFolder)
return playlist
}
async function downloadParts(parts: { filename: string; url: string }[], downloadID: number, dir: string) {
const path = await createFolder()
const dn = downloading.find((i) => i.id === downloadID)
let totalDownloadedBytes = 0
let startTime = Date.now()
for (const [index, part] of parts.entries()) {
let success = false
while (!success) {
try {
const stream = fs.createWriteStream(`${path}/${part.filename}`)
const { body } = await fetch(part.url)
const readableStream = Readable.from(body as any)
let partDownloadedBytes = 0
readableStream.on('data', (chunk) => {
partDownloadedBytes += chunk.length
totalDownloadedBytes += chunk.length
})
await finished(readableStream.pipe(stream))
console.log(`Fragment ${index + 1} downloaded`)
if (dn) {
dn.downloadedParts++
const endTime = Date.now()
const durationInSeconds = (endTime - startTime) / 1000
dn.downloadSpeed = totalDownloadedBytes / 1024 / 1024 / durationInSeconds
}
success = true
} catch (error) {
console.error(`Error occurred during download of fragment ${index + 1}:`, error)
console.log(`Retrying download of fragment ${index + 1}...`)
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
}
return await mergeParts(parts, downloadID, path, dir)
}
async function mergeParts(parts: { filename: string; url: string }[], downloadID: number, tmp: string, dir: string) {
const tempname = (Math.random() + 1).toString(36).substring(2)
try {
const list: Array<string> = []
await updatePlaylistByID(downloadID, 'merging')
isDownloading--
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()
.setFfmpegPath(ffmpegPath)
.setFfprobePath(ffprobePath)
.input(concatenatedFile)
.outputOptions('-c copy')
.save(dir + `/${tempname}.mp4`)
.on('end', async () => {
console.log('Merging finished')
await deleteFolder(tmp)
return resolve(dir + `/${tempname}.mp4`)
})
})
} catch (error) {
console.error('Error merging parts:', error)
}
}
async function mergeVideoFile(video: string, audios: Array<string>, subs: Array<string>, name: string, path: string, filename: string, format: 'mp4' | 'mkv') {
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().setFfmpegPath(ffmpegPath).setFfprobePath(ffprobePath)
var ffindex = 1
output.addInput(video)
var options = ['-map_metadata -1', '-c copy', '-metadata:s:v:0 VARIANT_BITRATE=0', '-map 0']
if (format === 'mp4') {
options.push('-c:s mov_text')
}
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++
options.push(
`-metadata:s:a:${index} title=${
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]
}`
)
options.push(`-metadata:s:a:${index} VARIANT_BITRATE=0`)
}
options.push(`-disposition:a:0 default`)
if (subs) {
for (const [index, s] of subs.entries()) {
output.addInput(s)
options.push(`-map ${ffindex}:s`)
if (s.includes('-FORCED')) {
options.push(
`-metadata:s:s:${index} language=${
locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])?.iso
: s.split('/')[1].split('-FORCED.ass')[0]
}`
)
} else {
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]
}`
)
}
if (s.includes('-FORCED')) {
options.push(
`-metadata:s:s:${index} title=${
locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('-FORCED.ass')[0])?.title
: s.split('/')[1].split('-FORCED.ass')[0]
}[FORCED]`
)
} else {
options.push(
`-metadata:s:s:${index} title=${
locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])
? locales.find((l) => l.locale === s.split('/')[1].split('.ass')[0])?.title
: s.split('/')[1].split('.ass')[0]
}`
)
}
ffindex++
}
options.push(`-disposition:s:0 default`)
}
output
.addOptions(options)
.saveToFile(path + `/${filename}.${format}`)
.on('error', (error) => {
console.log(error)
reject(error)
})
.on('end', async () => {
console.log('Download finished')
return resolve('combined')
})
})
}

69
src/api/services/audio.ts Normal file
View File

@ -0,0 +1,69 @@
import fs from 'fs'
import { Readable } from 'stream'
import { createFolder, deleteFolder } from './folder'
import { concatenateTSFiles } from './concatenate'
import Ffmpeg from 'fluent-ffmpeg'
const ffmpegPath = require('ffmpeg-static').replace('app.asar', 'app.asar.unpacked')
const ffprobePath = require('ffprobe-static').path.replace('app.asar', 'app.asar.unpacked')
export async function downloadMPDAudio(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 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()
.setFfmpegPath(ffmpegPath)
.setFfprobePath(ffprobePath)
.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)
}
}

View File

@ -0,0 +1,37 @@
import fs from 'fs'
export 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)
})
}

View File

@ -0,0 +1,54 @@
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
export 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
}
}
export async function checkDirectoryExistence(dir: string) {
try {
await fs.promises.access(dir)
console.log(`Directory ${dir} exists.`)
return true
} catch (error) {
console.log(`Directory ${dir} does not exist.`)
return false
}
}
export async function createFolderName(name: string, dir: string) {
var folderPath
const dirExists = await checkDirectoryExistence(dir)
if (dirExists) {
folderPath = path.join(dir, name)
} else {
folderPath = path.join(app.getPath('documents'), name)
}
try {
await fs.promises.access(folderPath)
return folderPath
} catch (error) {
try {
await fs.promises.mkdir(folderPath, { recursive: true })
return folderPath
} catch (mkdirError) {
console.error('Error creating season folder:', mkdirError)
throw mkdirError
}
}
}
export async function deleteFolder(folderPath: string) {
fs.rmSync(folderPath, { recursive: true, force: true })
}

39
src/api/services/subs.ts Normal file
View File

@ -0,0 +1,39 @@
import fs from 'fs'
import { parse, stringify } from 'ass-compiler'
import { Readable } from 'stream'
import { finished } from 'stream/promises'
export async function downloadCRSub(
sub: {
format: string
language: string
url: string
isDub: boolean
},
dir: string
) {
const path = `${dir}/${sub.language}${sub.isDub ? `-FORCED` : ''}.${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
}