Implement a way to mark items from task as already downloaded.

This commit is contained in:
arabcoders 2025-08-05 17:34:07 +03:00
parent 59508b5332
commit 1589e73063
4 changed files with 197 additions and 21 deletions

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

@ -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>