Refactor: add priority to presets
This commit is contained in:
parent
459dee77db
commit
3c7157542d
8 changed files with 215 additions and 45 deletions
|
|
@ -103,6 +103,9 @@ class Preset:
|
|||
default: bool = False
|
||||
"""If True, the preset is a default preset."""
|
||||
|
||||
priority: int = 0
|
||||
"""Priority of the preset."""
|
||||
|
||||
_cookies_file: Path | None = field(init=False, default=None)
|
||||
"""The path to the cookies file."""
|
||||
|
||||
|
|
@ -219,7 +222,7 @@ class Presets(metaclass=Singleton):
|
|||
|
||||
def get_all(self) -> list[Preset]:
|
||||
"""Return the items."""
|
||||
return self._default + self._items
|
||||
return sorted(self._default + self._items, key=lambda x: x.priority, reverse=True)
|
||||
|
||||
def load(self) -> "Presets":
|
||||
"""
|
||||
|
|
@ -249,6 +252,10 @@ class Presets(metaclass=Singleton):
|
|||
|
||||
for i, preset in enumerate(presets):
|
||||
try:
|
||||
if "priority" not in preset:
|
||||
preset["priority"] = 0
|
||||
need_save = True
|
||||
|
||||
if "id" not in preset:
|
||||
preset["id"] = str(uuid.uuid4())
|
||||
need_save = True
|
||||
|
|
@ -272,7 +279,7 @@ class Presets(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
if need_save:
|
||||
LOG.info(f"Saving '{self._file}'.")
|
||||
LOG.warning("Saving presets due to schema changes.")
|
||||
self.save(self._items)
|
||||
|
||||
return self
|
||||
|
|
@ -328,6 +335,15 @@ class Presets(metaclass=Singleton):
|
|||
msg = f"Invalid command options for yt-dlp. '{e!s}'."
|
||||
raise ValueError(msg) from e
|
||||
|
||||
if item.get("priority") is not None:
|
||||
priority = item.get("priority")
|
||||
if not isinstance(priority, int):
|
||||
msg = "Priority must be an integer."
|
||||
raise ValueError(msg)
|
||||
if priority < 0:
|
||||
msg = "Priority must be >= 0."
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
def save(self, items: list[Preset | dict]) -> "Presets":
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@
|
|||
"type": "boolean",
|
||||
"description": "Whether this preset is a default preset.",
|
||||
"default": false
|
||||
},
|
||||
"priority": {
|
||||
"type": "integer",
|
||||
"description": "Priority of the preset for sorting. Higher priority presets appear first.",
|
||||
"default": 0,
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
@ -54,12 +60,14 @@
|
|||
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
||||
"name": "default",
|
||||
"default": true,
|
||||
"priority": 0,
|
||||
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log",
|
||||
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio."
|
||||
},
|
||||
{
|
||||
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
||||
"name": "Audio Only",
|
||||
"priority": 5,
|
||||
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class TestPreset:
|
|||
cookies="test_cookies",
|
||||
cli="--test-option",
|
||||
default=True,
|
||||
priority=10,
|
||||
)
|
||||
|
||||
assert preset.id == preset_id
|
||||
|
|
@ -48,6 +49,7 @@ class TestPreset:
|
|||
assert preset.template == "test_template"
|
||||
assert preset.cookies == "test_cookies"
|
||||
assert preset.cli == "--test-option"
|
||||
assert preset.priority == 10
|
||||
assert preset.default is True
|
||||
|
||||
def test_preset_serialize(self):
|
||||
|
|
@ -597,3 +599,100 @@ class TestPresets:
|
|||
|
||||
# Should have logged errors for invalid presets
|
||||
mock_log.error.assert_called()
|
||||
|
||||
@patch("app.library.Presets.Config")
|
||||
@patch("app.library.Presets.EventBus")
|
||||
def test_preset_priority_validation_integer(self, mock_eventbus, mock_config):
|
||||
"""Test that priority must be an integer."""
|
||||
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
presets_file = Path(temp_dir) / "presets.json"
|
||||
presets = Presets(file=presets_file, config=mock_config_instance)
|
||||
|
||||
# Test with non-integer priority
|
||||
invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": "not_an_int"}
|
||||
|
||||
with pytest.raises(ValueError, match="Priority must be an integer"):
|
||||
presets.validate(invalid_preset)
|
||||
|
||||
@patch("app.library.Presets.Config")
|
||||
@patch("app.library.Presets.EventBus")
|
||||
def test_preset_priority_validation_negative(self, mock_eventbus, mock_config):
|
||||
"""Test that priority must be >= 0."""
|
||||
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
presets_file = Path(temp_dir) / "presets.json"
|
||||
presets = Presets(file=presets_file, config=mock_config_instance)
|
||||
|
||||
# Test with negative priority
|
||||
invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": -5}
|
||||
|
||||
with pytest.raises(ValueError, match="Priority must be >= 0"):
|
||||
presets.validate(invalid_preset)
|
||||
|
||||
@patch("app.library.Presets.Config")
|
||||
@patch("app.library.Presets.EventBus")
|
||||
def test_preset_priority_sorting(self, mock_eventbus, mock_config):
|
||||
"""Test that presets are sorted by priority in descending order."""
|
||||
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
presets_file = Path(temp_dir) / "presets.json"
|
||||
presets = Presets(file=presets_file, config=mock_config_instance)
|
||||
|
||||
# Create presets with different priorities
|
||||
preset1 = Preset(name="low", priority=1)
|
||||
preset2 = Preset(name="high", priority=10)
|
||||
preset3 = Preset(name="medium", priority=5)
|
||||
|
||||
presets.save([preset1, preset2, preset3]).load()
|
||||
|
||||
all_presets = presets.get_all()
|
||||
non_default = [p for p in all_presets if not p.default]
|
||||
|
||||
# Should be sorted by priority descending
|
||||
assert non_default[0].name == "high"
|
||||
assert non_default[0].priority == 10
|
||||
assert non_default[1].name == "medium"
|
||||
assert non_default[1].priority == 5
|
||||
assert non_default[2].name == "low"
|
||||
assert non_default[2].priority == 1
|
||||
|
||||
@patch("app.library.Presets.Config")
|
||||
@patch("app.library.Presets.EventBus")
|
||||
def test_preset_priority_default_zero(self, mock_eventbus, mock_config):
|
||||
"""Test that priority defaults to 0 if not specified."""
|
||||
preset = Preset(name="test")
|
||||
assert preset.priority == 0
|
||||
|
||||
@patch("app.library.Presets.Config")
|
||||
@patch("app.library.Presets.EventBus")
|
||||
def test_preset_priority_migration(self, mock_eventbus, mock_config):
|
||||
"""Test that old presets without priority get it added on load."""
|
||||
mock_config_instance = Mock(config_path="/tmp", default_preset="default")
|
||||
mock_config.get_instance.return_value = mock_config_instance
|
||||
mock_eventbus.get_instance.return_value = Mock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
presets_file = Path(temp_dir) / "presets.json"
|
||||
|
||||
# Create a preset file without priority field
|
||||
old_preset_data = [{"id": str(uuid.uuid4()), "name": "old_preset", "cli": ""}]
|
||||
presets_file.write_text(json.dumps(old_preset_data))
|
||||
|
||||
# Load presets - should migrate by adding priority
|
||||
presets = Presets(file=presets_file, config=mock_config_instance)
|
||||
presets.load()
|
||||
|
||||
# Check that priority was added
|
||||
loaded_data = json.loads(presets_file.read_text())
|
||||
assert "priority" in loaded_data[0]
|
||||
assert loaded_data[0]["priority"] == 0
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@
|
|||
|
||||
</template>
|
||||
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="name">
|
||||
<span class="icon"><i class="fa-solid fa-tag" /></span>
|
||||
|
|
@ -103,6 +102,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="priority">
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
Priority
|
||||
</label>
|
||||
<div class="control">
|
||||
<input type="number" class="input" id="priority" v-model.number="form.priority"
|
||||
:disabled="addInProgress" min="0" placeholder="0">
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span class="is-bold">Higher priority presets appear first in the list.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<label class="label is-inline" for="folder">
|
||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||
|
|
@ -262,6 +278,10 @@ const showOptions = ref<boolean>(false)
|
|||
const ytDlpOpt = ref<AutoCompleteOptions>([])
|
||||
const cookiesDropzoneRef = ref<InstanceType<typeof TextDropzone> | null>(null)
|
||||
|
||||
if (form.priority === undefined) {
|
||||
form.priority = 0
|
||||
}
|
||||
|
||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||
.filter(opt => !opt.ignored)
|
||||
.flatMap(opt => opt.flags
|
||||
|
|
@ -373,6 +393,10 @@ const importItem = async (): Promise<void> => {
|
|||
form.description = item.description
|
||||
}
|
||||
|
||||
if (item.priority !== undefined) {
|
||||
form.priority = item.priority
|
||||
}
|
||||
|
||||
import_string.value = ''
|
||||
showImport.value = false
|
||||
} catch (e: any) {
|
||||
|
|
@ -399,6 +423,7 @@ const import_existing_preset = async (): Promise<void> => {
|
|||
form.template = preset.template || ''
|
||||
form.cookies = preset.cookies || ''
|
||||
form.description = preset.description || ''
|
||||
form.priority = preset.priority ?? 0
|
||||
|
||||
await nextTick()
|
||||
selected_preset.value = ''
|
||||
|
|
|
|||
|
|
@ -74,10 +74,23 @@
|
|||
<tbody>
|
||||
<tr v-for="item in presetsNoDefault" :key="item.id">
|
||||
<td class="is-text-overflow is-vcentered">
|
||||
{{ item.name }}
|
||||
<span class="icon" v-if="item.cookies" v-tooltip="'Has cookies'">
|
||||
<i class="fa-solid fa-cookie has-text-primary" />
|
||||
</span>
|
||||
<div class="is-text-overflow is-bold">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="is-unselectable">
|
||||
<span class="icon-text" :class="{ 'has-text-primary': item.cookies }">
|
||||
<span class="icon"><i class="fa-solid fa-cookie" /></span>
|
||||
<span>{{ item.cookies ? 'Has cookies' : 'No cookies' }}</span>
|
||||
</span>
|
||||
|
||||
<template v-if="item.priority > 0">
|
||||
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
<span>Priority: {{ item.priority }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="is-vcentered is-items-center">
|
||||
<div class="field is-grouped is-grouped-centered">
|
||||
|
|
@ -116,16 +129,32 @@
|
|||
:class="{ 'is-text-overflow': !isExpanded(item.id, 'title') }" @click="toggleExpand(item.id, 'title')"
|
||||
:title="!isExpanded(item.id, 'title') ? 'Click to expand' : 'Click to collapse'" v-text="item.name" />
|
||||
<div class="card-header-icon">
|
||||
<span v-if="item.cookies" class="icon" v-tooltip="'Has cookies'">
|
||||
<i class="fa-solid fa-cookie has-text-primary" />
|
||||
</span>
|
||||
<button class="has-text-info" v-tooltip="'Export preset'" @click="exportItem(item)">
|
||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||
</button>
|
||||
<div class="field is-grouped">
|
||||
<div class="control" v-if="item.priority > 0">
|
||||
<span class="tag is-dark">
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
<span v-text="item.priority" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="control" v-if="item.cookies" v-tooltip="'This preset has cookies'">
|
||||
<span class="icon has-text-primary"><i class="fa-solid fa-cookie" /></span>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="has-text-info" v-tooltip="'Export preset'" @click="exportItem(item)">
|
||||
<span class="icon"><i class="fa-solid fa-file-export" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content is-flex-grow-1">
|
||||
<div class="content">
|
||||
<template v-if="item.priority > 0">
|
||||
<p>
|
||||
<span class="icon"><i class="fa-solid fa-sort-numeric-down" /></span>
|
||||
<span>Priority: {{ item.priority }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p :class="{ 'is-text-overflow': !isExpanded(item.id, 'folder'), 'is-clickable': true }"
|
||||
v-if="item.folder" @click="toggleExpand(item.id, 'folder')"
|
||||
:title="!isExpanded(item.id, 'folder') ? 'Click to expand' : 'Click to collapse'">
|
||||
|
|
@ -204,6 +233,8 @@ import { useStorage } from '@vueuse/core'
|
|||
import type { Preset } from '~/types/presets'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
type PresetWithUI = Preset & { raw?: boolean, toggle_description?: boolean }
|
||||
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
|
@ -212,7 +243,7 @@ const box = useConfirm()
|
|||
const display_style = useStorage<string>('preset_display_style', 'cards')
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
|
||||
const presets = ref<Preset[]>([])
|
||||
const presets = ref<PresetWithUI[]>([])
|
||||
const preset = ref<Partial<Preset>>({})
|
||||
const presetRef = ref<string | null>('')
|
||||
const toggleForm = ref(false)
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ export const useConfigStore = defineStore('config', () => {
|
|||
'template': '',
|
||||
'cookies': '',
|
||||
'cli': '',
|
||||
'default': true
|
||||
'default': true,
|
||||
'priority': 0
|
||||
}
|
||||
],
|
||||
dl_fields: [],
|
||||
|
|
|
|||
30
ui/app/types/config.d.ts
vendored
30
ui/app/types/config.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import type { Preset } from "./presets";
|
||||
import type { YTDLPOption } from './ytdlp';
|
||||
import type { DLField } from "./dl_fields"
|
||||
|
||||
|
|
@ -46,42 +47,23 @@ type AppConfig = {
|
|||
default_pagination: number
|
||||
}
|
||||
|
||||
type Preset = {
|
||||
/** Unique identifier for the preset */
|
||||
id?: string
|
||||
/** Preset name, e.g. "default" */
|
||||
name: string
|
||||
/** Optional description for the preset */
|
||||
description: string
|
||||
/** Folder where files will be saved, e.g. "/downloads" */
|
||||
folder: string
|
||||
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */
|
||||
template: string
|
||||
/** Cookies for the preset, e.g. "cookies.txt" */
|
||||
cookies: string
|
||||
/** Additional command line options for yt-dlp */
|
||||
cli: string
|
||||
/** Indicates if this is the default preset */
|
||||
default: boolean
|
||||
}
|
||||
|
||||
type ConfigState = {
|
||||
/** Show or hide the download form */
|
||||
showForm: RemovableRef<boolean>
|
||||
/** Application configuration */
|
||||
app: AppConfig
|
||||
/** List of presets */
|
||||
presets: Preset[]
|
||||
presets: Array<Preset>
|
||||
/** List of custom download fields */
|
||||
dl_fields: DLField[]
|
||||
dl_fields: Array<DLField>
|
||||
/** List of folders where files can be saved */
|
||||
folders: string[]
|
||||
folders: Array<string>
|
||||
/** List of yt-dlp options */
|
||||
ytdlp_options: YTDLPOption[]
|
||||
ytdlp_options: Array<YTDLPOption>
|
||||
/** Indicates if downloads are currently paused */
|
||||
paused: boolean
|
||||
/** Indicates if the configuration has been loaded */
|
||||
is_loaded: boolean
|
||||
}
|
||||
|
||||
export type { AppConfig, Preset, ConfigState }
|
||||
export type { AppConfig, ConfigState }
|
||||
|
|
|
|||
18
ui/app/types/presets.d.ts
vendored
18
ui/app/types/presets.d.ts
vendored
|
|
@ -1,14 +1,22 @@
|
|||
type Preset = {
|
||||
/** Unique identifier for the preset */
|
||||
id?: string
|
||||
/** Preset name, e.g. "default" */
|
||||
name: string
|
||||
cli: string
|
||||
cookies: string
|
||||
default: boolean
|
||||
/** Optional description for the preset */
|
||||
description: string
|
||||
/** Folder where files will be saved, e.g. "/downloads" */
|
||||
folder: string
|
||||
/** Output template for the preset, e.g. "%(title)s.%(ext)s" */
|
||||
template: string
|
||||
raw?:boolean
|
||||
toggle_description?: boolean
|
||||
/** Cookies for the preset, e.g. "cookies.txt" */
|
||||
cookies: string
|
||||
/** Additional command line options for yt-dlp */
|
||||
cli: string
|
||||
/** Indicates if this is the default preset */
|
||||
default: boolean
|
||||
/** Priority for sorting. Higher priority presets appear first */
|
||||
priority: number
|
||||
}
|
||||
|
||||
type PresetImport = Preset & {
|
||||
|
|
|
|||
Loading…
Reference in a new issue