adn first full download support (adn subs to ass converter missing)

This commit is contained in:
Daniel Haller 2024-04-23 00:27:24 +02:00
parent a9341a5877
commit 63080b70f9
9 changed files with 823 additions and 483 deletions

View File

@ -0,0 +1,18 @@
import type { ADNEpisodes, ADNEpisodesFetch } from './Types'
export async function getEpisodesWithShowIdADN(id: number) {
const { data, error } = await useFetch<ADNEpisodesFetch>(`https://gw.api.animationdigitalnetwork.fr/video/show/${id}?offset=0&limit=-1&order=asc`, {
method: 'GET',
headers: {
"x-target-distribution": "de",
},
})
if (error.value || !data.value) {
console.log(error.value)
alert(error.value)
return
}
return data.value.videos
}

View File

@ -7,3 +7,78 @@ export interface ADNSearchFetch {
episodeCount: number
}>
}
export interface ADNEpisodesFetch {
videos: Array<ADNEpisode>
}
export interface ADNEpisode {
id: number,
title: string,
name: string,
number: string,
shortNumber: string,
season: string,
reference: string,
type: string,
order: number,
image: string,
image2x: string,
summary: string,
releaseDate: string,
duration: number,
url: string,
urlPath: string,
embeddedUrl: string,
languages: Array<string>,
qualities: Array<string>,
rating: number,
ratingsCount: number,
commentsCount: number,
available: boolean,
download: boolean,
free: boolean,
freeWithAds: boolean,
show: {
id: number,
title: string,
type: string,
originalTitle: string,
shortTitle: string,
reference: string,
age: string,
languages: Array<string>,
summary: string,
image: string,
image2x: string,
imageHorizontal: string,
imageHorizontal2x: string,
url: string,
urlPath: string,
episodeCount: number,
genres: Array<string>,
copyright: string,
rating: number,
ratingsCount: number,
commentsCount: number,
qualities: Array<string>,
simulcast: boolean,
free: boolean,
available: boolean,
download: boolean,
basedOn: string,
tagline: Array<string>,
firstReleaseYear: string,
productionStudio: string,
countryOfOrigin: string,
productionTeam: Array<{
role: string,
name: string,
}>,
nextVideoReleaseDate: string,
indexable: boolean
}
indexable: boolean
}
export interface ADNEpisodes extends Array<ADNEpisode> {}

View File

@ -3,7 +3,7 @@
<div class="relative flex flex-row items-center justify-center">
<button
v-if="tab === 2"
@click=";(tab = 1), (added = false), (CRselectedShow = null)"
@click=";(tab = 1), (added = false), (CRselectedShow = null), (ADNselectedShow = null)"
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"
>
@ -19,7 +19,10 @@
<option value="adn">ADN</option>
</select>
</div>
<div v-if="isLoggedInCR && service === 'crunchyroll' || isLoggedInADN && service === 'adn'" class="relative flex flex-col">
<div v-if="service === 'adn'" class="text-sm text-center">
ADN downloader is still in beta and can only download ADN Germany
</div>
<div v-if="(isLoggedInCR && service === 'crunchyroll') || (isLoggedInADN && service === 'adn')" class="relative flex flex-col">
<input
v-model="search"
@input="handleInputChange"
@ -61,10 +64,10 @@
</button>
</div>
</div>
<div v-if="isLoggedInCR && service === 'crunchyroll' || isLoggedInADN && service === 'adn'" class="relative flex flex-col">
<div v-if="(isLoggedInCR && service === 'crunchyroll') || (isLoggedInADN && service === 'adn')" 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 v-if="isLoggedInCR && service === 'crunchyroll' || isLoggedInADN && service === 'adn'" class="relative flex flex-col">
<div v-if="(isLoggedInCR && service === 'crunchyroll') || (isLoggedInADN && service === 'adn')" class="relative flex flex-col">
<input
@click="getFolderPath()"
v-model="path"
@ -76,14 +79,10 @@
/>
</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>
<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 v-if="!isLoggedInADN && service === 'adn'" class="relative flex flex-col">
<button @click="openADNLogin"
class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer"
>Click to Login</button>
<button @click="openADNLogin" 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">
@ -98,14 +97,14 @@
</div>
</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">
<div v-if="service === 'crunchyroll'" 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">
<div v-if="service === 'crunchyroll'" 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
@ -119,7 +118,21 @@
<div class="text-sm">Loading</div>
</div>
</div>
<div class="relative flex flex-col">
<div v-if="service === 'adn'" class="relative flex flex-col">
<select v-model="selectedStartEpisodeADN" 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 episodesADN" :value="episode" class="text-sm text-slate-200"
>E{{ episode.shortNumber ? episode.shortNumber : episode.number }} - {{ episode.name }}</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 v-if="service === 'crunchyroll'" 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"
@ -138,7 +151,26 @@
<div class="text-sm">Loading</div></div
>
</div>
<div class="relative flex flex-col select-none">
<div v-if="service === 'adn'" class="relative flex flex-col">
<select v-model="selectedEndEpisodeADN" name="episode" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option
v-if="episodesADN && selectedEndEpisodeADN"
v-for="(episode, index) in episodesADN"
:value="episode"
class="text-sm text-slate-200"
:disabled="index < episodesADN.findIndex((i) => i.id === selectedStartEpisodeADN?.id)"
>E{{ episode.shortNumber ? episode.shortNumber : episode.number }} - {{ episode.name }}</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 v-if="service === 'crunchyroll'" 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(', ') }}
@ -156,7 +188,7 @@
</button>
</div>
</div>
<div class="relative flex flex-col select-none">
<div v-if="service === 'crunchyroll'" 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.length !== 0 ? selectedSubs.map((t) => t.name).join(', ') : 'No Subs selected' }}
@ -175,7 +207,7 @@
</div>
</div>
<div class="flex flex-row gap-3">
<div class="relative flex flex-col w-full">
<div v-if="service === 'crunchyroll'" 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>
<option :value="true" class="text-sm text-slate-200">Hardsub: true</option>
@ -188,7 +220,7 @@
<div class="text-sm">Loading</div></div
>
</div>
<div class="relative flex flex-col w-full">
<div v-if="service === 'crunchyroll'" class="relative flex flex-col w-full">
<select v-model="quality" name="quality" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option :value="1080" class="text-sm text-slate-200">1080p</option>
<option :value="720" class="text-sm text-slate-200">720p</option>
@ -197,6 +229,13 @@
<option :value="240" class="text-sm text-slate-200">240p</option>
</select>
</div>
<div v-if="service === 'adn'" class="relative flex flex-col w-full">
<select v-model="qualityADN" name="quality" class="bg-[#5c5b5b] focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer">
<option :value="1080" class="text-sm text-slate-200">1080p</option>
<option :value="720" class="text-sm text-slate-200">720p</option>
<option :value="480" class="text-sm text-slate-200">480p</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>
@ -207,7 +246,7 @@
<!-- {{ 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 v-if="!added" class="relative flex flex-col mt-auto">
<div v-if="!added && service === 'crunchyroll'" 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 Download</div>
@ -218,9 +257,20 @@
</div>
</button>
</div>
<div v-if="!added && service === 'adn'" class="relative flex flex-col mt-auto">
<button @click="addToPlaylistADN" 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 Download</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 v-if="added" class="relative flex flex-row gap-5 mt-auto">
<button
@click=";(tab = 1), (added = false), (CRselectedShow = null)"
@click=";(tab = 1), (added = false), (CRselectedShow = null), (ADNselectedShow = null)"
class="relative py-3 border-2 rounded-xl flex flex-row items-center justify-center cursor-default w-full"
>
<div class="flex gap-1 flex-row items-center justify-center transition-all">
@ -241,6 +291,8 @@
<script lang="ts" setup>
import { searchADN } from '~/components/ADN/ListAnimes'
import { getEpisodesWithShowIdADN } from '~/components/ADN/ListEpisodes'
import type { ADNEpisode, ADNEpisodes } from '~/components/ADN/Types'
import { checkAccount } from '~/components/Crunchyroll/Account'
import { getCRSeries, searchCrunchy } from '~/components/Crunchyroll/ListAnimes'
import { listEpisodeCrunchy } from '~/components/Crunchyroll/ListEpisodes'
@ -305,10 +357,15 @@ const isFetchingSeasons = ref<number>(0)
const isFetchingEpisodes = ref<number>(0)
const isFetchingResults = ref<number>(0)
const episodesADN = ref<ADNEpisodes | undefined>()
const selectedStartEpisodeADN = ref<ADNEpisode>()
const selectedEndEpisodeADN = ref<ADNEpisode>()
const qualityADN = ref<1080 | 720 | 480>(1080)
const isLoggedInCR = ref<boolean>(false)
const isLoggedInADN = ref<boolean>(false)
let intervalcr: NodeJS.Timeout;
let intervaladn: NodeJS.Timeout;
const isLoggedInADN = ref<boolean>(false)
let intervalcr: NodeJS.Timeout
let intervaladn: NodeJS.Timeout
const checkIfLoggedInCR = async () => {
const { data, error } = await checkAccount('CR')
@ -326,12 +383,12 @@ const checkIfLoggedInCR = async () => {
}
const openCRLogin = () => {
(window as any).myAPI.openWindow({
title: "Crunchyroll Login",
;(window as any).myAPI.openWindow({
title: 'Crunchyroll Login',
url: isProduction ? 'http://localhost:8079/crunchylogin' : 'http://localhost:3000/crunchylogin',
width: 600,
height: 300,
backgroundColor: "#111111"
backgroundColor: '#111111'
})
intervalcr = setInterval(checkIfLoggedInCR, 1000)
@ -353,18 +410,17 @@ const checkIfLoggedInADN = async () => {
}
const openADNLogin = () => {
(window as any).myAPI.openWindow({
title: "ADN Login",
;(window as any).myAPI.openWindow({
title: 'ADN Login',
url: isProduction ? 'http://localhost:8079/adnlogin' : 'http://localhost:3000/adnlogin',
width: 600,
height: 300,
backgroundColor: "#111111"
backgroundColor: '#111111'
})
intervalcr = setInterval(checkIfLoggedInADN, 1000)
}
checkIfLoggedInCR()
checkIfLoggedInADN()
@ -408,7 +464,7 @@ const handleInputChange = () => {
watch(search, () => {
if (search.value.length === 0 || !search.value) {
searchActive.value = false;
searchActive.value = false
}
})
@ -451,9 +507,7 @@ watch(selectedSeason, () => {
})
watch(service, () => {
url.value = "",
CRselectedShow.value = null,
ADNselectedShow.value = null
;(url.value = ''), (CRselectedShow.value = null), (ADNselectedShow.value = null)
})
watch(selectedStartEpisode, () => {
@ -468,6 +522,18 @@ watch(selectedStartEpisode, () => {
}
})
watch(selectedStartEpisodeADN, () => {
if (!selectedEndEpisodeADN.value) return
if (!episodesADN.value) return
const indexA = episodesADN.value.findIndex((i) => i === selectedStartEpisodeADN.value)
const indexE = episodesADN.value.findIndex((i) => i === selectedEndEpisodeADN.value)
if (indexA > indexE) {
selectedEndEpisodeADN.value = episodesADN.value[indexA]
}
})
const refetchEpisodes = async () => {
isFetchingEpisodes.value++
if (!selectedSeason.value) {
@ -490,6 +556,19 @@ const switchToSeason = async () => {
return
}
if (ADNselectedShow.value) {
episodesADN.value = await getEpisodesWithShowIdADN(ADNselectedShow.value.id)
if (!episodesADN.value) {
isFetchingSeasons.value--
return
}
selectedStartEpisodeADN.value = episodesADN.value[0]
selectedEndEpisodeADN.value = episodesADN.value[0]
tab.value = 2
selectedDubs.value = [{ locale: 'ja-JP', name: 'JP' }],
selectedSubs.value = [{ locale: 'de-DE', name: 'DE' }]
}
if (CRselectedShow.value) {
seasons.value = await listSeasonCrunchy(CRselectedShow.value.ID)
if (!seasons.value) {
@ -503,6 +582,7 @@ const switchToSeason = async () => {
selectedEndEpisode.value = episodes.value[0]
}
tab.value = 2
selectedDubs.value = [{ locale: 'ja-JP', name: 'JP' }]
}
if (url.value && url.value.includes('crunchyroll') && !CRselectedShow.value) {
@ -520,9 +600,10 @@ const switchToSeason = async () => {
selectedEndEpisode.value = episodes.value[0]
}
tab.value = 2
selectedDubs.value = [{ locale: 'ja-JP', name: 'JP' }]
}
;(selectedDubs.value = [{ locale: 'ja-JP', name: 'JP' }]), isFetchingSeasons.value--
isFetchingSeasons.value--
}
const toggleDub = (lang: { name: string | undefined; locale: string }) => {
@ -571,18 +652,6 @@ 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 = {
@ -592,7 +661,43 @@ const addToPlaylist = async () => {
dir: path.value,
hardsub: hardsub.value,
quality: quality.value,
service: selService,
service: 'CR',
format: format.value
}
const { error } = await useFetch('http://localhost:8080/api/service/playlist', {
method: 'POST',
body: JSON.stringify(data)
})
if (error.value) {
alert(error.value)
}
added.value = true
}
const addToPlaylistADN = async () => {
if (!episodesADN.value) return
const startEpisodeIndex = episodesADN.value.findIndex((episode) => episode === selectedStartEpisodeADN.value)
const endEpisodeIndex = episodesADN.value.findIndex((episode) => episode === selectedEndEpisodeADN.value)
if (startEpisodeIndex === -1 || endEpisodeIndex === -1) {
console.error('Indexes not found.')
return
}
const selectedEpisodes = episodesADN.value.slice(startEpisodeIndex, endEpisodeIndex + 1)
const data = {
episodes: selectedEpisodes,
dubs: selectedDubs.value,
subs: selectedSubs.value,
dir: path.value,
hardsub: false,
quality: qualityADN.value,
service: 'ADN',
format: format.value
}

View File

@ -6,19 +6,27 @@
Delete Playlist
</button> -->
<div v-for="p in playlist" class="flex flex-row gap-4 h-40 p-5 bg-[#636363] border-b-[1px] border-gray-400">
<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 v-if="p.service === 'CR'" class="flex min-w-52 w-52">
<img :src="(p.media as CrunchyEpisode).images.thumbnail[0].find((p) => p.height === 1080)?.source" alt="Image" class="object-cover rounded-xl" />
</div>
<div v-if="p.service === 'ADN'" class="flex min-w-52 w-52">
<img :src="(p.media as ADNEpisode).image2x" 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 }}
{{ p.status }}
</div>
<div class="text-sm capitalize ml-auto">
{{ p.service === 'CR' ? 'Crunchyroll' : 'ADN' }}
</div>
</div>
<div class="text-sm capitalize ml-auto">
{{ p.service === 'CR' ? 'Crunchyroll' : 'ADN' }}
<div v-if="p.service === 'CR'" class="text-base capitalize">
{{ (p.media as CrunchyEpisode).series_title }} Season {{ (p.media as CrunchyEpisode).season_number }} Episode {{ (p.media as CrunchyEpisode).episode_number }}
</div>
<div v-if="p.service === 'ADN'" class="text-base capitalize">
{{ (p.media as ADNEpisode).show.title }} Season {{ (p.media as ADNEpisode).season ? (p.media as ADNEpisode).season : 1 }} Episode {{ (p.media as ADNEpisode).shortNumber }}
</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'"
@ -38,19 +46,20 @@
<div v-if="p.downloadspeed && p.status === 'downloading'" class="text-sm">{{ p.downloadspeed }} MB/s</div>
</div>
</div>
</div> </div
>s
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { ADNEpisode } from '~/components/ADN/Types'
import type { CrunchyEpisode } from '~/components/Episode/Types'
const playlist = ref<
Array<{
id: number
status: string
media: CrunchyEpisode
media: CrunchyEpisode | ADNEpisode
dub: Array<{ locale: string; name: string }>
sub: Array<{ locale: string; name: string }>
dir: string
@ -68,7 +77,7 @@ const getPlaylist = async () => {
Array<{
id: number
status: string
media: CrunchyEpisode
media: CrunchyEpisode | ADNEpisode
dub: Array<{ locale: string; name: string }>
sub: Array<{ locale: string; name: string }>
dir: string

View File

@ -1,6 +1,7 @@
import { app } from 'electron'
import { Sequelize, DataTypes, ModelDefined } from 'sequelize'
import { CrunchyEpisode } from '../types/crunchyroll'
import { ADNEpisode } from '../types/adn'
const sequelize = new Sequelize({
dialect: 'sqlite',
@ -29,7 +30,7 @@ interface AccountCreateAttributes {
interface PlaylistAttributes {
id: number
status: 'waiting' | 'preparing' | 'downloading' | 'merging' | 'completed' | 'failed'
media: CrunchyEpisode
media: CrunchyEpisode | ADNEpisode
dub: Array<string>
sub: Array<string>
hardsub: boolean
@ -41,7 +42,7 @@ interface PlaylistAttributes {
}
interface PlaylistCreateAttributes {
media: CrunchyEpisode
media: CrunchyEpisode | ADNEpisode
dub: Array<string>
sub: Array<string>
dir: string

View File

@ -1,394 +1,16 @@
import JSEncrypt from 'jsencrypt'
import CryptoJS from 'crypto-js'
import { server } from '../../api'
import { ADNPlayerConfig } from '../../types/adn'
import { ADNLink, 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}`)
// 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
// }
// }
import { loggedInCheck } from '../service/service.service'
import { parse as mpdParse, parse } from 'mpd-parser'
export async function adnLogin(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
accessToken: string
}
| undefined = server.CacheController.get('adntoken')
@ -432,11 +54,11 @@ async function adnLoginFetch(user: string, passw: string) {
}
const { data, error } = await useFetch<{
accessToken: string;
accessToken: string
}>('https://gw.api.animationdigitalnetwork.fr/authentication/login', {
type: 'POST',
body: JSON.stringify(body),
header: headers,
header: headers
})
if (error) {
@ -449,3 +71,238 @@ async function adnLoginFetch(user: string, passw: string) {
return { data: data, error: null }
}
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 getPlayerConfigADN(id: number) {
const account = await loggedInCheck('ADN')
if (!account) return
const token = await adnLogin(account.username, account.password)
if (!token.data?.accessToken) return
try {
const response = await fetch(`https://gw.api.animationdigitalnetwork.fr/player/video/${id}/configuration`, {
method: 'GET',
headers: {
'x-target-distribution': 'de',
'Content-Type': 'application/json',
Authorization: `Bearer ${token.data.accessToken}`
}
})
if (response.ok) {
const data: ADNPlayerConfig = JSON.parse(await response.text())
return data
} else {
throw new Error('Failed to fetch ADN')
}
} catch (e) {
throw new Error(e as string)
}
}
async function getPlayerToken(id: number) {
const r = await getPlayerConfigADN(id)
if (!r) return
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(id: number) {
const token = await getPlayerToken(id)
if (!token) return
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 adnGetPlaylist(animeid: number) {
const token = await getPlayerEncryptedToken(animeid)
if (!token) return
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: ADNLink = await JSON.parse(await response.text())
return { data: data, secret: token.random }
} else {
const data: ADNLink = JSON.parse(await response.text())
return { data: data, secret: token.random }
}
} catch (e) {
throw new Error(e as string)
}
}
export async function adnGetM3U8Playlist(url: string) {
try {
const response = await fetch(url, {
method: 'GET',
})
if (response.ok) {
const data: { location: string } = await JSON.parse(await response.text())
const mu8 = await fetch(data.location, {
method: 'GET',
})
const playlist = await mu8.text()
const url = await extractURLFromPlaylist(playlist)
const partsraw = await fetch(url, {
method: 'GET',
})
const parts = await partsraw.text()
const baseurl = await extractBaseURL(url);
const partsArray = await extractSequenceURLs(parts, baseurl)
return partsArray
}
} catch (e) {
throw new Error(e as string)
}
}
async function extractURLFromPlaylist(playlist: string) {
var startIndex = playlist.indexOf("http");
var endIndex = playlist.indexOf(" ", startIndex);
var extractedURL = playlist.slice(startIndex, endIndex);
return extractedURL;
}
async function extractBaseURL(playlistURL: string) {
var baseURL = playlistURL.substring(0, playlistURL.lastIndexOf("/") + 1);
return baseURL;
}
async function extractSequenceURLs(playlistText: string, baseURL: string) {
var sequenceURLs: Array<{ filename: string, url: string }> = [];
var matches = playlistText.match(/sequence_\d+\.ts/g);
if (matches) {
matches.forEach(function(match) {
sequenceURLs.push({ filename: match, url: baseURL + match });
});
}
return sequenceURLs;
}
export async function parseSubs(url: string, secret: string) {
const response = await fetch(url)
const data = await response.text()
var key = secret + '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
}
}

View File

@ -2,7 +2,7 @@ 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 { downloadADNSub, downloadCRSub } from '../../services/subs'
import { CrunchyEpisode } from '../../types/crunchyroll'
import { crunchyGetPlaylist, crunchyGetPlaylistMPD } from '../crunchyroll/crunchyroll.service'
import fs from 'fs'
@ -10,6 +10,8 @@ var cron = require('node-cron')
import { Readable } from 'stream'
import { finished } from 'stream/promises'
import Ffmpeg from 'fluent-ffmpeg'
import { adnGetM3U8Playlist, adnGetPlaylist } from '../adn/adn.service'
import { ADNEpisode } from '../../types/adn'
const ffmpegPath = require('ffmpeg-static').replace('app.asar', 'app.asar.unpacked')
const ffprobePath = require('ffprobe-static').path.replace('app.asar', 'app.asar.unpacked')
@ -104,19 +106,34 @@ async function checkPlaylists() {
isDownloading++
if (e.dataValues.service === 'CR') {
downloadCrunchyrollPlaylist(
e.dataValues.media.id,
(e.dataValues.media as CrunchyEpisode).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.media as CrunchyEpisode).series_title,
(e.dataValues.media as CrunchyEpisode).season_number,
(e.dataValues.media as CrunchyEpisode).episode_number,
e.dataValues.quality,
e.dataValues.dir,
e.dataValues.format
)
}
if (e.dataValues.service === 'ADN') {
downloadADNPlaylist(
(e.dataValues.media as ADNEpisode).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.id,
(e.dataValues.media as ADNEpisode).show.title,
(e.dataValues.media as ADNEpisode).season,
(e.dataValues.media as ADNEpisode).shortNumber,
e.dataValues.quality,
e.dataValues.dir,
e.dataValues.format
)
console.log(e.dataValues.media)
}
}
}
}
@ -125,6 +142,98 @@ cron.schedule('*/2 * * * * *', () => {
checkPlaylists()
})
// Download ADN Playlist
export async function downloadADNPlaylist(
e: number,
dubs: Array<string>,
subs: Array<string>,
downloadID: number,
name: string,
season: string,
episode: string,
quality: 1080 | 720 | 480 | 360 | 240,
downloadPath: string,
format: 'mp4' | 'mkv'
) {
downloading.push({
id: downloadID,
downloadedParts: 0,
partsToDownload: 0,
downloadSpeed: 0
})
if (!season) {
season = "1"
}
await updatePlaylistByID(downloadID, 'downloading')
var playlist = await adnGetPlaylist(e)
const subFolder = await createFolder()
const videoFolder = await createFolder()
const seasonFolder = await createFolderName(`${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season}`, downloadPath)
const subDownload = async () => {
if (!playlist) return
const sbs: Array<string> = []
const name = await downloadADNSub(playlist.data.links.subtitles.all, subFolder, playlist.secret)
sbs.push(name)
return sbs
}
const downloadVideo = async () => {
var code
if (!playlist) return
var link: string = '';
switch (quality) {
case 1080:
link = playlist.data.links.streaming.vostde.fhd
break
case 720:
link = playlist.data.links.streaming.vostde.hd
break
case 480:
link = playlist.data.links.streaming.vostde.sd
break
}
if (!link) return
var m3u8 = await adnGetM3U8Playlist(link)
if (!m3u8) return
const dn = downloading.find((i) => i.id === downloadID)
if (dn) {
dn.partsToDownload = m3u8.length
}
const file = await downloadParts(m3u8, downloadID, videoFolder)
return file
}
const [subss, file] = await Promise.all([subDownload(), downloadVideo()])
if (!subss) return
await mergeVideoFile(file as string, [], [], String(playlist?.data.video.guid), seasonFolder, `${name.replace(/[/\\?%*:|"<>]/g, '')} Season ${season} Episode ${episode}`, format)
await updatePlaylistByID(downloadID, 'completed')
await deleteFolder(subFolder)
await deleteFolder(videoFolder)
return playlist
}
// Download Crunchyroll Playlist
export async function downloadCrunchyrollPlaylist(
e: string,
@ -509,7 +618,7 @@ async function mergeVideoFile(video: string, audios: Array<string>, subs: Array<
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')
options.push('-c:s mov_text')
}
for (const [index, a] of audios.entries()) {

View File

@ -2,6 +2,7 @@ import fs from 'fs'
import { parse, stringify } from 'ass-compiler'
import { Readable } from 'stream'
import { finished } from 'stream/promises'
import CryptoJS from 'crypto-js'
export async function downloadCRSub(
sub: {
@ -37,3 +38,60 @@ export async function downloadCRSub(
return path
}
export async function downloadADNSub(
link: string,
dir: string,
secret: string
) {
const path = `${dir}/de-DE.ass`
const stream = fs.createWriteStream(path)
const subURLFetch = await fetch(link)
const subURL: {
location: string
} = JSON.parse(await subURLFetch.text())
const rawSubsFetch = await fetch(subURL.location)
const rawSubs = await rawSubsFetch.text()
const subs = await ADNparseSub(rawSubs, secret)
// 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([subs])
await finished(readableStream.pipe(stream))
console.log(`Sub downloaded`)
return path
}
export async function ADNparseSub(raw: string, secret: string) {
var key = secret + '7fac1178830cfe0c'
console.log(key)
var parsedSubtitle = CryptoJS.enc.Base64.parse(raw.substring(0, 24))
var sec = CryptoJS.enc.Hex.parse(key)
var som = raw.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
}
}

View File

@ -1,37 +1,145 @@
export interface ADNPlayerConfig {
player: {
image: string,
options: {
user: {
hasAccess: true,
profileId: number,
refreshToken: string,
refreshTokenUrl: string
},
chromecast: {
appId: string,
refreshTokenUrl: string
},
ios: {
videoUrl: string,
appUrl: string,
title: string
},
video: {
startDate: string,
currentDate: string,
available: boolean,
free: boolean,
url: string
},
dock: Array<string>,
preference: {
quality: string,
autoplay: boolean,
language: string,
green: boolean
}
}
player: {
image: string
options: {
user: {
hasAccess: true
profileId: number
refreshToken: string
refreshTokenUrl: string
}
chromecast: {
appId: string
refreshTokenUrl: string
}
ios: {
videoUrl: string
appUrl: string
title: string
}
video: {
startDate: string
currentDate: string
available: boolean
free: boolean
url: string
}
dock: Array<string>
preference: {
quality: string
autoplay: boolean
language: string
green: boolean
}
}
}
}
}
export interface ADNLink {
links: {
streaming: {
vostde: {
mobile: string
sd: string
hd: string
fhd: string
auto: string
}
}
subtitles: {
all: string
}
history: string
nextVideoUrl: string
previousVideoUrl: string
}
video: {
guid: string
id: number
currentTime: number
duration: number
url: string
image: string
tcEpisodeStart: string
tcEpisodeEnd: string
tcIntroStart: string
tcIntroEnd: string
tcEndingStart: string
tcEndingEnd: string
}
metadata: {
title: string
subtitle: string
summary: string
rating: number
}
}
export interface ADNEpisode {
id: number,
title: string,
name: string,
number: string,
shortNumber: string,
season: string,
reference: string,
type: string,
order: number,
image: string,
image2x: string,
summary: string,
releaseDate: string,
duration: number,
url: string,
urlPath: string,
embeddedUrl: string,
languages: Array<string>,
qualities: Array<string>,
rating: number,
ratingsCount: number,
commentsCount: number,
available: boolean,
download: boolean,
free: boolean,
freeWithAds: boolean,
show: {
id: number,
title: string,
type: string,
originalTitle: string,
shortTitle: string,
reference: string,
age: string,
languages: Array<string>,
summary: string,
image: string,
image2x: string,
imageHorizontal: string,
imageHorizontal2x: string,
url: string,
urlPath: string,
episodeCount: number,
genres: Array<string>,
copyright: string,
rating: number,
ratingsCount: number,
commentsCount: number,
qualities: Array<string>,
simulcast: boolean,
free: boolean,
available: boolean,
download: boolean,
basedOn: string,
tagline: Array<string>,
firstReleaseYear: string,
productionStudio: string,
countryOfOrigin: string,
productionTeam: Array<{
role: string,
name: string,
}>,
nextVideoReleaseDate: string,
indexable: boolean
}
indexable: boolean
}