added better activestreams limiter and account limit checker

This commit is contained in:
stratuma 2024-05-23 02:22:58 +02:00
parent cecdd683c5
commit b52d93a7f3
10 changed files with 410 additions and 32 deletions

View File

@ -5,7 +5,7 @@
Crunchyroll <br />
Downloader
</div>
<div class="text-sm mt-1 text-gray-200"> v1.1.3 </div>
<div class="text-sm mt-1 text-gray-200"> v1.1.5 </div>
<div class="absolute right-0 bottom-0 text-xs text-gray-200"> Made by Stratum </div>
</div>
</template>

View File

@ -0,0 +1,90 @@
<template>
<div class="flex flex-col gap-3 mt-3 font-dm" style="-webkit-app-region: no-drag">
<div class="flex flex-col items-center p-3 bg-[#11111189] rounded-xl select-none">
<div class="text-sm mb-2">Episode File Naming</div>
<input
v-model="episodeNamingTemplate"
type="text"
name="text"
placeholder="Episode Naming"
class="bg-[#5c5b5b] w-full focus:outline-none px-3 py-2 rounded-xl text-sm text-center"
/>
<div class="text-sm mt-2">
Example:
</div>
<div class="text-sm">
{{ `${episodeNaming}` }}
</div>
<div class="text-sm mt-2">
Variables:
</div>
<div class="text-sm text-center">
{seriesName}, {seasonNumber}, {seasonNumberDD}, {episodeNumber}, {episodeNumberDD}, {quality}
</div>
</div>
<div class="flex flex-col items-center p-3 bg-[#11111189] rounded-xl select-none">
<div class="text-sm mb-2">Season Folder Naming</div>
<input
v-model="seasonNamingTemplate"
type="text"
name="text"
placeholder="Episode Naming"
class="bg-[#5c5b5b] w-full focus:outline-none px-3 py-2 rounded-xl text-sm text-center"
/>
<div class="text-sm mt-2">
Example:
</div>
<div class="text-sm">
{{ `${seasonNaming}` }}
</div>
<div class="text-sm mt-2">
Variables:
</div>
<div class="text-sm text-center">
{seriesName}, {seasonNumber}, {seasonNumberDD}, {quality}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
const episodeNumber = ref<number>(1);
const seasonNumber = ref<number>(1);
const quality = ref<number>(1080);
const seriesName = ref<string>('Frieren');
const episodeNamingTemplate = ref<string>();
const seasonNamingTemplate = ref<string>();
const episodeNaming = computed(() => {
if (!episodeNamingTemplate.value) return
return episodeNamingTemplate.value
.replace('{seriesName}', seriesName.value)
.replace('{seasonNumber}', seasonNumber.value.toString())
.replace('{seasonNumberDD}', seasonNumber.value.toString().padStart(2, '0'))
.replace('{episodeNumber}', episodeNumber.value.toString())
.replace('{episodeNumberDD}', episodeNumber.value.toString().padStart(2, '0'))
.replace('{quality}', quality.value.toString() +'p');
});
const seasonNaming = computed(() => {
if (!seasonNamingTemplate.value) return
return seasonNamingTemplate.value
.replace('{seriesName}', seriesName.value)
.replace('{seasonNumber}', seasonNumber.value.toString())
.replace('{seasonNumberDD}', seasonNumber.value.toString().padStart(2, '0'))
.replace('{quality}', quality.value.toString() +'p');
});
onMounted(() => {
;(window as any).myAPI.getSeasonTemplate().then((result: string) => {
seasonNamingTemplate.value = result
})
;(window as any).myAPI.getEpisodeTemplate().then((result: string) => {
episodeNamingTemplate.value = result
})
})
</script>
<style></style>

View File

@ -0,0 +1,36 @@
<template>
<div class="flex flex-col gap-3 mt-3 font-dm" style="-webkit-app-region: no-drag">
<div class="flex flex-col items-center p-3 bg-[#11111189] rounded-xl select-none">
<div class="text-sm mb-2">Default TEMP Path</div>
<input
@click="getTEMPFolder()"
v-model="tempPath"
type="text"
name="text"
placeholder="Select a TEMP Folder"
class="bg-[#5c5b5b] w-full focus:outline-none px-3 py-2 rounded-xl text-sm text-center cursor-pointer"
readonly
/>
</div>
</div>
</template>
<script lang="ts" setup>
const tempPath = ref<string>()
const getTEMPFolder = () => {
if (process.client) {
;(window as any).myAPI.selectTEMPFolder().then((result: string) => {
tempPath.value = result
})
}
}
onMounted(() => {
;(window as any).myAPI.getTEMPFolder().then((result: any) => {
tempPath.value = result
})
})
</script>
<style></style>

View File

@ -2,7 +2,7 @@
"name": "crunchyroll-downloader",
"author": "Stratum",
"description": "Crunchyroll Downloader",
"version": "1.1.4",
"version": "1.1.5",
"private": true,
"main": ".output/src/electron/background.js",
"repository": "https://github.com/stratuma/Crunchyroll-Downloader-v4.0",

View File

@ -14,15 +14,17 @@
</button>
</div>
<SettingsMain v-if="activeIndex === 0" />
<SettingsCrunchyroll v-if="activeIndex === 1" />
<SettingsWidevine v-if="activeIndex === 2" />
<SettingsProxy v-if="activeIndex === 3" />
<SettingsAbout v-if="activeIndex === 4" />
<SettingsNaming v-if="activeIndex === 1" />
<SettingsCrunchyroll v-if="activeIndex === 2" />
<SettingsWidevine v-if="activeIndex === 3" />
<SettingsProxy v-if="activeIndex === 4" />
<SettingsSystem v-if="activeIndex === 5" />
<SettingsAbout v-if="activeIndex === 6" />
</div>
</template>
<script lang="ts" setup>
const options = ref<Array<string>>(['Main', 'Crunchyroll', 'Widevine', 'Proxy', 'About'])
const options = ref<Array<string>>(['Main', 'Naming', 'Crunchy', 'Widevine', 'Proxy', 'System', 'About'])
const activeIndex = ref(0)
</script>

View File

@ -129,7 +129,7 @@ async function crunchyLoginFetchProxy(user: string, passw: string, geo: string)
headers = {
Authorization: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=',
'Content-Type': 'application/json',
'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
'User-Agent': 'Crunchyroll/3.46.2 Android/13 okhttp/4.12.0'
}
body = {
@ -139,7 +139,7 @@ async function crunchyLoginFetchProxy(user: string, passw: string, geo: string)
scope: 'offline_access',
device_name: 'RMX2170',
device_type: 'realme RMX2170',
ursa: 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27',
ursa: 'Crunchyroll/3.46.2 Android/13 okhttp/4.12.0',
token: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4='
}
}
@ -218,7 +218,7 @@ async function crunchyLoginFetch(user: string, passw: string) {
headers = {
Authorization: 'Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
'User-Agent': 'Crunchyroll/3.46.2 Android/13 okhttp/4.12.0'
}
body = {
@ -377,7 +377,7 @@ export async function crunchyGetPlaylist(q: string, geo: string | undefined) {
const headersLoc = {
Authorization: `Bearer ${login.access_token}`,
'X-Cr-Disable-Drm': 'true',
'x-cr-stream-limits': 'true',
'x-cr-stream-limits': 'false',
'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
}
@ -428,6 +428,14 @@ export async function crunchyGetPlaylist(q: string, geo: string | undefined) {
await deleteVideoToken(e.contentId, e.token)
}
server.logger.log({
level: 'error',
message: 'Refetching Crunchyroll Video Playlist & Deleting all Video Token because too many streams',
error: errorJSON,
timestamp: new Date().toISOString(),
section: 'playlistCrunchyrollFetch'
})
return await crunchyGetPlaylist(q, geo)
}
@ -458,7 +466,7 @@ export async function crunchyGetPlaylist(q: string, geo: string | undefined) {
'User-Agent': 'Crunchyroll/1.8.0 Nintendo Switch/12.3.12.0 UE4/4.27'
}
const response = await fetch(
const responseProx = await fetch(
`https://cr-play-service.prd.crunchyrollsvc.com/v1/${q}${
endpoints.find((e) => e.id === endpoint) ? endpoints.find((e) => e.id === endpoint)?.url : '/console/switch/play'
}`,
@ -468,30 +476,32 @@ export async function crunchyGetPlaylist(q: string, geo: string | undefined) {
}
)
if (response.ok) {
const data: VideoPlaylistNoGEO = JSON.parse(await response.text())
if (responseProx.ok) {
const dataProx: VideoPlaylistNoGEO = JSON.parse(await responseProx.text())
data.hardSubs = Object.values((data as any).hardSubs)
dataProx.hardSubs = Object.values((dataProx as any).hardSubs)
data.subtitles = Object.values((data as any).subtitles)
dataProx.subtitles = Object.values((dataProx as any).subtitles)
for (const v of data.versions) {
for (const v of dataProx.versions) {
if (!playlist.versions.find((ver) => ver.guid === v.guid)) {
playlist.versions.push({ ...v, geo: p.code })
}
}
for (const v of data.subtitles) {
for (const v of dataProx.subtitles) {
if (!playlist.subtitles.find((ver) => ver.language === v.language)) {
playlist.subtitles.push({ ...v, geo: p.code })
}
}
for (const v of data.hardSubs) {
for (const v of dataProx.hardSubs) {
if (!playlist.hardSubs.find((ver) => ver.hlang === v.hlang)) {
playlist.hardSubs.push({ ...v, geo: p.code })
}
}
await deleteVideoToken(q, dataProx.token)
}
}
}
@ -513,8 +523,8 @@ export async function deleteVideoToken(content: string, token: string) {
Authorization: `Bearer ${login.access_token}`
}
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/token/${content}/${token}/inactive`, {
method: 'PATCH',
const response = await fetch(`https://cr-play-service.prd.crunchyrollsvc.com/v1/token/${content}/${token}`, {
method: 'DELETE',
headers: headers
})
@ -584,3 +594,109 @@ export async function crunchyGetPlaylistMPD(q: string, geo: string | undefined)
throw new Error(e as string)
}
}
export async function getAccountInfo() {
const account = await loggedInCheck('CR')
if (!account) return
const login = await crunchyLogin(account.username, account.password, 'LOCAL')
if (!login) return
const headers = {
Authorization: `Bearer ${login.access_token}`,
}
try {
const response = await fetch('https://beta-api.crunchyroll.com/accounts/v1/me', {
method: 'GET',
headers: headers
})
if (response.ok) {
const data: {
account_id: string,
external_id: string,
} = await JSON.parse(await response.text())
return data
} else {
const error = await response.text()
messageBox('error', ['Cancel'], 2, 'Failed to get Crunchyroll Account Info', 'Failed to get Crunchyroll Account Info', error)
server.logger.log({
level: 'error',
message: 'Failed to get Crunchyroll Account Info',
error: error,
timestamp: new Date().toISOString(),
section: 'settingsCrunchyrollFetch'
})
throw new Error(await response.text())
}
} catch (e) {
throw new Error(e as string)
}
}
// Check Max account streams because of crunchyroll activestream limit
export async function checkAccountMaxStreams() {
const accountinfo = await getAccountInfo()
if (!accountinfo) return 1
const account = await loggedInCheck('CR')
if (!account) return 1
const login = await crunchyLogin(account.username, account.password, 'LOCAL')
if (!login) return 1
const headers = {
Authorization: `Bearer ${login.access_token}`,
}
try {
const response = await fetch(`https://beta-api.crunchyroll.com/subs/v1/subscriptions/${accountinfo.external_id}/benefits`, {
method: 'GET',
headers: headers
})
if (response.ok) {
const data: {
items: {
__class__: string,
__href__: string,
__links__: string,
__actions__: string,
benefit: string,
source: string
}[]
} = await JSON.parse(await response.text())
if (!data.items || data.items.length === 0) return 1
if (data.items.find(i => i.benefit === 'concurrent_streams.4')) return 2
if (data.items.find(i => i.benefit === 'concurrent_streams.1')) return 1
if (data.items.find(i => i.benefit === 'concurrent_streams.6')) return 3
return 1
} else {
const error = await response.text()
messageBox('error', ['Cancel'], 2, 'Failed to get Crunchyroll Account Subscription', 'Failed to get Crunchyroll Account Subscription', error)
server.logger.log({
level: 'error',
message: 'Failed to get Crunchyroll Account Subscription',
error: error,
timestamp: new Date().toISOString(),
section: 'subCrunchyrollFetch'
})
throw new Error(await response.text())
}
} catch (e) {
throw new Error(e as string)
}
}

View File

@ -4,7 +4,7 @@ import { concatenateTSFiles } from '../../services/concatenate'
import { createFolder, createFolderName, deleteFolder, deleteTemporaryFolders } from '../../services/folder'
import { downloadADNSub, downloadCRSub } from '../../services/subs'
import { CrunchyEpisode } from '../../types/crunchyroll'
import { crunchyGetPlaylist, crunchyGetPlaylistMPD, deleteVideoToken } from '../crunchyroll/crunchyroll.service'
import { checkAccountMaxStreams, crunchyGetPlaylist, crunchyGetPlaylistMPD, deleteVideoToken } from '../crunchyroll/crunchyroll.service'
import fs from 'fs'
var cron = require('node-cron')
import { Readable } from 'stream'
@ -159,7 +159,7 @@ async function deletePlaylistandTMP() {
}
}
setTimeout(deletePlaylistandTMP, 500)
deletePlaylistandTMP();
// Update Playlist Item
export async function updatePlaylistByID(
@ -425,6 +425,27 @@ export async function downloadADNPlaylist(
await deleteFolder(videoFolder)
}
var counter = 0;
var maxLimit = 1;
async function incrementPlaylistCounter() {
return new Promise<void>((resolve) => {
const interval = setInterval(() => {
if (counter < maxLimit) {
counter++;
clearInterval(interval);
resolve();
}
}, 100);
});
}
function decrementPlaylistCounter() {
if (counter > 0) {
counter--;
}
}
// Download Crunchyroll Playlist
export async function downloadCrunchyrollPlaylist(
e: string,
@ -449,8 +470,15 @@ export async function downloadCrunchyrollPlaylist(
totalDownloaded: 0
})
const accmaxstream = await checkAccountMaxStreams();
if (accmaxstream) {
maxLimit = accmaxstream
}
await updatePlaylistByID(downloadID, 'downloading')
await incrementPlaylistCounter();
var playlist = await crunchyGetPlaylist(e, geo)
if (!playlist) {
@ -471,7 +499,9 @@ export async function downloadCrunchyrollPlaylist(
const found = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
if (found) {
await deleteVideoToken(episodeID, playlist.data.token)
decrementPlaylistCounter();
playlist = await crunchyGetPlaylist(found.guid, found.geo)
await incrementPlaylistCounter();
} else {
console.log('Exact Playlist not found, taking what crunchy gives.')
messageBox(
@ -499,6 +529,7 @@ export async function downloadCrunchyrollPlaylist(
}
await deleteVideoToken(episodeID, playlist.data.token)
decrementPlaylistCounter();
const subFolder = await createFolder()
@ -537,6 +568,7 @@ export async function downloadCrunchyrollPlaylist(
if (playlist.data.audioLocale !== 'ja-JP') {
const foundStream = playlist.data.versions.find((v) => v.audio_locale === 'ja-JP')
if (foundStream) {
await incrementPlaylistCounter();
subPlaylist = await crunchyGetPlaylist(foundStream.guid, foundStream.geo)
}
} else {
@ -576,7 +608,8 @@ export async function downloadCrunchyrollPlaylist(
})
}
await deleteVideoToken(episodeID, playlist.data.token)
await deleteVideoToken(episodeID, subPlaylist.data.token)
decrementPlaylistCounter()
}
for (const d of dubs) {
@ -586,16 +619,18 @@ export async function downloadCrunchyrollPlaylist(
}
if (found) {
await incrementPlaylistCounter();
const list = await crunchyGetPlaylist(found.guid, found.geo)
if (list) {
await deleteVideoToken(episodeID, list.data.token)
decrementPlaylistCounter();
const foundSub = list.data.subtitles.find((sub) => sub.language === d)
if (foundSub) {
subDownloadList.push({ ...foundSub, isDub: true })
} else {
console.log(`No Dub Sub Found for ${d}`)
}
await deleteVideoToken(episodeID, playlist.data.token)
}
dubDownloadList.push(found)
console.log(`Audio ${d}.aac found, adding to download`)
@ -642,6 +677,7 @@ export async function downloadCrunchyrollPlaylist(
const audioDownload = async () => {
const audios: Array<string> = []
for (const v of dubDownloadList) {
await incrementPlaylistCounter();
const list = await crunchyGetPlaylist(v.guid, v.geo)
if (!list) return
@ -651,6 +687,7 @@ export async function downloadCrunchyrollPlaylist(
if (!playlist) return
await deleteVideoToken(episodeID, list.data.token)
decrementPlaylistCounter();
const assetId = playlist.mediaGroups.AUDIO.audio.main.playlists[0].segments[0].resolvedUri.match(/\/assets\/(?:p\/)?([^_,]+)/)
@ -755,6 +792,7 @@ export async function downloadCrunchyrollPlaylist(
return
}
await incrementPlaylistCounter();
const play = await crunchyGetPlaylist(code, geo)
if (!play) {
@ -799,6 +837,7 @@ export async function downloadCrunchyrollPlaylist(
if (!mdp) return
await deleteVideoToken(episodeID, play.data.token)
decrementPlaylistCounter();
var hq = mdp.playlists.find((i) => i.attributes.RESOLUTION?.height === quality)

View File

@ -1,9 +1,17 @@
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import settings from 'electron-settings'
export async function createFolder() {
const tempFolderPath = path.join(app.getPath('documents'), `crd-tmp-${(Math.random() + 1).toString(36).substring(2)}`)
var tempPath = await settings.get('tempPath') as string
if (!tempPath) {
tempPath = app.getPath('temp')
}
const tempFolderPath = path.join(tempPath, `crd-tmp-${(Math.random() + 1).toString(36).substring(2)}`)
try {
await fs.promises.mkdir(tempFolderPath, { recursive: true })
return tempFolderPath
@ -25,6 +33,13 @@ export async function checkDirectoryExistence(dir: string) {
}
export async function createFolderName(name: string, dir: string) {
var tempPath = await settings.get('tempPath') as string
if (!tempPath) {
tempPath = app.getPath('temp')
}
var folderPath
const dirExists = await checkDirectoryExistence(dir)
@ -32,7 +47,7 @@ export async function createFolderName(name: string, dir: string) {
if (dirExists) {
folderPath = path.join(dir, name)
} else {
folderPath = path.join(app.getPath('documents'), name)
folderPath = path.join(tempPath, name)
}
try {
@ -54,15 +69,21 @@ export async function deleteFolder(folderPath: string) {
}
export async function deleteTemporaryFolders() {
const documentsPath = app.getPath('documents')
var tempPath = await settings.get('tempPath') as string
if (!tempPath) {
tempPath = app.getPath('temp')
}
const folderPrefix = 'crd-tmp-'
try {
const files = await fs.promises.readdir(documentsPath)
const files = await fs.promises.readdir(tempPath)
const tempFolders = files.filter((file) => file.startsWith(folderPrefix))
for (const folder of tempFolders) {
const folderPath = path.join(documentsPath, folder)
const folderPath = path.join(tempPath, folder)
await deleteFolder(folderPath)
console.log(`Temporary folder ${folder} deleted.`)
}

View File

@ -171,6 +171,38 @@ ipcMain.handle('dialog:defaultDirectory', async () => {
return savedPath
})
ipcMain.handle('dialog:getDirectoryTEMP', async () => {
const savedPath = await settings.get('tempPath')
if (!savedPath) {
const path = app.getPath('temp')
await settings.set('tempPath', path)
return path
}
return savedPath
})
ipcMain.handle('dialog:openDirectoryTEMP', async () => {
const window = BrowserWindow.getFocusedWindow()
if (!window) {
return
}
const { canceled, filePaths } = await dialog.showOpenDialog(window, {
properties: ['openDirectory']
})
if (canceled) {
return await settings.get('tempPath')
} else {
await settings.set('tempPath', filePaths[0])
return filePaths[0]
}
})
ipcMain.handle('dialog:selectEndpoint', async (events, nr: number) => {
await settings.set('CREndpoint', nr)
@ -248,6 +280,42 @@ app.on('window-all-closed', () => {
}
})
ipcMain.handle('dialog:setEpisodeTemplate', async (events, name: string) => {
await settings.set('EpisodeTemp', name)
return name
})
ipcMain.handle('dialog:getEpisodeTemplate', async (events) => {
const epTP = await settings.get('EpisodeTemp')
if (!epTP) {
await settings.set('EpisodeTemp', '{seriesName} Season {seasonNumber} Episode {episodeNumber}')
return '{seriesName} Season {seasonNumber} Episode {episodeNumber}'
}
return epTP
})
ipcMain.handle('dialog:setSeasonTemplate', async (events, name: string) => {
await settings.set('SeasonTemp', name)
return name
})
ipcMain.handle('dialog:getSeasonTemplate', async (events) => {
const seTP = await settings.get('SeasonTemp')
if (!seTP) {
await settings.set('SeasonTemp', '{seriesName} Season {seasonNumber}')
return '{seriesName} Season {seasonNumber}'
}
return seTP
})
const openWindows = new Map()
// Open New Window

View File

@ -16,5 +16,11 @@ contextBridge.exposeInMainWorld('myAPI', {
openWindow: (opt: { title: string; url: string; width: number; height: number; backgroundColor: string }) => ipcRenderer.invoke('window:openNewWindow', opt),
getUpdateStatus: () => ipcRenderer.invoke('updater:getUpdateStatus'),
startUpdateDownload: () => ipcRenderer.invoke('updater:download'),
startUpdateInstall: () => ipcRenderer.invoke('updater:quitAndInstall')
startUpdateInstall: () => ipcRenderer.invoke('updater:quitAndInstall'),
selectTEMPFolder: () => ipcRenderer.invoke('dialog:openDirectoryTEMP'),
getTEMPFolder: () => ipcRenderer.invoke('dialog:getDirectoryTEMP'),
setEpisodeTemplate: (name: string) => ipcRenderer.invoke('dialog:setEpisodeTemplate', name),
getEpisodeTemplate: () => ipcRenderer.invoke('dialog:getEpisodeTemplate'),
setSeasonTemplate: (name: string) => ipcRenderer.invoke('dialog:setSeasonTemplate', name),
getSeasonTemplate: () => ipcRenderer.invoke('dialog:getSeasonTemplate'),
})