migrated /api/archive to /api/history/{id}/archive

This commit is contained in:
arabcoders 2025-08-29 18:40:53 +03:00
parent fd999211ef
commit 20cfc00640
8 changed files with 150 additions and 220 deletions

20
API.md
View file

@ -25,8 +25,8 @@ This document describes the available endpoints and their usage. All endpoints r
- [POST /api/history/{id}](#post-apihistoryid) - [POST /api/history/{id}](#post-apihistoryid)
- [GET /api/history/{id}](#get-apihistoryid) - [GET /api/history/{id}](#get-apihistoryid)
- [GET /api/history](#get-apihistory) - [GET /api/history](#get-apihistory)
- [DELETE /api/archive/{id}](#delete-apiarchiveid) - [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/archive/{id}](#post-apiarchiveid) - [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/tasks](#get-apitasks) - [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks) - [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark) - [POST /api/tasks/{id}/mark](#post-apitasksidmark)
@ -361,17 +361,11 @@ or an error:
--- ---
### DELETE /api/archive/{id} ### DELETE /api/history/{id}/archive
**Purpose**: Remove an item's URL from the yt-dlp archive file, allowing it to be re-downloaded. **Purpose**: Remove an item from archive file, allowing it to be re-downloaded.
**Path Parameter**: **Path Parameter**:
- `id`: Item ID from the history (if body `url` is not provided). - `id`: Item ID from the history.
**Body (optional)**:
```json
{ "url": "https://..." }
```
If `url` is provided, it is used directly; otherwise the route resolves the item by `id` and uses its URL.
**Response**: **Response**:
```json ```json
@ -387,8 +381,8 @@ or an error:
--- ---
### POST /api/archive/{id} ### POST /api/history/{id}/archive
**Purpose**: Manually mark an item as archived by writing its archive ID to the archive file. **Purpose**: Add item to the archive file preventing it from being downloaded.
**Path Parameter**: **Path Parameter**:
- `id`: Item ID from the history. - `id`: Item ID from the history.

View file

@ -1,148 +0,0 @@
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue
from app.library.router import route
from app.library.Utils import archive_add, archive_delete, archive_read, get_archive_id
from app.library.YTDLPOpts import YTDLPOpts
if TYPE_CHECKING:
from library.Download import Download
LOG: logging.Logger = logging.getLogger(__name__)
@route("POST", r"api/archive/{id}", "archive.item")
async def archive_item(request: Request, queue: DownloadQueue):
"""
Manually mark an item as archived.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
params: dict = item.get_ytdlp_opts().get_all()
if not (archive_file := params.get("download_archive")):
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
idDict = get_archive_id(url=item.info.url)
if not (archive_id := idDict.get("archive_id")):
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if len(archive_read(archive_file, [archive_id])) > 0:
return web.json_response(
data={"error": f"item '{item.info.title}' already archived."},
status=web.HTTPConflict.status_code,
)
archive_add(archive_file, [archive_id])
return web.json_response(
data={"message": f"item '{item.info.title}' archived."},
status=web.HTTPOk.status_code,
)
@route("DELETE", r"api/archive/{id}", "archive.remove")
async def archive_remove(request: Request, queue: DownloadQueue) -> Response:
"""
Remove an item from the archive.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
Returns:
Response: The response object.
"""
item = None
try:
data: dict | None = await request.json()
except Exception:
data = {}
title: str = ""
url: str | None = data.get("url")
id: str | None = request.match_info.get("id")
preset: str | None = data.get("preset",)
if not id and not url:
return web.json_response(data={"error": "id or url is required."}, status=web.HTTPBadRequest.status_code)
if id:
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
url = item.info.url
title = f" '{item.info.title}'"
params = item.get_ytdlp_opts().get_all()
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
else:
params: YTDLPOpts = YTDLPOpts.get_instance()
if preset := data.get("preset"):
params = params.preset(name=preset)
params = params.get_all()
if not (archive_file := params.get("download_archive", None)):
return web.json_response(
data={"error": "Archive file is not configured."},
status=web.HTTPBadRequest.status_code,
)
archive_file = Path(archive_file)
if not archive_file.exists():
return web.json_response(
data={"error": f"Archive file '{archive_file}' does not exist."},
status=web.HTTPNotFound.status_code,
)
idDict = get_archive_id(url=url)
if not (archive_id := idDict.get("archive_id")):
return web.json_response(
data={"error": "item does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if not archive_delete(archive_file, [archive_id]):
return web.json_response(
data={"error": f"item{title} not found in '{archive_file}' archive."},
status=web.HTTPNotFound.status_code,
)
return web.json_response(
data={"message": f"item{title} removed from '{archive_file}' archive."},
status=web.HTTPOk.status_code,
)

View file

@ -7,13 +7,14 @@ from aiohttp import web
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from app.library.config import Config from app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder from app.library.encoder import Encoder
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item from app.library.ItemDTO import Item
from app.library.Presets import Preset, Presets from app.library.Presets import Preset, Presets
from app.library.router import route from app.library.router import route
from app.library.Utils import archive_read from app.library.Utils import archive_add, archive_delete, archive_read, get_archive_id
if TYPE_CHECKING: if TYPE_CHECKING:
from library.Download import Download from library.Download import Download
@ -263,3 +264,116 @@ async def items_add(request: Request, queue: DownloadQueue, encoder: Encoder) ->
response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")}) response.append({"item": item, "status": "ok" == status[i].get("status"), "msg": status[i].get("msg")})
return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=encoder.encode)
@route("POST", r"api/history/{id}/archive", "history.item.archive.add")
async def item_archive_add(request: Request, queue: DownloadQueue) -> Response:
"""
Manually mark an item as archived.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
config (Config): The configuration instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
params: dict = item.get_ytdlp_opts().get_all()
if not (archive_file := params.get("download_archive")):
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive file."},
status=web.HTTPBadRequest.status_code,
)
idDict = get_archive_id(url=item.info.url)
if not (archive_id := idDict.get("archive_id")):
return web.json_response(
data={"error": f"item '{item.info.title}' does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if len(archive_read(archive_file, [archive_id])) > 0:
return web.json_response(
data={"error": f"item '{item.info.title}' already archived."},
status=web.HTTPConflict.status_code,
)
archive_add(archive_file, [archive_id])
return web.json_response(
data={"message": f"item '{item.info.title}' archived."},
status=web.HTTPOk.status_code,
)
@route("DELETE", r"api/history/{id}/archive", "history.item.archive.delete")
async def item_archive_delete(request: Request, queue: DownloadQueue) -> Response:
"""
Remove an item from the archive.
Args:
request (Request): The request object.
queue (DownloadQueue): The download queue instance.
Returns:
Response: The response object.
"""
if not (id := request.match_info.get("id")):
return web.json_response(data={"error": "id is required."}, status=web.HTTPBadRequest.status_code)
try:
item: Download | None = queue.done.get_by_id(id)
if not item:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
except KeyError:
return web.json_response(data={"error": f"item '{id}' not found."}, status=web.HTTPNotFound.status_code)
url: str = item.info.url
title: str = f" '{item.info.title}'"
params: dict = item.get_ytdlp_opts().get_all()
if not (archive_file := params.get("download_archive")):
return web.json_response(
data={"error": "Archive file is not configured."},
status=web.HTTPBadRequest.status_code,
)
archive_file = Path(archive_file)
if not archive_file.exists():
return web.json_response(
data={"error": f"Archive file '{archive_file}' does not exist."},
status=web.HTTPNotFound.status_code,
)
idDict = get_archive_id(url=url)
if not (archive_id := idDict.get("archive_id")):
return web.json_response(
data={"error": "item does not have an archive ID."},
status=web.HTTPBadRequest.status_code,
)
if not archive_delete(archive_file, [archive_id]):
return web.json_response(
data={"error": f"item{title} not found in '{archive_file}' archive."},
status=web.HTTPNotFound.status_code,
)
return web.json_response(
data={"message": f"item{title} removed from '{archive_file}' archive."},
status=web.HTTPOk.status_code,
)

View file

@ -47,6 +47,7 @@ const emitter = defineEmits<{ (e: 'closeModel'): void }>()
const props = defineProps<{ const props = defineProps<{
link?: string link?: string
preset?: string preset?: string
cli?: string
useUrl?: boolean useUrl?: boolean
externalModel?: boolean externalModel?: boolean
}>() }>()
@ -71,6 +72,9 @@ onMounted(async (): Promise<void> => {
if (props.preset) { if (props.preset) {
params.append('preset', props.preset) params.append('preset', props.preset)
} }
if (props.cli) {
params.append('args', props.cli)
}
params.append('url', props.link || '') params.append('url', props.link || '')
url += '?' + params.toString() url += '?' + params.toString()
} }

View file

@ -193,7 +193,7 @@
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</template> </template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
@ -368,7 +368,7 @@
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</template> </template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)" <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode"> v-if="!config.app.basic_mode">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
@ -466,7 +466,7 @@ import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store'
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void (e: 'getInfo', url: string, preset: string, cli: string): void
(e: 'add_new', item: Partial<StoreItem>): void (e: 'add_new', item: Partial<StoreItem>): void
(e: 'getItemInfo', id: string): void (e: 'getItemInfo', id: string): void
(e: 'clear_search'): void (e: 'clear_search'): void
@ -737,7 +737,7 @@ const addArchiveDialog = (item: StoreItem) => {
const archiveItem = async (item: StoreItem, opts = {}) => { const archiveItem = async (item: StoreItem, opts = {}) => {
try { try {
const req = await request(`/api/archive/${item._id}`, { const req = await request(`/api/history/${item._id}/archive`, {
credentials: 'include', credentials: 'include',
method: 'POST', method: 'POST',
}) })
@ -871,10 +871,7 @@ const removeFromArchiveDialog = (item: StoreItem) => {
const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => { const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => {
console.log('Removing from archive:', item, opts) console.log('Removing from archive:', item, opts)
try { try {
const req = await request(`/api/archive/${item._id}`, { const req = await request(`/api/history/${item._id}/archive`, { credentials: 'include', method: 'DELETE' })
credentials: 'include',
method: 'DELETE',
})
const data = await req.json() const data = await req.json()
if (!req.ok) { if (!req.ok) {
toast.error(data.error) toast.error(data.error)

View file

@ -167,16 +167,11 @@
</NuxtLink> </NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset)"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', form.url, form.preset, form.cli)">
<span class="icon has-text-info"><i class="fa-solid fa-info" /></span> <span class="icon has-text-info"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
<NuxtLink class="dropdown-item" @click="removeFromArchive(form.url)">
<span class="icon has-text-warning"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</NuxtLink>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="resetConfig"> <NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span> <span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -194,7 +189,8 @@
</button> </button>
</div> </div>
<div class="control"> <div class="control">
<button type="button" class="button is-info" @click="emitter('getInfo', form.url, form.preset)" <button type="button" class="button is-info"
@click="emitter('getInfo', form.url, form.preset, form.cli)"
:class="{ 'is-loading': !socket.isConnected }" :class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url"> :disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
@ -202,15 +198,6 @@
</button> </button>
</div> </div>
<div class="control">
<button type="button" class="button is-warning" @click="removeFromArchive(form.url)"
:class="{ 'is-loading': !socket.isConnected }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-box-archive" /></span>
<span>Remove from archive</span>
</button>
</div>
<div class="control"> <div class="control">
<button type="button" class="button is-danger" @click="resetConfig" <button type="button" class="button is-danger" @click="resetConfig"
:disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'"> :disabled="!!(!socket.isConnected || form?.id)" v-tooltip="'Reset local settings'">
@ -248,7 +235,7 @@ import type { AutoCompleteOptions } from '~/types/autocomplete';
const props = defineProps<{ item?: Partial<item_request> }>() const props = defineProps<{ item?: Partial<item_request> }>()
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string | undefined): void (e: 'getInfo', url: string, preset: string | undefined, cli: string | undefined): void
(e: 'clear_form'): void (e: 'clear_form'): void
(e: 'remove_archive', url: string): void (e: 'remove_archive', url: string): void
}>() }>()
@ -497,27 +484,6 @@ const filter_presets = (flag: boolean = true) => config.presets.filter(item => i
const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name) const get_preset = (name: string | undefined) => config.presets.find(item => item.name === name)
const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap']) const expand_description = (e: Event) => toggleClass(e.target as HTMLElement, ['is-ellipsis', 'is-pre-wrap'])
const removeFromArchive = async (url: string) => {
try {
const req = await request(`/api/archive/0`, {
credentials: 'include',
method: 'DELETE',
body: JSON.stringify({ url }),
})
const data = await req.json()
if (!req.ok) {
toast.error(data.error)
return
}
toast.success(data.message ?? `Removed item from archive.`)
} catch (e: any) {
toast.error(`Error: ${e.message}`)
}
}
const get_output_template = () => { const get_output_template = () => {
if (form.value.preset && !hasFormatInConfig.value) { if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset) const preset = config.presets.find(p => p.name === form.value.preset)

View file

@ -150,7 +150,7 @@
<template v-if="!config.app.basic_mode"> <template v-if="!config.app.basic_mode">
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)"> <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
</NuxtLink> </NuxtLink>
@ -275,7 +275,7 @@
<hr class="dropdown-divider" v-if="!config.app.basic_mode" /> <hr class="dropdown-divider" v-if="!config.app.basic_mode" />
</template> </template>
<NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset)" <NuxtLink class="dropdown-item" @click="emitter('getInfo', item.url, item.preset, item.cli)"
v-if="!config.app.basic_mode"> v-if="!config.app.basic_mode">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span> <span>yt-dlp Information</span>
@ -319,7 +319,7 @@ import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store' import type { StoreItem } from '~/types/store'
const emitter = defineEmits<{ const emitter = defineEmits<{
(e: 'getInfo', url: string, preset: string): void (e: 'getInfo', url: string, preset: string, cli: string): void
(e: 'getItemInfo', id: string): void (e: 'getItemInfo', id: string): void
(e: 'clear_search'): void (e: 'clear_search'): void
}>() }>()

View file

@ -96,15 +96,16 @@
</div> </div>
<NewDownload v-if="config.showForm || config.app.basic_mode" <NewDownload v-if="config.showForm || config.app.basic_mode"
@getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :item="item_form" @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@clear_form="item_form = {}" @remove_archive="" /> :item="item_form" @clear_form="item_form = {}" @remove_archive="" />
<Queue @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" :thumbnails="show_thumbnail" <Queue @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
:query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> :thumbnails="show_thumbnail" :query="query" @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)"
<History @getInfo="(url: string, preset: string = '') => view_info(url, false, preset)" @clear_search="query = ''" />
<History @getInfo="(url: string, preset: string = '', cli: string = '') => view_info(url, false, preset, cli)"
@add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail" @add_new="(item: Partial<StoreItem>) => toNewDownload(item)" :query="query" :thumbnails="show_thumbnail"
@getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" /> @getItemInfo="(id: string) => view_info(`/api/history/${id}`, true)" @clear_search="query = ''" />
<GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :useUrl="info_view.useUrl" <GetInfo v-if="info_view.url" :link="info_view.url" :preset="info_view.preset" :cli="info_view.cli"
@closeModel="close_info()" /> :useUrl="info_view.useUrl" @closeModel="close_info()" />
<ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title" <ConfirmDialog v-if="dialog_confirm.visible" :visible="dialog_confirm.visible" :title="dialog_confirm.title"
:html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm" :html_message="dialog_confirm.html_message" :options="dialog_confirm.options" @confirm="dialog_confirm.confirm"
@cancel="() => dialog_confirm.visible = false" /> @cancel="() => dialog_confirm.visible = false" />
@ -128,8 +129,9 @@ const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const info_view = ref({ const info_view = ref({
url: '', url: '',
preset: '', preset: '',
cli: '',
useUrl: false, useUrl: false,
}) as Ref<{ url: string, preset: string, useUrl: boolean }> }) as Ref<{ url: string, preset: string, cli: string, useUrl: boolean }>
const item_form = ref<item_request | object>({}) const item_form = ref<item_request | object>({})
const query = ref() const query = ref()
const toggleFilter = ref(false) const toggleFilter = ref(false)
@ -198,10 +200,11 @@ const close_info = () => {
info_view.value.useUrl = false info_view.value.useUrl = false
} }
const view_info = (url: string, useUrl: boolean = false, preset: string = '') => { const view_info = (url: string, useUrl: boolean = false, preset: string = '', cli: string = '') => {
info_view.value.url = url info_view.value.url = url
info_view.value.useUrl = useUrl info_view.value.useUrl = useUrl
info_view.value.preset = preset info_view.value.preset = preset
info_view.value.cli = cli
} }
watch(() => info_view.value.url, v => { watch(() => info_view.value.url, v => {