perf(search): Rework search data to save in store (#991)

This commit is contained in:
Rémi Marseault 2023-07-24 20:39:37 +02:00 committed by GitHub
parent b547d9d217
commit 470ae36cb5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 230 additions and 225 deletions

View file

@ -1,168 +0,0 @@
<template>
<div>
<v-card flat>
<v-list-item class="searchCriterias">
<v-row class="my-2">
<v-col cols="12" md="6">
<v-text-field
v-model="searchPattern"
dense
hide-details
clearable
:rules="[v => !!v || 'Search term is required']"
label="Search pattern"
@keydown.enter.prevent="runNewSearch"
autofocus
/>
</v-col>
<v-col cols="6" sm="5" md="2">
<v-select v-model="searchCategory" height="1" flat dense hide-details outlined :items="categories" label="Search category" />
</v-col>
<v-col cols="6" sm="5" md="2">
<v-select v-model="searchPlugin" height="1" flat dense hide-details outlined :items="plugins" label="Search plugins" />
</v-col>
<v-col cols="12" sm="2" class="d-flex align-center justify-center">
<v-btn v-if="queryId === 0" class="mx-auto accent white--text elevation-0 px-4" @click="runNewSearch">
{{ $t('search.runNewSearch') }}
</v-btn>
<v-btn v-else class="mx-auto warning white--text elevation-0 px-4" @click="stopSearch">
{{ $t('search.stopSearch') }}
</v-btn>
</v-col>
</v-row>
</v-list-item>
</v-card>
<v-card flat class="mt-5">
<v-list-item class="searchResults">
<v-data-table style="width: 100%" id="searchResultsTable" :headers="headers" :items="filteredResults" :search="resultFilter" :custom-filter="customFilter">
<template v-slot:top>
<v-row class="mt-2">
<v-col cols="12" md="6">
<v-text-field v-model="resultFilter" dense hide-details label="Filter" />
</v-col>
</v-row>
</template>
<template v-slot:item.fileSize="{ item }">
{{ item.fileSize | formatSize }}
</template>
<template v-slot:item.actions="{ item }">
<span class="d-flex flex-row">
<v-icon @click="downloadTorrent(item)">{{ mdiDownload }}</v-icon>
</span>
</template>
</v-data-table>
</v-list-item>
</v-card>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { mapGetters } from 'vuex'
import { mdiDownload } from '@mdi/js'
import { SearchPlugin, SearchResult } from '@/types/qbit/models'
import { General } from '@/mixins'
import qbit from '@/services/qbit'
export default defineComponent({
name: 'SearchTab',
mixins: [General],
data() {
return {
headers: [
{ text: 'Filename', value: 'fileName' },
{ text: 'File Size', value: 'fileSize' },
{ text: 'Seeders', value: 'nbSeeders' },
{ text: 'Leechers', value: 'nbLeechers' },
{ text: 'Site URL', value: 'siteUrl' },
{ text: '', value: 'actions', sortable: false }
],
searchPattern: '',
searchCategory: 'all',
searchPlugin: 'enabled',
queryId: 0,
queryTimer: NaN as NodeJS.Timer,
queryResults: [] as SearchResult[],
resultFilter: '',
mdiDownload
}
},
async beforeDestroy() {
await this.stopSearch()
},
computed: {
...mapGetters(['getSearchPlugins']),
categories() {
const cats = [
{ text: 'Movies', value: 'movies' },
{ text: 'TV shows', value: 'tv' },
{ text: 'Music', value: 'music' },
{ text: 'Games', value: 'games' },
{ text: 'Anime', value: 'anime' },
{ text: 'Software', value: 'software' },
{ text: 'Pictures', value: 'pictures' },
{ text: 'Books', value: 'books' }
]
cats.sort((a, b) => a.text.localeCompare(b.text))
return [{ text: 'All categories', value: 'all' }, ...cats]
},
plugins() {
const plugins = [
{ text: 'All plugins', value: 'all' },
{ text: 'Only enabled', value: 'enabled' }
]
this.getSearchPlugins()
.filter((plugin: SearchPlugin) => plugin.enabled)
.forEach((plugin: SearchPlugin) => plugins.push({ text: plugin.fullName, value: plugin.name }))
return plugins
},
filteredResults() {
return this.queryResults
}
},
methods: {
downloadTorrent(item: SearchResult) {
this.createModal('AddModal', { initialMagnet: item.fileUrl })
},
async runNewSearch() {
const searchJob = await qbit.startSearch(this.searchPattern, this.searchCategory, [this.searchPlugin])
this.queryId = searchJob.id
this.queryResults = []
this.queryTimer = setInterval(() => this.refreshResults(), 1000)
},
async stopSearch() {
if (this.queryId !== 0) await qbit.stopSearch(this.queryId)
this.queryId = 0
clearInterval(this.queryTimer)
},
customFilter(value: any, search: string | null) {
return (
value != null &&
search != null &&
typeof value === 'string' &&
search
.trim()
.toLowerCase()
.split(' ')
.every(i => value.toString().toLowerCase().indexOf(i) !== -1)
)
},
async refreshResults() {
const response = await qbit.getSearchResults(this.queryId, this.queryResults.length)
this.queryResults.push(...response.results)
if (response.status === 'Stopped') {
this.queryId = 0
await this.stopSearch()
}
}
}
})
</script>
<style scoped lang="scss"></style>

View file

@ -101,7 +101,7 @@
},
"search": {
"title": "Search torrents",
"tabHeaderTemplate": "Tab #$0",
"tabHeaderEmpty": "(Empty Query)",
"runNewSearch": "Search",
"stopSearch": "Stop"
},

View file

@ -101,7 +101,6 @@
},
"search": {
"title": "搜尋種子",
"tabHeaderTemplate": "分頁 #$0",
"runNewSearch": "搜尋",
"stopSearch": "停止"
},

View file

@ -98,6 +98,7 @@ export default new Vuex.Store<StoreState>({
feeds: [],
rules: []
},
searchData: [],
searchPlugins: [],
selectMode: false,
selected_torrents: [],

View file

@ -8,6 +8,7 @@ import type ModalTemplate from './ModalTemplate'
import type { Status } from '@/models'
import type WebUISettings from './WebUISettings'
import type { SearchPlugin } from '@/types/qbit/models'
import { SearchData } from "@/types/vuetorrent/search";
export interface PersistentStoreState {
authenticated: boolean
@ -33,6 +34,7 @@ export default interface StoreState extends PersistentStoreState {
feeds: Feed[]
rules: FeedRule[]
}
searchData: SearchData[]
searchPlugins: SearchPlugin[]
selectMode: boolean
selected_torrents: string[]

View file

@ -1,7 +1,6 @@
import type Category from './Category'
import type { Feed, FeedArticle, FeedRule } from './rss'
import type SearchStatus from './search/SearchStatus'
import type SearchResult from './search/SearchResult'
import type { SearchData, SearchFilters } from './search'
import type ModalTemplate from './ModalTemplate'
import type SortOptions from './SortOptions'
import type StoreState from './StoreState'
@ -15,8 +14,8 @@ export {
Feed,
FeedArticle,
FeedRule,
SearchStatus,
SearchResult,
SearchData,
SearchFilters,
ModalTemplate,
SortOptions,
PersistentStoreState,

View file

@ -0,0 +1,12 @@
import SearchFilters from './SearchFilters'
import { SearchResult } from '@/types/qbit/models'
export default interface SearchData {
uniqueId: string
id: number
timer: NodeJS.Timer | null
query: string
itemsPerPage: number
filters: SearchFilters
results: SearchResult[]
}

View file

@ -0,0 +1,5 @@
export default interface SearchFilters {
title: string
category: string
plugin: string
}

View file

@ -1,9 +0,0 @@
export default interface SearchResult {
descrLink: string
fileName: string
fileSize: number
fileUrl: string
nbLeechers: number
nbSeeders: number
siteUrl: string
}

View file

@ -1,9 +0,0 @@
import type { SearchResult } from '..'
import type { Optional } from '@/global'
export default interface SearchStatus {
id: number
status: 'Running' | 'Stopped'
interval: Optional<NodeJS.Timer>
results: SearchResult[]
}

View file

@ -0,0 +1,7 @@
import SearchData from './SearchData'
import SearchFilters from './SearchFilters'
export {
SearchData,
SearchFilters
}

View file

@ -8,6 +8,9 @@
</v-col>
<v-col class="align-center justify-center">
<v-card-actions class="justify-end">
<v-btn small elevation="0" color="error" @click="stopAllSearch">
<v-icon>{{ mdiStop }}</v-icon>
</v-btn>
<v-btn small elevation="0" color="primary" @click="openPluginManager">
<v-icon>{{ mdiToyBrick }}</v-icon>
</v-btn>
@ -20,60 +23,172 @@
<v-row class="ma-0 pa-0">
<v-container class="d-flex align-center justify-center ma-0 pa-0 primary" fluid>
<v-tabs v-model="tab" align-with-title show-arrows background-color="primary" slider-color="white" class="overflow-auto">
<v-tab v-for="t in tabs" :href="`#${t.value}`" class="white--text">
<h4>{{ $t('search.tabHeaderTemplate').replace('$0', t.id) }}</h4>
<v-tabs v-model="tabIndex" ref="tabs" align-with-title show-arrows background-color="primary" slider-color="white" class="overflow-auto">
<v-tab v-for="t in tabs" :key="t.uniqueId" class="white--text">
<h4>{{ !t.query || t.query.length === 0 ? $t('search.tabHeaderEmpty') : t.query }}</h4>
</v-tab>
</v-tabs>
<v-spacer />
<v-btn icon @click="createNewTab" class="mr-1">
<v-icon color="accent">{{ mdiPlusCircleOutline }}</v-icon>
</v-btn>
<v-btn icon @click="deleteTab" :disabled="tabs.length === 0" class="mx-1">
<v-btn icon @click="deleteTab" :disabled="tabs.length === 1" class="mx-1">
<v-icon color="error">{{ mdiMinusCircleOutline }}</v-icon>
</v-btn>
</v-container>
<v-tabs-items v-model="tab" touchless class="full-width">
<v-tab-item v-for="t in tabs" :key="t.id" eager :value="t.value">
<SearchTab />
</v-tab-item>
</v-tabs-items>
</v-row>
<div>
<v-card flat>
<v-list-item class="searchCriterias">
<v-row class="my-2">
<v-col cols="12" md="6">
<v-text-field
v-model="selectedTab.query"
ref="queryInput"
autofocus
dense
hide-details
clearable
label="Search pattern"
@keydown.enter.prevent="runNewSearch"
@input="updateTabWidth"
/>
</v-col>
<v-col cols="6" sm="5" md="2">
<v-select v-model="selectedTab.filters.category" height="1" flat dense hide-details outlined :items="categories" label="Search category" />
</v-col>
<v-col cols="6" sm="5" md="2">
<v-select v-model="selectedTab.filters.plugin" height="1" flat dense hide-details outlined :items="plugins" label="Search plugins" />
</v-col>
<v-col cols="12" sm="2" class="d-flex align-center justify-center">
<v-btn v-if="selectedTab.id === 0" class="mx-auto accent white--text elevation-0 px-4" @click="runNewSearch">
{{ $t('search.runNewSearch') }}
</v-btn>
<v-btn v-else class="mx-auto warning white--text elevation-0 px-4" @click="stopSearch(selectedTab)">
{{ $t('search.stopSearch') }}
</v-btn>
</v-col>
</v-row>
</v-list-item>
</v-card>
<v-card flat class="mt-5">
<v-list-item class="searchResults">
<v-data-table style="width: 100%" id="searchResultsTable" :headers="headers" :items="filteredResults" :footer-props="{ itemsPerPageOptions: [10, 25, 50, 100, -1] }" :items-per-page.sync="selectedTab.itemsPerPage" :search="selectedTab.filters.title" :custom-filter="customFilter">
<template v-slot:top>
<v-row class="mt-2">
<v-col cols="12" md="6">
<v-text-field v-model="selectedTab.filters.title" dense hide-details label="Filter" />
</v-col>
</v-row>
</template>
<template v-slot:item.fileSize="{ item }">
{{ item.fileSize | formatData(shouldUseBinaryData()) }}
</template>
<template v-slot:item.actions="{ item }">
<span class="d-flex flex-row">
<v-icon @click="downloadTorrent(item)">{{ mdiDownload }}</v-icon>
</span>
</template>
</v-data-table>
</v-list-item>
</v-card>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import { mapGetters } from 'vuex'
import { mdiClose, mdiToyBrick, mdiPlusCircleOutline, mdiMinusCircleOutline } from '@mdi/js'
import { FullScreenModal, General } from '@/mixins'
import SearchTab from '@/components/SearchEngine/SearchTab.vue'
import { mdiClose, mdiToyBrick, mdiPlusCircleOutline, mdiMinusCircleOutline, mdiDownload, mdiStop } from '@mdi/js'
import { General } from '@/mixins'
import { SearchPlugin, SearchResult } from '@/types/qbit/models'
import qbit from '@/services/qbit'
import { SearchData } from '@/types/vuetorrent'
import { Optional } from '@/global'
import { v1 as genUuid } from 'uuid'
export default defineComponent({
name: 'SearchEngine',
components: { SearchTab },
mixins: [General, FullScreenModal],
mixins: [General],
data() {
return {
tabs: [] as { id: number; value: string }[],
tabCount: 0,
tabIndex: 0,
tabs: [] as SearchData[],
headers: [
{ text: 'Filename', value: 'fileName' },
{ text: 'File Size', value: 'fileSize' },
{ text: 'Seeders', value: 'nbSeeders' },
{ text: 'Leechers', value: 'nbLeechers' },
{ text: 'Site URL', value: 'siteUrl' },
{ text: '', value: 'actions', sortable: false }
],
mdiDownload,
mdiClose,
mdiToyBrick,
mdiPlusCircleOutline,
mdiMinusCircleOutline
mdiMinusCircleOutline,
mdiStop
}
},
async mounted() {
computed: {
...mapGetters(['getModals', 'getSearchPlugins', 'shouldUseBinaryData']),
categories() {
const cats = [
{ text: 'Movies', value: 'movies' },
{ text: 'TV shows', value: 'tv' },
{ text: 'Music', value: 'music' },
{ text: 'Games', value: 'games' },
{ text: 'Anime', value: 'anime' },
{ text: 'Software', value: 'software' },
{ text: 'Pictures', value: 'pictures' },
{ text: 'Books', value: 'books' }
]
cats.sort((a, b) => a.text.localeCompare(b.text))
return [{ text: 'All categories', value: 'all' }, ...cats]
},
plugins() {
const plugins = [
{ text: 'All plugins', value: 'all' },
{ text: 'Only enabled', value: 'enabled' }
]
this.getSearchPlugins()
.filter((plugin: SearchPlugin) => plugin.enabled)
.forEach((plugin: SearchPlugin) => plugins.push({ text: plugin.fullName, value: plugin.name }))
return plugins
},
selectedTab() {
return this.tabs[this.tabIndex]
},
filteredResults() {
return this.selectedTab.results
}
},
async beforeMount() {
this.tabs = this.$store.state.searchData
if (this.tabs.length === 0) this.createNewTab()
else {
for (const tab of this.tabs) {
if (tab.id && tab.id !== 0) {
tab.timer = setInterval(() => this.refreshResults(tab), 1000)
}
}
}
await this.$store.dispatch('FETCH_SEARCH_PLUGINS')
},
mounted() {
document.addEventListener('keydown', this.handleKeyboardShortcut)
this.createNewTab()
},
async beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardShortcut)
},
computed: {
...mapGetters(['getModals'])
for (const tab of this.tabs) {
!!tab.timer && clearInterval(tab.timer)
}
},
methods: {
openPluginManager() {
@ -81,18 +196,65 @@ export default defineComponent({
},
createNewTab() {
this.tabs.push({
id: this.tabCount++,
value: `tab-${this.tabCount}`
uniqueId: genUuid(),
id: 0,
query: '',
itemsPerPage: 10,
filters: {
title: '',
category: 'all',
plugin: 'enabled'
},
results: [],
timer: null
})
this.tabIndex = this.tabs.length - 1;
const input = this.$refs.queryInput as Optional<HTMLInputElement>
input && input.focus()
},
deleteTab() {
this.tabs.find((tab, index) => {
if (tab.value === this.tab) {
this.tabs.splice(index, 1)
return true
}
return false
})
async deleteTab() {
await this.stopSearch(this.selectedTab)
this.tabs.splice(this.tabIndex, 1)
this.tabIndex = Math.min(this.tabIndex, this.tabs.length - 1)
},
downloadTorrent(item: SearchResult) {
this.createModal('AddModal', { initialMagnet: item.fileUrl })
},
async runNewSearch() {
const searchJob = await qbit.startSearch(this.selectedTab.query, this.selectedTab.filters.category, [this.selectedTab.filters.plugin])
this.selectedTab.id = searchJob.id
this.selectedTab.results = []
this.selectedTab.timer = setInterval(() => this.refreshResults(this.selectedTab), 1000)
},
async stopSearch(tab: SearchData) {
if (tab.id && tab.id !== 0) await qbit.stopSearch(tab.id)
tab.id = 0
!!tab.timer && clearInterval(tab.timer)
},
async stopAllSearch() {
for (const tab of this.tabs) {
await this.stopSearch(tab)
}
},
customFilter(value: any, search: string | null) {
return (
value != null &&
search != null &&
typeof value === 'string' &&
search
.trim()
.toLowerCase()
.split(' ')
.every(i => value.toString().toLowerCase().indexOf(i) !== -1)
)
},
async refreshResults(tab: SearchData) {
const response = await qbit.getSearchResults(tab.id, tab.results.length)
tab.results.push(...response.results)
if (response.status === 'Stopped') {
await this.stopSearch(tab)
}
},
close() {
this.$router.back()
@ -101,6 +263,10 @@ export default defineComponent({
if (e.key === 'Escape' && this.getModals().length === 0) {
this.close()
}
},
updateTabWidth() {
//@ts-expect-error: TS2339: Property 'onResize' does not exist on type '...'
this.$refs.tabs && this.$refs.tabs.onResize()
}
}
})