From eb8b59dd30324a2ef954a6dbbe746f5f2a8560b5 Mon Sep 17 00:00:00 2001 From: Ben Adrian Sarmiento Date: Wed, 26 Apr 2023 20:57:50 +0200 Subject: [PATCH] 2.5.0 (#32) * Add search filters, add AD insta check * 2.5.0 * add groupby util * use system ids * add proper title and biggest size * add search filter buttons * update todo --- TODO.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/pages/hashlist.tsx | 12 +- src/pages/library.tsx | 8 +- src/pages/search.tsx | 317 ++++++++++++++++++++++++++++++----------- src/services/btdigg.ts | 5 +- src/utils/groupBy.ts | 4 + src/utils/mediaId.ts | 4 +- 9 files changed, 254 insertions(+), 106 deletions(-) create mode 100644 src/utils/groupBy.ts diff --git a/TODO.md b/TODO.md index 35399d2..ba82d8e 100644 --- a/TODO.md +++ b/TODO.md @@ -17,8 +17,8 @@ - [✔️] Make the architecture more scalable using docker swarm - [✔️] Find cached and selectable download on search - [✔️] 2.3.0: Show AD/RD download buttons even if status=downloaded -- [] 2.5.0: Add instant check in AD on search page (removed due to performance impact) +- [✔️] 2.5.0: Add instant check in AD on search page (removed due to performance impact) - [] 2.7.0: Rescan library button in all other pages except library -- [] 2.9.0: Add title filter buttons on search page +- [✔️] 2.9.0: Add title filter buttons on search page - [] 3.0.0: Add tests - [] 4.0.0: Refactor pages into different components diff --git a/package-lock.json b/package-lock.json index 68a92da..8030e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debrid-media-manager", - "version": "2.4.2", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debrid-media-manager", - "version": "2.4.2", + "version": "2.5.0", "dependencies": { "@ctrl/video-filename-parser": "^4.12.0", "@octokit/rest": "^19.0.7", diff --git a/package.json b/package.json index bd97e58..95ad102 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "debrid-media-manager", - "version": "2.4.2", + "version": "2.5.0", "private": false, "scripts": { "dev": "next dev", diff --git a/src/pages/hashlist.tsx b/src/pages/hashlist.tsx index d521d7b..f3b83be 100644 --- a/src/pages/hashlist.tsx +++ b/src/pages/hashlist.tsx @@ -269,8 +269,8 @@ function TorrentsPage() { if (rdKey && id.startsWith('rd:')) await deleteTorrent(rdKey, id.substring(3)); if (adKey && id.startsWith('ad:')) await deleteTorrent(adKey, id.substring(3)); if (!disableToast) toast.success(`Download canceled (${id})`); - if (id.startsWith('rd:')) removeFromRdCache(id.substring(3)); - if (id.startsWith('ad:')) removeFromAdCache(id.substring(3)); + if (id.startsWith('rd:')) removeFromRdCache(id); + if (id.startsWith('ad:')) removeFromAdCache(id); } catch (error) { if (!disableToast) toast.error(`Error deleting torrent (${id})`); throw error; @@ -312,7 +312,7 @@ function TorrentsPage() { {(totalBytes / ONE_GIGABYTE / 1024).toFixed(1)} TB - +

Share this page ({userTorrentsList.length} files in total; size:{' '} @@ -366,7 +366,7 @@ function TorrentsPage() { href="/hashlist" className="mr-2 mb-2 bg-yellow-400 hover:bg-yellow-500 text-black py-2 px-4 rounded" > - Clear filter + Reset )}

@@ -417,10 +417,6 @@ function TorrentsPage() { groupCount === 1 ? '' : 's' }` : ''; - console.log( - rd.isDownloaded(t.hash) || ad.isDownloaded(t.hash), - rd.isDownloading(t.hash) || ad.isDownloading(t.hash) - ); return ( Debrid Media Manager - Library - +

My Library ({userTorrentsList.length} downloads in total; size:{' '} @@ -649,7 +649,7 @@ function TorrentsPage() { : '' }`} > - Clear filter + Reset

diff --git a/src/pages/search.tsx b/src/pages/search.tsx index 0867951..839c98b 100644 --- a/src/pages/search.tsx +++ b/src/pages/search.tsx @@ -1,6 +1,7 @@ import useMyAccount, { MyAccount } from '@/hooks/account'; import { useAllDebridApiKey, useRealDebridAccessToken } from '@/hooks/auth'; import { useDownloadsCache } from '@/hooks/cache'; +import useLocalStorage from '@/hooks/localStorage'; import { AdInstantAvailabilityResponse, adInstantCheck, @@ -16,6 +17,7 @@ import { rdInstantCheck, selectFiles, } from '@/services/realDebrid'; +import { groupBy } from '@/utils/groupBy'; import { getMediaId } from '@/utils/mediaId'; import { getSelectableFiles, isVideo } from '@/utils/selectable'; import { withAuth } from '@/utils/withAuth'; @@ -31,8 +33,10 @@ import { FaDownload, FaFastForward, FaTimes } from 'react-icons/fa'; import { SearchApiResponse } from './api/search'; type Availability = 'all:available' | 'rd:available' | 'ad:available' | 'unavailable' | 'no_videos'; +type HashAvailability = Record; type SearchResult = { + mediaId: string; title: string; fileSize: number; hash: string; @@ -41,19 +45,36 @@ type SearchResult = { available: Availability; }; +type SearchFilter = { + count: number; + title: string; + biggestFileSize: number; +}; + const { publicRuntimeConfig: config } = getConfig(); function Search() { const [query, setQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); + const [filteredResults, setFilteredResults] = useState([]); const [errorMessage, setErrorMessage] = useState(''); const rdKey = useRealDebridAccessToken(); const adKey = useAllDebridApiKey(); const [loading, setLoading] = useState(false); const [cancelTokenSource, setCancelTokenSource] = useState(null); const [myAccount, setMyAccount] = useMyAccount(); + const [masterAvailability, setMasterAvailability] = useState({}); + const [searchFilters, setSearchFilters] = useState>({}); const [rdCache, rd, rdCacheAdder, removeFromRdCache] = useDownloadsCache('rd'); const [adCache, ad, adCacheAdder, removeFromAdCache] = useDownloadsCache('ad'); + const [rdAutoInstantCheck, setRdAutoInstantCheck] = useLocalStorage( + 'rdAutoInstantCheck', + false + ); + // const [adAutoInstantCheck, setAdAutoInstantCheck] = useLocalStorage( + // 'adAutoInstantCheck', + // false + // ); const router = useRouter(); @@ -77,26 +98,42 @@ function Search() { const response = await axios.get(endpoint, { cancelToken: source.token, }); + setSearchResults( - response.data.searchResults?.map((r) => ({ ...r, available: 'unavailable' })) || [] + response.data.searchResults?.map((r) => ({ + ...r, + mediaId: getMediaId(r.info, r.mediaType, false), + available: 'unavailable', + })) || [] + ); + setSearchFilters( + response.data.searchResults?.reduce((acc, r) => { + const mediaId = getMediaId(r.info, r.mediaType, true); + if (acc[mediaId]) { + acc[mediaId].count += 1; + acc[mediaId].biggestFileSize = Math.max( + acc[mediaId].biggestFileSize, + r.fileSize + ); + } else { + acc[mediaId] = { + title: getMediaId(r.info, r.mediaType, false), + biggestFileSize: r.fileSize, + count: 1, + }; + } + return acc; + }, {} as Record)! ); if (response.data.searchResults?.length) { toast(`Found ${response.data.searchResults.length} results`, { icon: '🔍' }); - const availability = await instantCheckInRd( - response.data.searchResults.map((result) => result.hash) - ); - toast( - `Found ${ - Object.values(availability).filter((a) => a.includes(':available')).length - } available in RD`, - { icon: '🔍' } - ); - setSearchResults((prev) => - prev.map((r) => ({ ...r, available: availability[r.hash] })) - ); + + // instant checks + const hashArr = response.data.searchResults.map((r) => r.hash); + if (rdKey && rdAutoInstantCheck) await instantCheckInRd(hashArr); + // if (adKey && adAutoInstantCheck) await instantCheckInAd(hashArr); } else { toast(`No results found`, { icon: '🔍' }); - setSearchResults([]); } } catch (error) { if (axios.isCancel(error)) { @@ -129,38 +166,56 @@ function Search() { const { query: searchQuery } = router.query; if (!searchQuery) return; const decodedQuery = decodeURIComponent(searchQuery as string); + if (decodedQuery === query) return; setQuery(decodedQuery); fetchData(decodedQuery); // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.query, myAccount]); + useEffect(() => { + setSearchResults((prev) => + prev.map((r) => ({ + ...r, + available: masterAvailability[r.hash], + })) + ); + }, [masterAvailability]); + + useEffect(() => { + const { filter } = router.query; + if (!filter) { + setFilteredResults(searchResults); + } else { + const decodedFilter = decodeURIComponent(filter as string); + setFilteredResults( + searchResults.filter((r) => r.mediaId.toLocaleLowerCase() === decodedFilter) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.query, searchResults]); + useEffect(() => { return () => { if (cancelTokenSource) cancelTokenSource.cancel(); }; }, [cancelTokenSource]); - type HashAvailability = Record; - - const instantCheckInRd = async (hashes: string[]): Promise => { - const availability = hashes.reduce((acc: HashAvailability, curr: string) => { - acc[curr] = 'unavailable'; - return acc; - }, {}); + const instantCheckInRd = async (hashes: string[]): Promise => { + const rdAvailability = {} as HashAvailability; - if (!rdKey) return availability; - - const setInstantFromRd = (rdAvailabilityResp: RdInstantAvailabilityResponse) => { - for (const masterHash in rdAvailabilityResp) { - if ('rd' in rdAvailabilityResp[masterHash] === false) continue; - const variants = rdAvailabilityResp[masterHash]['rd']; - if (variants.length) availability[masterHash] = 'no_videos'; + const setInstantFromRd = (resp: RdInstantAvailabilityResponse) => { + for (const masterHash in resp) { + if ('rd' in resp[masterHash] === false) continue; + if (masterAvailability[masterHash] === 'no_videos') continue; + const variants = resp[masterHash]['rd']; + if (!variants.length) rdAvailability[masterHash] = 'no_videos'; for (const variant of variants) { for (const fileId in variant) { const file = variant[fileId]; if (isVideo({ path: file.filename })) { - availability[masterHash] = - availability[masterHash] === 'ad:available' + rdAvailability[masterHash] = + masterAvailability[masterHash] === 'ad:available' || + masterAvailability[masterHash] === 'all:available' ? 'all:available' : 'rd:available'; break; @@ -170,16 +225,17 @@ function Search() { } }; - const groupBy = (itemLimit: number, hashes: string[]) => - Array.from({ length: Math.ceil(hashes.length / itemLimit) }, (_, i) => - hashes.slice(i * itemLimit, (i + 1) * itemLimit) - ); - try { for (const hashGroup of groupBy(100, hashes)) { if (rdKey) await rdInstantCheck(rdKey, hashGroup).then(setInstantFromRd); } - return availability; + toast( + `Found ${ + Object.values(rdAvailability).filter((a) => a.includes(':available')).length + } available in RD`, + { icon: '🔍' } + ); + setMasterAvailability({ ...masterAvailability, ...rdAvailability }); } catch (error) { toast.error( 'There was an error checking availability in Real-Debrid. Please try again.' @@ -188,27 +244,15 @@ function Search() { } }; - // TODO: Add AD instant check-in support - const instantCheckInAd = async ( - hashes: string[], - existingAvailability?: HashAvailability - ): Promise => { - const availability = - existingAvailability || - hashes.reduce((acc: HashAvailability, curr: string) => { - acc[curr] = 'unavailable'; - return acc; - }, {}); + const instantCheckInAd = async (hashes: string[]): Promise => { + const adAvailability = {} as HashAvailability; - if (!adKey) return availability; - - const setInstantFromAd = (adAvailabilityResp: AdInstantAvailabilityResponse) => { - for (const magnetData of adAvailabilityResp.data.magnets) { + const setInstantFromAd = (resp: AdInstantAvailabilityResponse) => { + for (const magnetData of resp.data.magnets) { const masterHash = magnetData.hash; - const instant = magnetData.instant; - - if (masterHash in availability && instant === true) { - availability[masterHash] = magnetData.files?.reduce( + if (masterAvailability[masterHash] === 'no_videos') continue; + if (magnetData.instant) { + adAvailability[masterHash] = magnetData.files?.reduce( (acc: boolean, curr: MagnetFile) => { if (isVideo({ path: curr.n })) { return true; @@ -217,7 +261,8 @@ function Search() { }, false ) - ? availability[masterHash] === 'rd:available' + ? masterAvailability[masterHash] === 'rd:available' || + masterAvailability[masterHash] === 'all:available' ? 'all:available' : 'ad:available' : 'no_videos'; @@ -225,16 +270,17 @@ function Search() { } }; - const groupBy = (itemLimit: number, hashes: string[]) => - Array.from({ length: Math.ceil(hashes.length / itemLimit) }, (_, i) => - hashes.slice(i * itemLimit, (i + 1) * itemLimit) - ); - try { for (const hashGroup of groupBy(30, hashes)) { if (adKey) await adInstantCheck(adKey, hashGroup).then(setInstantFromAd); } - return availability; + toast( + `Found ${ + Object.values(adAvailability).filter((a) => a.includes(':available')).length + } available in AD`, + { icon: '🔍' } + ); + setMasterAvailability({ ...masterAvailability, ...adAvailability }); } catch (error) { toast.error('There was an error checking availability in AllDebrid. Please try again.'); throw error; @@ -286,6 +332,7 @@ function Search() { result.available === 'rd:available' || result.available === 'all:available'; const isAvailableInAd = (result: SearchResult) => result.available === 'ad:available' || result.available === 'all:available'; + const hasNoVideos = (result: SearchResult) => result.available === 'no_videos'; const handleDeleteTorrent = async (id: string, disableToast: boolean = false) => { try { @@ -293,8 +340,8 @@ function Search() { if (rdKey && id.startsWith('rd:')) await deleteTorrent(rdKey, id.substring(3)); if (adKey && id.startsWith('ad:')) await deleteMagnet(adKey, id.substring(3)); if (!disableToast) toast.success(`Download canceled (${id})`); - if (id.startsWith('rd:')) removeFromRdCache(id.substring(3)); - if (id.startsWith('ad:')) removeFromAdCache(id.substring(3)); + if (id.startsWith('rd:')) removeFromRdCache(id); + if (id.startsWith('ad:')) removeFromAdCache(id); } catch (error) { if (!disableToast) toast.error(`Error deleting torrent (${id})`); throw error; @@ -330,11 +377,11 @@ function Search() { }; return ( -
+
Debrid Media Manager - Search: {query} - +

Search

-
+
- {loading && ( -
-
-
- )} - {errorMessage && ( -
- Error: - {errorMessage} -
- )} + {loading && ( +
+
+
+ )} + {errorMessage && ( +
+ Error: + {errorMessage} +
+ )} {searchResults.length > 0 && ( <> + {!loading && ( +
+ + { + const isChecked = event.target.checked; + setRdAutoInstantCheck(isChecked); + }} + />{' '} + + + {/* { + const isChecked = event.target.checked; + setAdAutoInstantCheck(isChecked); + }} + />{' '} + */} + + Reset + + {Object.keys(searchFilters).map((mediaId) => ( + + ))} +
+ )}

Search Results

@@ -396,7 +527,7 @@ function Search() { - {searchResults.map((r: SearchResult) => ( + {filteredResults.map((r: SearchResult) => ( @@ -436,9 +565,17 @@ function Search() { {rdKey && rd.notInLibrary(r.hash) && (
- - {getMediaId(r.info, r.mediaType, false)} - + {r.mediaId}
{r.title}