Merge pull request #364 from arabcoders/dev

Implement a way to mark items from task as already downloaded.
This commit is contained in:
Abdulmohsen 2025-08-05 17:59:17 +03:00 committed by GitHub
commit 4c54f80b87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 236 additions and 23 deletions

View file

@ -313,7 +313,9 @@ class DownloadQueue(metaclass=Singleton):
async def playlist_processor(i: int, etr: dict): async def playlist_processor(i: int, etr: dict):
await self.processors.acquire() await self.processors.acquire()
try: try:
LOG.debug(f"Processing entry {i}/{playlistCount} - ID: {etr.get('id')} - Title: {etr.get('title')}") LOG.info(
f"Processing '{entry.get("title")}: {i}/{playlistCount}' - ID: {etr.get('id')} - Title: {etr.get('title')}"
)
_status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params) _status, _msg = ytdlp_reject(entry=etr, yt_params=yt_params)
if not _status: if not _status:

View file

@ -19,7 +19,8 @@ from .ItemDTO import Item
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Services import Services from .Services import Services
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import init_class, validate_url from .Utils import extract_info, init_class, validate_url
from .YTDLPOpts import YTDLPOpts
LOG: logging.Logger = logging.getLogger("tasks") LOG: logging.Logger = logging.getLogger("tasks")
@ -46,6 +47,80 @@ class Task:
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default) return self.serialize().get(key, default)
def mark(self) -> tuple[bool, str]:
if not self.url:
return False, "No URL found in task parameters."
params: YTDLPOpts = YTDLPOpts.get_instance().preset(name=self.preset)
if self.cli:
params.add_cli(self.cli, from_user=True)
params = params.get_all()
if not (_archive := params.get("download_archive", None)):
return False, "No archive file found in task parameters."
_archive: Path = Path(_archive)
if not _archive.parent.exists():
_archive.parent.mkdir(parents=True, exist_ok=True)
if not _archive.exists():
_archive.touch()
_info = extract_info(params, self.url, no_archive=True, follow_redirect=True)
if not _info or not isinstance(_info, dict):
return False, "Failed to extract information from URL."
if "playlist" != _info.get("_type"):
return False, "Expected a playlist type from extract_info."
archived_items: list[str] = []
with _archive.open() as f:
for line in f:
line = line.strip()
if not line or not isinstance(line, str) or line.startswith("#"):
continue
archived_items.append(line)
def _process(item: dict) -> bool:
status = False
for entry in item.get("entries", []):
if not isinstance(entry, dict):
continue
if "playlist" == entry.get("_type"):
_status = _process(entry)
if status is False:
status = _status
continue
if entry.get("_type") not in ("video", "url"):
continue
if not entry.get("id") or not entry.get("ie_key"):
continue
archive_id = f'{entry.get("ie_key","").lower()} {entry.get("id")}'
if archive_id in archived_items:
continue
archived_items.append(archive_id)
status = True
return status
updated = _process(_info)
if not updated:
return True, "No new items to mark as downloaded."
with _archive.open("a") as f:
for item in archived_items:
f.write(f"{item}\n")
return True, f"Task '{self.name}' marked as downloaded. Updated archive file '{_archive}'."
class Tasks(metaclass=Singleton): class Tasks(metaclass=Singleton):
""" """
@ -124,6 +199,19 @@ class Tasks(metaclass=Singleton):
"""Return the tasks.""" """Return the tasks."""
return self._tasks return self._tasks
def get(self, task_id: str) -> Task | None:
"""
Get a task by its ID.
Args:
task_id (str): The ID of the task.
Returns:
Task | None: The task if found, otherwise None.
"""
return next((task for task in self._tasks if task.id == task_id), None)
def load(self) -> "Tasks": def load(self) -> "Tasks":
""" """
Load the tasks. Load the tasks.

View file

@ -91,3 +91,38 @@ async def tasks_add(request: Request, encoder: Encoder) -> Response:
) )
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", "api/tasks/{id}/mark", "tasks_mark")
async def mark_task(request: Request, encoder: Encoder) -> Response:
"""
Mark all items from task as downloaded.
Args:
request (Request): The request object.
encoder (Encoder): The encoder instance.
Returns:
Response: The response object
"""
task_id: str = request.match_info.get("id", None)
if not task_id:
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
tasks = Tasks.get_instance()
try:
task = tasks.get(task_id)
if not task:
return web.json_response(
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
)
_status, _message = task.mark()
if not _status:
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
except ValueError as e:
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)

View file

@ -178,6 +178,10 @@ hr {
color: #fff; color: #fff;
} }
.has-text-purple {
color: #5f00d1;
}
.progress, .progress,
.progress-percentage { .progress-percentage {
border-radius: unset !important; border-radius: unset !important;

View file

@ -0,0 +1,16 @@
<template>
<Message message_class="is-success has-background-success-90 has-text-dark">
<span class="icon"><i class="fas fa-info-circle" /></span>
<span>
A New WebUI Version installed, please
<NuxtLink class="is-bold" @click="reloadPage">click here</NuxtLink>
to reload the app.
</span>
</Message>
</template>
<script setup lang="ts">
import Message from "~/components/Message.vue";
const reloadPage = () => window.location.reload();
</script>

View file

@ -1,5 +1,6 @@
<template> <template>
<div id="main_container" class="container"> <div id="main_container" class="container">
<NewVersion v-if="newVersionIsAvailable" />
<nav class="navbar is-mobile is-dark"> <nav class="navbar is-mobile is-dark">
<div class="navbar-brand pl-5"> <div class="navbar-brand pl-5">
@ -361,4 +362,19 @@ const openMenu = (e: MouseEvent) => {
elm?.classList.toggle('is-active') elm?.classList.toggle('is-active')
} }
const useVersionUpdate = () => {
const newVersionIsAvailable = ref(false)
const nuxtApp = useNuxtApp()
nuxtApp.hooks.addHooks({
'app:manifest:update': () => {
newVersionIsAvailable.value = true
}
});
return {
newVersionIsAvailable: readonly(newVersionIsAvailable),
}
}
const { newVersionIsAvailable } = useVersionUpdate()
</script> </script>

View file

@ -97,7 +97,7 @@
<div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTasks && filteredTasks.length > 0"> <div class="columns is-multiline" v-if="!isLoading && !toggleForm && filteredTasks && filteredTasks.length > 0">
<template v-if="'list' === display_style"> <template v-if="'list' === display_style">
<div class="column is-12"> <div class="column is-12">
<div class="table-container"> <div :class="{ 'table-container': table_container }">
<table class="table is-striped is-hoverable is-fullwidth is-bordered" <table class="table is-striped is-hoverable is-fullwidth is-bordered"
style="min-width: 850px; table-layout: fixed;"> style="min-width: 850px; table-layout: fixed;">
<thead> <thead>
@ -171,20 +171,6 @@
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control">
<button class="button is-purple is-small is-fullwidth" v-tooltip="'Run now'"
@click="runNow(item)" :class="{ 'is-loading': item?.in_progress }">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
</button>
</div>
<div class="control">
<button class="button is-info is-small is-fullwidth" v-tooltip="'Export'"
@click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</button>
</div>
<div class="control"> <div class="control">
<button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'" <button class="button is-warning is-small is-fullwidth" v-tooltip="'Edit'"
@click="editItem(item)"> @click="editItem(item)">
@ -197,6 +183,31 @@
<span class="icon"><i class="fa-solid fa-trash" /></span> <span class="icon"><i class="fa-solid fa-trash" /></span>
</button> </button>
</div> </div>
<div class="control is-expanded">
<Dropdown icons="fa-solid fa-cogs" label="Actions" button_classes="is-small"
@open_state="s => table_container = !s">
<NuxtLink class="dropdown-item has-text-purple" @click="runNow(item)">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
<span>Run now</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Archive all</span>
</NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-info" @click="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
<span>Export Task</span>
</NuxtLink>
</Dropdown>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@ -292,12 +303,21 @@
<span>Delete</span> <span>Delete</span>
</button> </button>
</div> </div>
<div class="card-footer-item"> <div class="card-footer-item">
<button class="button is-purple is-fullwidth" @click="runNow(item)" <Dropdown icons="fa-solid fa-cogs" label="Actions">
:class="{ 'is-loading': item?.in_progress }"> <NuxtLink class="dropdown-item has-text-purple" @click="runNow(item)">
<span class="icon"><i class="fa-solid fa-up-right-from-square" /></span> <span class="icon"><i class="fa-solid fa-up-right-from-square" /></span>
<span>Run now</span> <span>Run now</span>
</button> </NuxtLink>
<hr class="dropdown-divider" />
<NuxtLink class="dropdown-item has-text-danger" @click="archiveItems(item)">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Archive all</span>
</NuxtLink>
</Dropdown>
</div> </div>
</div> </div>
</div> </div>
@ -348,6 +368,7 @@ const selectedElms = ref<Array<string>>([])
const masterSelectAll = ref(false) const masterSelectAll = ref(false)
const massRun = ref<boolean>(false) const massRun = ref<boolean>(false)
const massDelete = ref<boolean>(false) const massDelete = ref<boolean>(false)
const table_container = ref(false)
const reset_dialog = () => ({ const reset_dialog = () => ({
visible: false, visible: false,
@ -727,4 +748,32 @@ const get_tags = (name: string): Array<string> => {
} }
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim(); const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
const archiveItems = async (item: task_item) => {
dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Archive All videos'
dialog_confirm.value.message = `Archive all items for '${item.name}' task? This will mark all items as downloaded and update the archive file.`
dialog_confirm.value.confirm = async () => await archiveAll(item)
}
const archiveAll = async (item: task_item) => {
try {
dialog_confirm.value.visible = false
item.in_progress = true
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' })
const data = await response.json()
if (data?.error) {
toast.error(data.error)
return
}
toast.success(data.message)
} catch (e: any) {
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
return
} finally {
item.in_progress = false
}
}
</script> </script>

View file

@ -76,5 +76,8 @@ export default defineNuxtConfig({
} }
}, },
telemetry: false, telemetry: false,
compatibilityDate: "2024-07-13", compatibilityDate: "2025-08-03",
experimental: {
checkOutdatedBuildInterval: 1000 * 60 * 60,
}
}) })