diff --git a/components/Settings/About.vue b/components/Settings/About.vue index c64ba29..b19df98 100644 --- a/components/Settings/About.vue +++ b/components/Settings/About.vue @@ -5,7 +5,7 @@ Crunchyroll
Downloader -
v1.1.3
+
v1.1.5
Made by Stratum
diff --git a/components/Settings/Naming.vue b/components/Settings/Naming.vue new file mode 100644 index 0000000..71bac5c --- /dev/null +++ b/components/Settings/Naming.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/components/Settings/System.vue b/components/Settings/System.vue new file mode 100644 index 0000000..571ff27 --- /dev/null +++ b/components/Settings/System.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/package.json b/package.json index 710ff4c..a1847c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/settings.vue b/pages/settings.vue index 5c44850..55692c8 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -14,15 +14,17 @@ - - - - + + + + + + diff --git a/src/api/routes/crunchyroll/crunchyroll.service.ts b/src/api/routes/crunchyroll/crunchyroll.service.ts index 3f065e1..634a632 100644 --- a/src/api/routes/crunchyroll/crunchyroll.service.ts +++ b/src/api/routes/crunchyroll/crunchyroll.service.ts @@ -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) + } + } diff --git a/src/api/routes/service/service.service.ts b/src/api/routes/service/service.service.ts index 839b715..d4e7731 100644 --- a/src/api/routes/service/service.service.ts +++ b/src/api/routes/service/service.service.ts @@ -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((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 = [] 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) diff --git a/src/api/services/folder.ts b/src/api/services/folder.ts index be24039..f4c8718 100644 --- a/src/api/services/folder.ts +++ b/src/api/services/folder.ts @@ -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.`) } diff --git a/src/electron/background.ts b/src/electron/background.ts index fba46b6..1c593f6 100644 --- a/src/electron/background.ts +++ b/src/electron/background.ts @@ -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 diff --git a/src/electron/preload.ts b/src/electron/preload.ts index 228ed65..5e4b102 100644 --- a/src/electron/preload.ts +++ b/src/electron/preload.ts @@ -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'), })