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)
- [GET /api/history/{id}](#get-apihistoryid)
- [GET /api/history](#get-apihistory)
- [DELETE /api/archive/{id}](#delete-apiarchiveid)
- [POST /api/archive/{id}](#post-apiarchiveid)
- [DELETE /api/history/{id}/archive](#delete-apihistoryidarchive)
- [POST /api/history/{id}/archive](#post-apihistoryidarchive)
- [GET /api/tasks](#get-apitasks)
- [PUT /api/tasks](#put-apitasks)
- [POST /api/tasks/{id}/mark](#post-apitasksidmark)
@ -361,17 +361,11 @@ or an error:
---
### DELETE /api/archive/{id}
**Purpose**: Remove an item's URL from the yt-dlp archive file, allowing it to be re-downloaded.
### DELETE /api/history/{id}/archive
**Purpose**: Remove an item from archive file, allowing it to be re-downloaded.
**Path Parameter**:
- `id`: Item ID from the history (if body `url` is not provided).
**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.
- `id`: Item ID from the history.
**Response**:
```json
@ -387,8 +381,8 @@ or an error:
---
### POST /api/archive/{id}
**Purpose**: Manually mark an item as archived by writing its archive ID to the archive file.
### POST /api/history/{id}/archive
**Purpose**: Add item to the archive file preventing it from being downloaded.
**Path Parameter**:
- `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 app.library.config import Config
from app.library.Download import Download
from app.library.DownloadQueue import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.ItemDTO import Item
from app.library.Presets import Preset, Presets
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:
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")})
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<{
link?: string
preset?: string
cli?: string
useUrl?: boolean
externalModel?: boolean
}>()
@ -71,6 +72,9 @@ onMounted(async (): Promise<void> => {
if (props.preset) {
params.append('preset', props.preset)
}
if (props.cli) {
params.append('args', props.cli)
}
params.append('url', props.link || '')
url += '?' + params.toString()
}

View file

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

View file

@ -167,16 +167,11 @@
</NuxtLink>
<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>yt-dlp Information</span>
</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" />
<NuxtLink class="dropdown-item" @click="resetConfig">
<span class="icon has-text-danger"><i class="fa-solid fa-rotate-left" /></span>
@ -194,7 +189,8 @@
</button>
</div>
<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 }"
:disabled="!socket.isConnected || addInProgress || !form?.url">
<span class="icon"><i class="fa-solid fa-info" /></span>
@ -202,15 +198,6 @@
</button>
</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">
<button type="button" class="button is-danger" @click="resetConfig"
: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 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: '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 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 = () => {
if (form.value.preset && !hasFormatInConfig.value) {
const preset = config.presets.find(p => p.name === form.value.preset)

View file

@ -150,7 +150,7 @@
<template v-if="!config.app.basic_mode">
<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>yt-dlp Information</span>
</NuxtLink>
@ -275,7 +275,7 @@
<hr class="dropdown-divider" v-if="!config.app.basic_mode" />
</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">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>yt-dlp Information</span>
@ -319,7 +319,7 @@ import { useStorage } from '@vueuse/core'
import type { StoreItem } from '~/types/store'
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: 'clear_search'): void
}>()

View file

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