feat(rss): Update rule form to include enabled and torrentParams (#1501)

This commit is contained in:
Rémi Marseault 2024-02-06 09:46:50 +01:00 committed by GitHub
parent 01ac1bb577
commit aefa996a6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 450 additions and 70 deletions

View file

@ -0,0 +1,296 @@
<script lang="ts" setup>
import HistoryField from '@/components/Core/HistoryField.vue'
import { AppPreferences } from '@/constants/qbit'
import { HistoryKey } from '@/constants/vuetorrent'
import { useMaindataStore, usePreferenceStore } from '@/stores'
import { AddTorrentParams } from '@/types/qbit/models'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const form = defineModel<AddTorrentParams>({ required: true })
const isOpened = defineModel<boolean>('isOpened')
const { t } = useI18n()
const maindataStore = useMaindataStore()
const preferenceStore = usePreferenceStore()
const contentLayoutOptions = [
{ title: t('common.useGlobalSettings'), value: null },
{ title: t('constants.contentLayout.original'), value: AppPreferences.ContentLayout.ORIGINAL },
{ title: t('constants.contentLayout.subfolder'), value: AppPreferences.ContentLayout.SUBFOLDER },
{ title: t('constants.contentLayout.nosubfolder'), value: AppPreferences.ContentLayout.NO_SUBFOLDER }
]
const stopConditionOptions = [
{ title: t('common.useGlobalSettings'), value: null },
{ title: t('constants.stopCondition.none'), value: AppPreferences.StopCondition.NONE },
{ title: t('constants.stopCondition.metadataReceived'), value: AppPreferences.StopCondition.METADATA_RECEIVED },
{ title: t('constants.stopCondition.filesChecked'), value: AppPreferences.StopCondition.FILES_CHECKED }
]
const downloadPathField = ref<typeof HistoryField>()
const savePathField = ref<typeof HistoryField>()
const tagSearch = ref('')
const categorySearch = ref('')
const categoryNames = computed(() => maindataStore.categories.map(c => c.name))
const category = computed<string | undefined>({
get: () => form.value.category || categorySearch.value || undefined,
set: value => (form.value.category = value || undefined)
})
const downloadPath = computed<string | undefined>({
get: () => form.value.download_path || undefined,
set: value => {
form.value.use_download_path = !!value || false
form.value.download_path = value || undefined
}
})
function getLimit(value?: number) {
return !value || value === -1 ? '' : (value / 1024).toString()
}
const downloadLimit = computed({
get: () => getLimit(form.value.download_limit),
set: value => {
if (!value) {
form.value.download_limit = undefined
} else {
const parsedValue = parseInt(value)
if (parsedValue > 0) {
form.value.download_limit = parsedValue * 1024
}
}
}
})
const uploadLimit = computed({
get: () => getLimit(form.value.upload_limit),
set: value => {
if (!value) {
form.value.upload_limit = undefined
} else {
const parsedValue = parseInt(value)
if (parsedValue > 0) {
form.value.upload_limit = parsedValue * 1024
}
}
}
})
const ratioLimit = computed({
get: () => form.value.ratio_limit,
set: val => (form.value.ratio_limit = val || undefined)
})
const seedingTimeLimit = computed({
get: () => form.value.seeding_time_limit,
set: val => (form.value.seeding_time_limit = val || undefined)
})
const inactiveSeedingTimeLimit = computed({
get: () => form.value.inactive_seeding_time_limit,
set: val => (form.value.inactive_seeding_time_limit = val || undefined)
})
function close() {
downloadPathField.value?.saveValueToHistory()
savePathField.value?.saveValueToHistory()
isOpened.value = false
}
const onCategoryChanged = () => {
form.value.save_path = maindataStore.getCategoryFromName(form.value.category)?.savePath ?? preferenceStore.preferences!.save_path
}
</script>
<template>
<v-dialog v-model="isOpened" :class="$vuetify.display.mobile ? '' : 'w-75'" :fullscreen="$vuetify.display.mobile" scrollable>
<v-card>
<v-card-title class="ios-margin">
<v-toolbar color="transparent">
<v-toolbar-title>{{ t('dialogs.add.params.title') }}</v-toolbar-title>
<v-btn icon="mdi-close" @click="close" />
</v-toolbar>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-combobox
v-model="form.tags"
v-model:search="tagSearch"
:hide-no-data="false"
:items="maindataStore.tags"
:label="t('dialogs.add.params.tags')"
chips
clearable
hide-details
multiple
autocomplete="tags">
<template v-slot:prepend>
<v-icon color="accent">mdi-tag</v-icon>
</template>
<template v-slot:no-data>
<v-list-item>
<v-list-item-title v-if="tagSearch?.length > 0">
{{ t('dialogs.add.params.noTagMatch', { query: tagSearch }) }}
</v-list-item-title>
<v-list-item-title v-else>
{{ t('dialogs.add.params.noTags') }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</v-col>
<v-col cols="12" md="6">
<v-combobox
v-model="category"
v-model:search="categorySearch"
:hide-no-data="false"
:items="categoryNames"
:label="$t('dialogs.add.params.category')"
clearable
hide-details
autocomplete="categories"
@update:modelValue="onCategoryChanged">
<template v-slot:prepend>
<v-icon color="accent">mdi-label</v-icon>
</template>
<template v-slot:no-data>
<v-list-item>
<v-list-item-title v-if="categorySearch?.length > 0">
{{ t('dialogs.add.params.noCategoryMatch', { query: categorySearch }) }}
</v-list-item-title>
<v-list-item-title v-else>
{{ t('dialogs.add.params.noCategories') }}
</v-list-item-title>
</v-list-item>
</template>
</v-combobox>
</v-col>
<v-col cols="12">
<HistoryField
v-model="downloadPath"
:history-key="HistoryKey.TORRENT_PATH"
ref="downloadPathField"
:disabled="form.use_auto_tmm"
:label="t('dialogs.add.params.downloadPath')"
hide-details>
<template v-slot:prepend>
<v-icon color="accent">mdi-tray-arrow-down</v-icon>
</template>
</HistoryField>
</v-col>
<v-col cols="12">
<HistoryField
v-model="form.save_path"
:history-key="HistoryKey.TORRENT_PATH"
ref="savePathField"
:disabled="form.use_auto_tmm"
:label="t('dialogs.add.params.savePath')"
hide-details>
<template v-slot:prepend>
<v-icon color="accent">mdi-content-save</v-icon>
</template>
</HistoryField>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.content_layout"
:items="contentLayoutOptions"
:label="t('constants.contentLayout.title')"
color="accent"
hide-details
rounded="xl"
variant="solo-filled" />
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="form.stop_condition"
:items="stopConditionOptions"
:label="t('constants.stopCondition.title')"
color="accent"
hide-details
rounded="xl"
variant="solo-filled" />
</v-col>
</v-row>
<v-row class="mx-3">
<v-col cols="12" md="6">
<v-checkbox v-model="form.stopped" :label="t('dialogs.add.params.stopped')" color="accent" density="compact" hide-details />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="form.add_to_top_of_queue" :label="t('dialogs.add.params.add_to_top_of_queue')" color="accent" density="compact" hide-details />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="form.skip_checking" :label="t('dialogs.add.params.skip_checking')" color="accent" density="compact" hide-details />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="form.use_auto_tmm" :label="t('dialogs.add.params.use_auto_tmm')" color="accent" density="compact" hide-details />
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-expansion-panels>
<v-expansion-panel color="primary" :title="$t('dialogs.add.params.limit_collapse')">
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field v-model="downloadLimit" :label="$t('dialogs.add.params.download_limit')" hide-details suffix="KiB/s">
<template v-slot:prepend>
<v-icon color="accent">mdi-download</v-icon>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="uploadLimit" :label="$t('dialogs.add.params.upload_limit')" hide-details suffix="KiB/s">
<template v-slot:prepend>
<v-icon color="accent">mdi-upload</v-icon>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field v-model="ratioLimit" :hint="$t('dialogs.add.params.limit_hint')" :label="$t('dialogs.add.params.ratio_limit')" type="number" />
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="seedingTimeLimit"
:label="$t('dialogs.add.params.seeding_time_limit')"
:hint="$t('dialogs.add.params.limit_hint')"
:suffix="$t('units.minutes')"
type="number" />
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="inactiveSeedingTimeLimit"
:label="$t('dialogs.add.params.inactive_seeding_time_limit')"
:hint="$t('dialogs.add.params.limit_hint')"
:suffix="$t('units.minutes')"
type="number" />
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="mb-2">
<v-spacer />
<v-btn :text="$t('common.close')" color="" variant="flat" @click="close" />
</v-card-actions>
</v-card>
</v-dialog>
</template>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import AddTorrentParamsDialog from '@/components/Dialogs/AddTorrentParamsDialog.vue'
import { useDialog } from '@/composables'
import { ContentLayout } from '@/constants/qbit/AppPreferences'
import { useMaindataStore, useRssStore } from '@/stores'
import { FeedRule } from '@/types/qbit/models'
import { useMaindataStore, usePreferenceStore, useRssStore } from '@/stores'
import { FeedRule, getEmptyParams } from '@/types/qbit/models'
import { computed, onBeforeMount, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { VForm } from 'vuetify/components'
@ -12,62 +12,46 @@ const props = defineProps<{
initialRule?: FeedRule
}>()
const hasInitialRule = computed(() => {
return !!(props.initialRule && props.initialRule.name)
})
const { isOpened } = useDialog(props.guid)
const { t } = useI18n()
const maindataStore = useMaindataStore()
const preferenceStore = usePreferenceStore()
const rssStore = useRssStore()
const form = ref<VForm>()
const isFormValid = ref(false)
const formData = reactive({
addPaused: null,
affectedFeeds: [] as string[],
assignedCategory: '',
enabled: true,
episodeFilter: '',
ignoreDays: 0,
lastMatch: '',
mustContain: '',
mustNotContain: '',
name: '',
savePath: '',
smartFilter: false,
torrentContentLayout: null,
useRegex: false
})
const formData = reactive<FeedRule>(getEmptyRule())
const lastSavedName = ref('')
const matchingArticles = ref<{ type: string; value?: string }[]>([])
const addPausedOptions = [
{ title: t('common.useGlobalSettings'), value: null },
{ title: t('constants.addPaused.always'), value: true },
{ title: t('constants.addPaused.never'), value: false }
]
const contentLayoutOptions = [
{ title: t('common.useGlobalSettings'), value: null },
{ title: t('constants.contentLayout.original'), value: ContentLayout.ORIGINAL },
{ title: t('constants.contentLayout.subfolder'), value: ContentLayout.SUBFOLDER },
{ title: t('constants.contentLayout.nosubfolder'), value: ContentLayout.NO_SUBFOLDER }
]
const categories = computed(() => {
return [
{ title: t('common.none'), value: '' },
...maindataStore.categories.map(category => ({
title: category.name,
value: category.name
}))
]
})
const lastMatch = computed(() => {
if (formData.lastMatch === '') return t('dialogs.rss.rule.lastMatch.unknownValue').toString()
const delta = new Date().getTime() - new Date(formData.lastMatch).getTime()
return t('dialogs.rss.rule.lastMatch.knownValue', Math.floor(delta / (1000 * 60 * 60 * 24)).toString())
})
const hasInitialRule = computed(() => {
return !!(props.initialRule && props.initialRule.name)
})
function getEmptyRule(): FeedRule {
return {
affectedFeeds: [] as string[],
enabled: true,
episodeFilter: '',
ignoreDays: 0,
lastMatch: '',
mustContain: '',
mustNotContain: '',
name: '',
priority: 0,
smartFilter: false,
useRegex: false,
previouslyMatchedEpisodes: hasInitialRule.value ? props.initialRule!.previouslyMatchedEpisodes : [],
torrentParams: getEmptyParams(preferenceStore.preferences)
}
}
async function updateArticles() {
if (lastSavedName.value === '') return
@ -120,8 +104,6 @@ onBeforeMount(async () => {
if (hasInitialRule.value) {
lastSavedName.value = props.initialRule!.name!
Object.assign(formData, props.initialRule!)
} else {
form.value?.reset()
}
await updateArticles()
@ -142,6 +124,19 @@ onBeforeMount(async () => {
<v-col cols="12" sm="6" class="scrollable-col">
<v-text-field v-model="formData.name" autofocus required :label="$t('dialogs.rss.rule.name')" />
<div class="d-flex">
<v-switch v-model="formData.enabled" color="accent" inset hide-details :label="$t('dialogs.rss.rule.enabled')" />
<v-spacer />
<div class="d-flex align-center">
<v-btn class="d-flex align-center justify-center" color="accent">
{{ $t('dialogs.add.params.title') }}
<AddTorrentParamsDialog v-model="formData.torrentParams" activator="parent" />
</v-btn>
</div>
</div>
<v-divider />
<v-checkbox v-model="formData.useRegex" hide-details :label="$t('dialogs.rss.rule.useRegex')" />
@ -152,16 +147,11 @@ onBeforeMount(async () => {
<v-divider class="mb-4" />
<v-select v-model="formData.assignedCategory" :items="categories" :label="$t('dialogs.rss.rule.assignedCategory')" />
<v-text-field v-model="formData.savePath" :placeholder="$t('dialogs.rss.rule.savePathPlaceholder')" :label="$t('dialogs.rss.rule.savePath')" />
<v-text-field v-model.number="formData.ignoreDays" type="number" :hint="$t('dialogs.rss.rule.ignoreDaysHint')" :label="$t('dialogs.rss.rule.ignoreDays')" />
<v-text-field v-model="lastMatch" disabled :label="$t('dialogs.rss.rule.lastMatch.label')" />
<v-divider />
<v-select v-model="formData.addPaused" :items="addPausedOptions" :label="$t('constants.addPaused.title')" />
<v-select v-model="formData.torrentContentLayout" :items="contentLayoutOptions" :label="$t('constants.contentLayout.title')" />
<v-list-subheader>{{ $t('dialogs.rss.rule.affectedFeedsSubheader') }}</v-list-subheader>
<v-row>

View file

@ -11,6 +11,11 @@ const rssStore = useRssStore()
const loading = ref(false)
const ruleDialog = ref('')
async function toggleRule(rule: FeedRule) {
await rssStore.setRule(rule.name, { ...rule, enabled: !rule.enabled })
await rssStore.fetchRules()
}
async function deleteRule(rule: FeedRule) {
await rssStore.deleteRule(rule.name!)
await rssStore.fetchRules()
@ -50,8 +55,11 @@ watch(
<div class="pl-4">{{ rule.name }}</div>
<v-spacer />
<div>
<v-btn icon="mdi-pencil" variant="plain" density="compact" @click="openRuleDialog(rule)" />
<v-btn icon="mdi-delete" color="red" variant="plain" @click="deleteRule(rule)" />
<v-btn v-if="rule.enabled" class="my-2 mr-2" icon="mdi-check" color="accent" variant="plain" density="compact" @click="toggleRule(rule)" />
<v-btn v-if="!rule.enabled" class="my-2 mr-2" icon="mdi-cancel" color="red" variant="plain" density="compact" @click="toggleRule(rule)" />
<v-btn class="my-2 mr-2" icon="mdi-pencil" variant="plain" density="compact" @click="openRuleDialog(rule)" />
<v-btn class="my-2 mr-2" icon="mdi-delete" color="red" variant="plain" density="compact" @click="deleteRule(rule)" />
</div>
</v-sheet>
</v-col>

View file

@ -272,6 +272,28 @@
"noCategoryMatch": "No categories matching \"{query}\". It will be created automatically.",
"noTagMatch": "No tags matching \"{query}\". Press Enter to create it.",
"noTags": "No tags yet. Type a name to create one.",
"params": {
"title": "Torrent parameters",
"add_to_top_of_queue": "Add to top of queue",
"use_auto_tmm": "Automatic Torrent Management",
"downloadPath": "Download path (when incomplete)",
"download_limit": "Download limit",
"category": "Category",
"limit_collapse": "Configure limits",
"noCategories": "No categories yet. Type a name to create one.",
"noCategoryMatch": "No categories matching \"{query}\". It will be created automatically.",
"tags": "Tags",
"noTagMatch": "No tags matching \"{query}\". Press Enter to create it.",
"noTags": "No tags yet. Type a name to create one.",
"savePath": "Save path (when completed)",
"skip_checking": "Skip hash checking",
"stopped": "Don't start torrent",
"upload_limit": "Upload limit",
"ratio_limit": "Ratio limit",
"limit_hint": "-1 to disable, -2 or empty to use global value",
"seeding_time_limit": "Seeding time limit",
"inactive_seeding_time_limit": "Inactive seeding time limit"
},
"pending": "Sending torrents...",
"ratioLimit": "Ratio limit",
"rename": "Rename torrent",

View file

@ -1,4 +1,4 @@
import { ConnectionStatus, FilePriority, LogType, PieceState, TorrentState } from '@/constants/qbit'
import { ConnectionStatus, FilePriority, LogType, PieceState, TorrentOperatingMode, TorrentState } from '@/constants/qbit'
import { ContentLayout, ProxyType, ResumeDataStorageType, StopCondition } from '@/constants/qbit/AppPreferences'
import type {
ApplicationVersion,
@ -458,9 +458,7 @@ export default class MockProvider implements IProvider {
return this.generateResponse({
result: [
{
addPaused: null,
affectedFeeds: ['https://www.example.com/feed'],
assignedCategory: '',
enabled: true,
episodeFilter: '',
ignoreDays: 0,
@ -470,15 +468,13 @@ export default class MockProvider implements IProvider {
name: 'rule1',
previouslyMatchedEpisodes: [],
priority: 0,
savePath: '',
smartFilter: false,
torrentContentLayout: null,
torrentParams: {
category: '',
download_limit: -1,
download_path: '',
inactive_seeding_time_limit: -2,
operating_mode: 'AutoManaged',
operating_mode: TorrentOperatingMode.AUTO_MANAGED,
ratio_limit: -2,
save_path: '',
seeding_time_limit: -2,

View file

@ -0,0 +1,59 @@
import { ContentLayout, StopCondition } from '@/constants/qbit/AppPreferences'
import { AppPreferences } from '@/types/qbit/models'
import { TorrentOperatingMode } from '@/constants/qbit'
export default interface AddTorrentParams {
/** Whether this torrent should be added at the top of the waiting queue */
add_to_top_of_queue?: boolean
/** Torrent category */
category?: string
/** Overrides the default value of the torrent content layout */
content_layout?: ContentLayout
/** Torrent download limit (in kb/s), set to -1 to disable (default) */
download_limit?: number
/** Torrent download path */
download_path?: string
/** Inactive upload seeding time limit (in minutes), -1 to disable, -2 to use global value (default) */
inactive_seeding_time_limit?: number
/** Torrent operating mode (used for forced state) */
operating_mode?: TorrentOperatingMode
/** Ratio limit, -1 to disable, -2 to use global value (default) */
ratio_limit?: number
/** Torrent save path */
save_path: string
/** Upload seeding time limit (in minutes), -1 to disable, -2 to use global value (default) */
seeding_time_limit?: number
/** Skip hash checking */
skip_checking: boolean
/** Overrides the default value of the torrent stop condition */
stop_condition?: StopCondition
/** Whether this torrent should be added in paused state */
stopped?: boolean
/** List of torrent tags */
tags?: string[]
/** Torrent upload limit (in kb/s), set to -1 to disable (default) */
upload_limit?: number
/** Overrides the default value of the TMM attribute */
use_auto_tmm?: boolean
/** Whether the download_path attribute should be used */
use_download_path?: boolean
}
export function getEmptyParams(prefs?: AppPreferences): AddTorrentParams {
return {
save_path: prefs?.save_path ?? '',
skip_checking: false,
add_to_top_of_queue: prefs?.add_to_top_of_queue ?? false,
content_layout: prefs?.torrent_content_layout,
stop_condition: prefs?.torrent_stop_condition,
download_limit: prefs?.dl_limit,
upload_limit: prefs?.up_limit,
use_download_path: !!prefs?.temp_path,
download_path: !!prefs?.temp_path ? prefs?.temp_path : '',
stopped: prefs?.start_paused_enabled,
use_auto_tmm: prefs?.auto_tmm_enabled,
ratio_limit: -2,
seeding_time_limit: -2,
inactive_seeding_time_limit: -2
}
}

View file

@ -1,8 +1,10 @@
import { AddTorrentParams } from '@/types/qbit/models'
export default interface FeedRule {
/** The feed URLs the rule applies to */
affectedFeeds?: string[]
affectedFeeds: string[]
/** Whether the rule is enabled */
enabled?: boolean
enabled: boolean
/**
* Matches articles based on episode filter.
*
@ -18,18 +20,18 @@ export default interface FeedRule {
* - * Normal range: 1x25-40; matches episodes 25 through 40 of season one
* - * Infinite range: 1x25-; matches episodes 25 and upward of season one, and all episodes of later seasons
*/
episodeFilter?: string
episodeFilter: string
/**
* Ignore articles where its date is within n days
* Values less than 1 will be ignored
*/
ignoreDays?: number
ignoreDays: number
/**
* The rule last match time
* Must match RFC-2822 or ISO-8601 date format
* source: https://www.rfc-editor.org/rfc/rfc2822#page-14
*/
lastMatch?: string
lastMatch: string
/**
* Wildcard mode: you can use
*
@ -42,7 +44,7 @@ export default interface FeedRule {
*
* An expression with an empty | clause (e.g. expr|) will match all articles.
*/
mustContain?: string
mustContain: string
/**
* Wildcard mode: you can use
*
@ -55,18 +57,20 @@ export default interface FeedRule {
*
* An expression with an empty | clause (e.g. expr|) will exclude all articles.
*/
mustNotContain?: string
mustNotContain: string
/** The rule name */
name?: string
name: string
/** The list of episodes already matched by smart filter */
previouslyMatchedEpisodes?: string[]
previouslyMatchedEpisodes: string[]
/** The rule priority */
priority?: number
priority: number
/**
* Smart Episode Filter will check the episode number to prevent downloading of duplicates.
* Supports the formats: S01E01, 1x1, 2017.12.31 and 31.12.2017 (Date formats also support - as a separator)
*/
smartFilter?: boolean
smartFilter: boolean
/** Parameters to apply to torrents added using that rule */
torrentParams: AddTorrentParams
/** Enable regex mode in "mustContain" and "mustNotContain" */
useRegex?: boolean
useRegex: boolean
}

View file

@ -1,3 +1,5 @@
import type AddTorrentParams from './AddTorrentParams'
import { getEmptyParams } from './AddTorrentParams'
import type AppPreferences from './AppPreferences'
import type Category from './Category'
import type ServerState from './ServerState'
@ -17,7 +19,10 @@ import type Log from './Log'
type ApplicationVersion = string
export { getEmptyParams }
export type {
AddTorrentParams,
ApplicationVersion,
AppPreferences,
Category,