feat: improve tosho batch lookup

feat: infinite scroll on search
fix: update data on list update
This commit is contained in:
ThaUnknown 2023-07-06 19:40:16 +02:00
parent 517352b7ce
commit d954314e9b
8 changed files with 155 additions and 412 deletions

View file

@ -31,7 +31,7 @@
<Toasts />
<div class='page-wrapper with-sidebar with-transitions bg-dark' data-sidebar-type='overlayed-all'>
<div class='sticky-alerts' />
<CatBlock />
<!-- <CatBlock /> -->
<Menubar bind:page={$page} />
<ViewAnime />
<ViewTrailer />

View file

@ -166,14 +166,6 @@ function getDistanceFromTitle (media, name) {
}
}
function sanitiseObject (object = {}) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
export async function alSearch (method) {
const res = await alRequest(method)
const media = res.data.Page.media.map(media => getDistanceFromTitle(media, method.name))
@ -214,6 +206,11 @@ nextAiringEpisode{
timeUntilAiring,
episode
},
startDate{
year,
month,
day
},
trailer{
id,
site
@ -252,6 +249,9 @@ relations {
status,
format,
episodes,
synonyms,
season,
seasonYear,
startDate{
year,
month,
@ -284,7 +284,7 @@ recommendations{
export async function alRequest (opts) {
let query
const variables = {
...sanitiseObject(opts),
...opts,
sort: opts.sort || 'TRENDING_DESC',
page: opts.page || 1,
perPage: opts.perPage || 30,

View file

@ -176,14 +176,6 @@ async function resolveTitle (name) {
media = (await alSearch(method)).data.Page.media[0]
}
// remove (TV)
if (!media) {
const match = method.name.match(/\(TV\)/)
if (match) {
method.name = method.name.replace('(TV)', '')
media = (await alSearch(method)).data.Page.media[0]
}
}
// remove - :
if (!media) {
const match = method.name.match(/[-:]/g)
@ -192,6 +184,14 @@ async function resolveTitle (name) {
media = (await alSearch(method)).data.Page.media[0]
}
}
// remove (TV)
if (!media) {
const match = method.name.match(/\(TV\)/)
if (match) {
method.name = method.name.replace('(TV)', '')
media = (await alSearch(method)).data.Page.media[0]
}
}
// remove 2020
if (!media) {
const match = method.name.match(/ (19[5-9]\d|20\d{2})/)

View file

@ -23,14 +23,19 @@ export default async function tosho ({ media, episode }) {
window.tosho = tosho
async function getAniDBFromAL (media) {
console.log('getting AniDB ID from AL')
const mappingsResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + media.id)
const json = await mappingsResponse.json()
if (json.mappings.anidb_id) return json
console.log('failed getting AniDB ID, checking via parent')
const parentID = getParentForSpecial(media)
if (!parentID) return
console.log('found via parent')
const parentResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + parentID)
return parentResponse.json()
}
@ -48,8 +53,10 @@ function getRelation (list, type) {
// TODO: https://anilist.co/anime/13055/
async function getAniDBEpisodeFromAL ({ media, episode }, { episodes, episodeCount }) {
console.log('getting AniDB EpID for Mal EP', { episode, episodes })
if (!episode || !Object.values(episodes).length) return
if (media.episodes && media.episodes === episodeCount && episodes[Number(episode)]) return episodes[Number(episode)]
console.log('EP count doesn\'t match, checking by air date')
const res = await alRequest({ method: 'EpisodeDate', id: media.id, ep: episode })
const alDate = new Date((res.data.AiringSchedule?.airingAt || 0) * 1000)
@ -68,23 +75,106 @@ async function getToshoEntries (media, episode, { mappings }, quality) {
if (episode) {
const { anidbEid } = episode
console.log('fetching episode', anidbEid, quality)
promises.push(fetchSingleEpisode({ id: anidbEid, quality }))
} else {
// TODO: look for episodes via.... title?
}
// look for batches and movies
if (mappings.anidb_id && media.status === 'FINISHED' && (isMovie(media) || media.episodes !== 1)) {
promises.push(fetchBatches({ media, id: mappings.anidb_id, quality }))
const movie = isMovie(media)
if (mappings.anidb_id && media.status === 'FINISHED' && (movie || media.episodes !== 1)) {
promises.push(fetchBatches({ episodeCount: media.episodes, id: mappings.anidb_id, quality }))
console.log('fetching batch', quality, movie)
if (!movie) {
const courRelation = getSplitCourRelation(media)
if (courRelation) {
console.log('found split cour!')
const episodeCount = (media.episodes || 0) + (courRelation.episodes || 0)
const mappingsResponse = await fetch('https://api.ani.zip/mappings?anilist_id=' + courRelation.id)
const json = await mappingsResponse.json()
console.log('found mappings for split cour', !!json.mappings.anidb_id)
if (json.mappings.anidb_id) promises.push(fetchBatches({ episodeCount, id: json.mappings.anidb_id, quality }))
}
}
}
return (await Promise.all(promises)).flat()
}
function getSplitCourRelation (media) {
// Part 2 / Cour 3 / 4th Cour
if (isTitleSplitCour(media)) return getCourPrequel(media)
// Part 1 of split cour which usually doesn't get labeled as split cour
// sequel can not exist
return getCourSequel(media)
}
const courRegex = /[2-9](?:nd|rd|th) Cour|Cour [2-9]|Part [2-9]/i
function isTitleSplitCour (media) {
const titles = [...Object.values(media.title), ...media.synonyms]
console.log('checking cour titles', titles)
return titles.some(title => courRegex.test(title))
}
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
const getDate = ({ seasonYear, season }) => new Date(`${seasonYear}-${seasons.indexOf(season) * 4 || 1}-01`)
function getMediaDate (media) {
if (media.startDate) return new Date(Object.values(media.startDate).join(' '))
return getDate(media)
}
function getCourSequel (media) {
const mediaDate = getMediaDate(media)
const animeRelations = media.relations.edges.filter(({ node, relationType }) => {
if (node.type !== 'ANIME') return false
if (node.status !== 'FINISHED') return false
if (relationType !== 'SEQUEL') return false
if (!['OVA', 'TV'].some(format => node.format === format)) return false // not movies or ona's
if (mediaDate > getMediaDate(node)) return false // node needs to be released after media to be a sequel
return isTitleSplitCour(node)
})
if (!animeRelations.length) return false
// get closest sequel
return animeRelations.reduce((prev, curr) => {
return getMediaDate(prev) - mediaDate > getMediaDate(curr) - mediaDate ? curr : prev
})
}
function getCourPrequel (media) {
const mediaDate = getMediaDate(media)
const animeRelations = media.relations.edges.filter(({ node, relationType }) => {
if (node.type !== 'ANIME') return false
if (node.status !== 'FINISHED') return false
if (relationType !== 'PREQUEL') return false
if (!['OVA', 'TV'].some(format => node.format === format)) return false
if (mediaDate < getMediaDate(node)) return false // node needs to be released before media to be a prequel
return true
}).map(({ node }) => node)
if (!animeRelations.length) {
console.error('Detected split count but couldn\'t find prequel', media)
return false
}
// get closest prequel
return animeRelations.reduce((prev, curr) => {
return mediaDate - getMediaDate(prev) > mediaDate - getMediaDate(curr) ? curr : prev
})
}
function isMovie (media) {
if (media.format === 'MOVIE') return true
if ([...Object.values(media.title), ...media.synonyms].some(title => title.toLowerCase().includes('movie'))) return true
if (!getParentForSpecial(media)) return true
// if (!getParentForSpecial(media)) return true // TODO: this is good for checking movies, but false positives with normal TV shows
return media.duration > 80 && media.episodes === 1
}
@ -95,20 +185,23 @@ function buildQuery (quality) {
return query
}
async function fetchBatches ({ media, id, quality }) {
// TODO: improve split-cour show batch lookup [tosho assigns aid's incorrectly]
async function fetchBatches ({ episodeCount, id, quality }) {
const queryString = buildQuery(quality)
const torrents = await fetch(toshoURL + 'order=size-d&aid=' + id + queryString)
// safe if AL includes EP 0 or doesn't
return (await torrents.json()).filter(entry => entry.num_files >= media.episodes)
const batches = (await torrents.json()).filter(entry => entry.num_files >= episodeCount)
console.log({ batches })
return batches
}
async function fetchSingleEpisode ({ id, quality }) {
const queryString = buildQuery(quality)
const torrents = await fetch(toshoURL + 'eid=' + id + queryString)
return torrents.json()
const episodes = await torrents.json()
console.log({ episodes })
return episodes
}
function mapTosho2dDeDupedEntry (entries) {

View file

@ -14,7 +14,7 @@ export default class Sections {
static createFallbackLoad (variables, type) {
return (page = 1, perPage = 50, search = variables) => {
const options = { method: 'Search', page, perPage, ...search }
const options = { method: 'Search', page, perPage, ...Sections.sanitiseObject(search) }
const res = alRequest(options)
return Sections.wrapResponse(res, perPage, type)
}
@ -27,6 +27,14 @@ export default class Sections {
return Array.from({ length }, (_, i) => ({ type, data: Sections.fromPending(res, i) }))
}
static sanitiseObject (object = {}) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
static async fromPending (arr, i) {
const { data } = await arr
return data.Page.media[i]

View file

@ -24,7 +24,7 @@
])
}
if (alToken) {
manager.add([
const sections = [
{
title: 'Continue Watching',
load: (page = 1, perPage = 50, variables = {}) => {
@ -34,7 +34,7 @@
if (media.status === 'FINISHED') return true
return media.mediaListEntry?.progress < media.nextAiringEpisode?.episode - 1
}).map(({ media }) => media.id)
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...variables })
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...Sections.sanitiseObject(variables) })
})
return Sections.wrapResponse(res, perPage)
}
@ -49,7 +49,7 @@
return edge.relationType === 'SEQUEL'
})
}).map(({ node }) => node.id)
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...variables, status: ['FINISHED', 'RELEASING'], onList: false })
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...Sections.sanitiseObject(variables), status: ['FINISHED', 'RELEASING'], onList: false })
})
return Sections.wrapResponse(res, perPage)
}
@ -59,12 +59,19 @@
load: (page = 1, perPage = 50, variables = {}) => {
const res = userLists.value.then(res => {
const ids = res.data.MediaListCollection.lists.find(({ status }) => status === 'PLANNING').entries.map(({ media }) => media.id)
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...variables })
return alRequest({ method: 'SearchIDS', page, perPage, id: ids, ...Sections.sanitiseObject(variables) })
})
return Sections.wrapResponse(res, perPage)
}
}
])
]
userLists.subscribe(() => {
const titles = sections.map(({ title }) => title)
for (const section of manager.sections) {
if (titles.includes(section.title)) delete section.preview
}
})
manager.add(sections)
}
manager.add([
{

View file

@ -1,378 +0,0 @@
<script context='module'>
import { readable, writable } from 'simple-store-svelte'
import { add } from '@/modules/torrent.js'
import { alToken, set } from '../Settings.svelte'
import { alRequest } from '@/modules/anilist.js'
import { sleep } from '@/modules/util.js'
import { resolveFileMedia } from '@/modules/anime.js'
import { getRSSContent, getReleasesRSSurl } from '@/modules/rss.js'
const noop = () => {}
async function getVal (set) {
const res = await fetch('https://gh.miru.workers.dev/')
const amt = await res.text() / 100
set(amt.toFixed(2))
}
const progress = readable(0, set => {
getVal(set)
return noop
})
const getSeason = d => seasons[Math.floor((d.getMonth() / 12) * 4) % 4]
let hasNext = true
function processMedia (res, length) {
res.then(res => {
hasNext = res?.data?.Page.pageInfo.hasNextPage
})
return Array.from({ length }, (_, i) => ({ type: 'full', data: fromPending(res, i) }))
}
async function fromPending (arr, i) {
// return new Promise(r => r)
const { data } = await arr
return data.Page.media[i]
}
const search = writable({})
let lastRSSDate = 0
async function releasesCards (page, limit, force, val) {
const doc = await getRSSContent(getReleasesRSSurl(val))
if (doc) {
const pubDate = doc.querySelector('pubDate').textContent
if (force || lastRSSDate !== pubDate) {
lastRSSDate = pubDate
const index = (page - 1) * limit
const items = [...doc.querySelectorAll('item')].slice(index, index + limit)
hasNext = items.length === limit
const media = await resolveFileMedia(items.map(item => item.querySelector('title').textContent))
media.forEach((mediaInformation, index) => {
mediaInformation.date = new Date(items[index].querySelector('pubDate').textContent)
mediaInformation.onclick = () => {
add(items[index].querySelector('link').textContent)
}
})
media.hasNext = hasNext
return media
}
}
}
const seasons = ['WINTER', 'SPRING', 'SUMMER', 'FALL']
function sanitiseObject (object) {
const safe = {}
for (const [key, value] of Object.entries(object)) {
if (value) safe[key] = value
}
return safe
}
function customFilter (mediaList) {
return mediaList?.filter(({ media }) => {
let condition = true
const { value } = search
if (!media) return condition
if (value.genre && !media.genres?.includes(value.genre)) condition = false
if (value.season && media.season !== value.season) condition = false
if (value.year && media.seasonYear !== value.year) condition = false
if (value.format && media.format !== value.format) condition = false
if (value.status && media.status !== value.status) condition = false
if (value.search) {
const titles = Object.values(media.title)
.concat(media.synonyms)
.filter(name => name != null)
.map(title => title.toLowerCase())
if (!titles.find(title => title.includes(value.search.toLowerCase()))) condition = false
}
return condition
})
}
let lastDate = null
let sections = {
// continue: {
// title: 'Continue Watching',
// preview: () => sections.continue.load(1, 6),
// load: (page = 1, perPage = 50, initial = false) => {
// if (initial) search.value = { ...search.value, sort: 'UPDATED_TIME_DESC' }
// return alRequest({ method: 'UserLists', status_in: ['CURRENT', 'REPEATING'], page }).then(res => {
// hasNext = res?.data?.Page.pageInfo.hasNextPage
// return customFilter(
// res?.data?.Page.mediaList
// .filter(i => {
// return i.media.status !== 'RELEASING' || i.media.mediaListEntry?.progress < i.media.nextAiringEpisode?.episode - 1
// })
// .slice(0, perPage)
// )
// })
// },
// hide: !alToken
// },
// newSeasons: {
// title: 'Sequels You Missed',
// data: (async () => {
// if (!alToken) return
// const { data } = await alRequest({ method: 'NewSeasons' })
// const res = data.MediaListCollection.lists[0]
// return res?.entries?.flatMap(({ media }) => {
// return media.relations.edges.filter(edge => {
// return edge.relationType === 'SEQUEL' && !edge.node.mediaListEntry
// })
// }).map(({ node }) => node.id)
// })(),
// preview: () => sections.newSeasons.load(1, 6),
// load: async (page = 1, perPage = 50, initial = false) => {
// if (initial) search.value = { ...search.value, status: 'FINISHED' }
// const id = await sections.newSeasons.data
// const res = await alRequest({ method: 'SearchIDS', page, perPage, id, ...sanitiseObject(search.value), status: ['FINISHED', 'RELEASING'], onList: false })
// return processMedia(res)
// },
// hide: !alToken
// },
// planning: {
// title: 'Your List',
// preview: () => sections.planning.load(1, 6),
// load: (page = 1, perPage = 50, initial = false) => {
// if (initial) search.value = { ...search.value, sort: 'UPDATED_TIME_DESC' }
// return alRequest({ method: 'UserLists', page, perPage, status_in: 'PLANNING' }).then(res => {
// hasNext = res?.data?.Page.pageInfo.hasNextPage
// return customFilter(res?.data?.Page.mediaList)
// })
// },
// hide: !alToken
// },
seasonal: {
title: 'Popular This Season',
preview: function () {
if (!this.previewData) this.previewData = this.load(1, 10)
return this.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
const date = new Date()
if (initial) {
search.value = {
...search.value,
season: getSeason(date),
year: date.getFullYear(),
sort: 'POPULARITY_DESC'
}
}
return processMedia(alRequest({ method: 'Search', page, perPage, year: date.getFullYear(), season: getSeason(date), sort: 'POPULARITY_DESC', ...sanitiseObject(search.value) }), perPage)
}
},
trending: {
title: 'Trending Now',
preview: function () {
if (!this.previewData) this.previewData = this.load(1, 10)
return this.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', ...sanitiseObject(search.value) }), perPage)
}
},
popular: {
title: 'All Time Popular',
preview: function () {
if (!this.previewData) this.previewData = this.load(1, 10)
return this.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'POPULARITY_DESC' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'POPULARITY_DESC', ...sanitiseObject(search.value) }), perPage)
}
},
romance: {
title: 'Romance',
preview: () => {
const self = sections.romance
if (!self.previewData) self.previewData = self.load(1, 10)
return self.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC', genre: 'Romance' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Romance', ...sanitiseObject(search.value) }), perPage)
}
},
action: {
title: 'Action',
preview: () => {
const self = sections.action
if (!self.previewData) self.previewData = self.load(1, 10)
return self.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC', genre: 'Action' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Action', ...sanitiseObject(search.value) }), perPage)
}
},
adventure: {
title: 'Adventure',
preview: () => {
const self = sections.adventure
if (!self.previewData) self.previewData = self.load(1, 10)
return self.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC', genre: 'Adventure' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Adventure', ...sanitiseObject(search.value) }), perPage)
}
},
fantasy: {
title: 'Fantasy',
preview: () => {
const self = sections.fantasy
if (!self.previewData) self.previewData = self.load(1, 10)
return self.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC', genre: 'Fantasy' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Fantasy', ...sanitiseObject(search.value) }), perPage)
}
},
comedy: {
title: 'Comedy',
preview: () => {
const self = sections.comedy
if (!self.previewData) self.previewData = self.load(1, 10)
return self.previewData
},
load: (page = 1, perPage = 50, initial = false) => {
if (initial) search.value = { ...search.value, sort: 'TRENDING_DESC', genre: 'Comedy' }
return processMedia(alRequest({ method: 'Search', page, perPage, sort: 'TRENDING_DESC', genre: 'Comedy', ...sanitiseObject(search.value) }), perPage)
}
},
schedule: {
title: 'Schedule',
hide: true,
load: (page = 1, perPage = 50, initial = false) => {
const date = new Date()
if (initial) search.value = { ...search.value, sort: 'START_DATE_DESC', status: 'RELEASING' }
if (perPage !== 6) date.setHours(0, 0, 0, 0)
return alRequest({ method: 'AiringSchedule', page, from: parseInt(date.getTime() / 1000) }).then(res => {
const entries = customFilter(res?.data?.Page.airingSchedules.filter(entry => entry.media.countryOfOrigin !== 'CN' && !entry.media.isAdult) || []).slice(0, perPage)
const media = []
hasNext = res?.data?.Page.pageInfo.hasNextPage
const date = new Date()
for (const entry of entries) {
if (entry.timeUntilAiring && perPage !== 6 && (!lastDate || new Date(+date + entry.timeUntilAiring * 1000).getDay() !== lastDate.getDay())) {
lastDate = new Date(+date + entry.timeUntilAiring * 1000)
media.push(lastDate.toLocaleDateString('en-US', { weekday: 'long' }))
}
entry.schedule = true
media.push(entry)
}
return media
})
}
},
search: {
title: 'Search',
hide: true,
load: (page = 1, perPage = 50) => {
const opts = {
method: 'Search',
page,
perPage,
...sanitiseObject(search.value)
}
return processMedia(alRequest(opts), perPage)
}
}
}
for (let i = set.rssFeeds.length - 1; i >= 0; --i) {
const [title, val] = set.rssFeeds[i]
const section = {
title,
load: async (page = 1, perPage = 20, initial = false, force = true) => {
if (initial) search.value = { ...search.value, sort: 'START_DATE_DESC' }
return customFilter(await releasesCards(page, Math.min(perPage, 13), force, val))
},
preview: async () => {
const self = sections['releases-' + i]
if (!self.previewData) {
await sleep(i * 3000) // stagger lists by 3 seconds
setInterval(async () => {
const newData = await self.load(1, 6, false, false)
if (newData) self.previewData = newData
}, 15000)
self.previewData = await self.load(1, 6, false, true)
}
return self.previewData
}
}
sections = {
['releases-' + i]: section,
...sections
}
}
</script>
<script>
import Search from './Search.svelte'
import Section from './Section.svelte'
import Gallery from './Gallery.svelte'
let media = []
export let current = null
let page = 1
let canScroll = true
let container = null
function infiniteScroll () {
if (current && canScroll && hasNext && this.scrollTop + this.clientHeight > this.scrollHeight - 800) {
infiniteScrollLoad()
}
}
async function infiniteScrollLoad () {
canScroll = false
const res = sections[current].load(++page)
media.push(res)
media = media
await res
canScroll = hasNext
}
async function loadCurrent (initial = true) {
page = 1
canScroll = false
const res = sections[current].load(1, 50, initial)
media = [res]
await res
canScroll = hasNext
if ((await res).length < 12 && hasNext) infiniteScrollLoad()
}
$: load(current)
async function load (current) {
if (sections[current]) {
loadCurrent()
} else {
if (container) container.scrollTop = 0
media = []
canScroll = true
lastDate = null
$search = {
search: null,
genre: '',
season: '',
year: null,
format: '',
status: '',
sort: ''
}
}
}
</script>
<div class='h-full w-full overflow-y-scroll root overflow-x-hidden' on:scroll={infiniteScroll} bind:this={container}>
<div class='d-flex flex-column h-full w-full'>
<Search bind:media bind:search={$search} bind:current {loadCurrent} />
{#if media.length}
<Gallery {media} />
{:else}
{#each Object.entries(sections) as [key, opts] (key)}
{#if !opts.hide}
<Section opts={{ ...opts, onclick: () => (current = key) }} />
{/if}
{/each}
{/if}
</div>
</div>

View file

@ -13,17 +13,30 @@
import smoothScroll from '@/modules/scroll.js'
import { debounce } from '@/modules/util.js'
let page = 1
function loadSearchData (search) {
const load = search.load || Sections.createFallbackLoad()
$items = load(1, undefined, searchCleanup(search))
$items = load(page, undefined, searchCleanup(search))
}
loadSearchData($search)
const update = debounce(loadSearchData, 150)
// TODO: infinite scrolling
let canScroll = true
const hasNextPage = true
async function infiniteScroll () {
if (canScroll && hasNextPage && this.scrollTop + this.clientHeight > this.scrollHeight - 800) {
canScroll = false
const load = search.load || Sections.createFallbackLoad()
const nextData = load(++page, undefined, searchCleanup(search))
$items = [...$items, ...nextData]
nextData[nextData.length - 1].data.then(() => { canScroll = true })
}
}
</script>
<div class='h-full w-full overflow-y-scroll d-flex flex-wrap flex-row root overflow-x-hidden px-50 justify-content-center align-content-start' use:smoothScroll>
<div class='h-full w-full overflow-y-scroll d-flex flex-wrap flex-row root overflow-x-hidden px-50 justify-content-center align-content-start' use:smoothScroll on:scroll={infiniteScroll}>
<Search bind:search={$search} on:input={() => update($search)} />
{#each $items as card}
<Card {card} />