[media] Support for generic media downloads.

This commit is contained in:
Fabio Manganiello 2024-07-15 04:07:56 +02:00
parent bd01827b52
commit ef4d0bd38c
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 683 additions and 233 deletions

View file

@ -1,4 +1,5 @@
from abc import ABC
from typing import Optional
from platypush.message.event import Event
@ -133,24 +134,48 @@ class MediaDownloadEvent(MediaEvent, ABC):
"""
def __init__(
self, *args, player=None, plugin=None, resource=None, target=None, **kwargs
self,
*args,
plugin: str,
resource: str,
state: str,
path: str,
player: Optional[str] = None,
size: Optional[int] = None,
timeout: Optional[int] = None,
progress: Optional[float] = None,
started_at: Optional[float] = None,
ended_at: Optional[float] = None,
**kwargs
):
"""
:param resource: File name or URI of the downloaded resource
:type resource: str
:param target: Target file name or URI of the downloaded resource
:type target: str
:param url: Alias for resource
:param path: Path where the resource is downloaded
:param state: Download state
:param size: Size of the downloaded resource in bytes
:param timeout: Download timeout in seconds
:param progress: Download progress in percentage, between 0 and 100
:param started_at: Download start time
:param ended_at: Download end time
"""
super().__init__(
*args,
player=player,
plugin=plugin,
resource=resource,
target=target,
**kwargs
kwargs.update(
{
"resource": resource,
"path": path,
"url": resource,
"state": state,
"size": size,
"timeout": timeout,
"progress": progress,
"started_at": started_at,
"ended_at": ended_at,
}
)
super().__init__(*args, player=player, plugin=plugin, **kwargs)
class MediaDownloadStartedEvent(MediaDownloadEvent):
"""
@ -163,12 +188,6 @@ class MediaDownloadProgressEvent(MediaDownloadEvent):
Event triggered when a media download is in progress.
"""
def __init__(self, progress: float, *args, **kwargs):
"""
:param progress: Download progress in percentage, between 0 and 100.
"""
super().__init__(*args, progress=progress, **kwargs)
class MediaDownloadCompletedEvent(MediaDownloadEvent):
"""
@ -188,4 +207,28 @@ class MediaDownloadErrorEvent(MediaDownloadEvent):
super().__init__(*args, error=error, **kwargs)
class MediaDownloadPausedEvent(MediaDownloadEvent):
"""
Event triggered when a media download is paused.
"""
class MediaDownloadResumedEvent(MediaDownloadEvent):
"""
Event triggered when a media download is resumed.
"""
class MediaDownloadCancelledEvent(MediaDownloadEvent):
"""
Event triggered when a media download is cancelled.
"""
class MediaDownloadClearEvent(MediaDownloadEvent):
"""
Event triggered when a download is cleared from the queue.
"""
# vim:sw=4:ts=4:et:

View file

@ -1,6 +1,3 @@
from contextlib import suppress
from dataclasses import dataclass
import enum
import functools
import inspect
import json
@ -10,12 +7,9 @@ import re
import subprocess
import tempfile
import threading
import time
from abc import ABC, abstractmethod
from typing import (
Callable,
Dict,
IO,
Iterable,
List,
Optional,
@ -28,46 +22,18 @@ import requests
from platypush.config import Config
from platypush.context import get_plugin, get_backend
from platypush.message.event.media import (
MediaDownloadCompletedEvent,
MediaDownloadErrorEvent,
MediaDownloadEvent,
MediaDownloadProgressEvent,
MediaDownloadStartedEvent,
MediaEvent,
)
from platypush.message.event.media import MediaEvent
from platypush.plugins import RunnablePlugin, action
from platypush.utils import get_default_downloads_dir, get_plugin_name_by_class
class PlayerState(enum.Enum):
"""
Models the possible states of a media player
"""
STOP = 'stop'
PLAY = 'play'
PAUSE = 'pause'
IDLE = 'idle'
@dataclass
class MediaResource:
"""
Models a media resource
"""
resource: str
url: str
title: Optional[str] = None
description: Optional[str] = None
filename: Optional[str] = None
image: Optional[str] = None
duration: Optional[float] = None
channel: Optional[str] = None
channel_url: Optional[str] = None
type: Optional[str] = None
resolution: Optional[str] = None
from ._download import (
DownloadState,
DownloadThread,
FileDownloadThread,
YouTubeDownloadThread,
)
from ._resource import MediaResource
from ._state import PlayerState
class MediaPlugin(RunnablePlugin, ABC):
@ -233,7 +199,7 @@ class MediaPlugin(RunnablePlugin, ABC):
media_dirs = []
player = None
player_config = {}
self._download_threads: Dict[Tuple[str, str], threading.Thread] = {}
self._download_threads: Dict[Tuple[str, str], DownloadThread] = {}
if self.__class__.__name__ == 'MediaPlugin':
# Abstract class, initialize with the default configured player
@ -357,6 +323,7 @@ class MediaPlugin(RunnablePlugin, ABC):
)
elif self._is_youtube_resource(resource):
info = self._get_youtube_info(resource)
if info:
url = info.get('url')
if url:
resource = url
@ -420,7 +387,7 @@ class MediaPlugin(RunnablePlugin, ABC):
@action
@abstractmethod
def stop(self, *args, **kwargs):
def stop(self, *args, **kwargs): # type: ignore
super().stop()
@action
@ -737,15 +704,6 @@ class MediaPlugin(RunnablePlugin, ABC):
return None
@action
def get_youtube_url(self, url, youtube_format: Optional[str] = None):
youtube_id = self.get_youtube_id(url)
if youtube_id:
url = f'https://www.youtube.com/watch?v={youtube_id}'
return self._get_youtube_info(url, youtube_format=youtube_format).get('url')
return None
@action
def get_youtube_info(self, url):
# Legacy conversion for Mopidy YouTube URIs
@ -806,6 +764,7 @@ class MediaPlugin(RunnablePlugin, ABC):
filename: Optional[str] = None,
directory: Optional[str] = None,
timeout: int = 10,
sync: bool = False,
youtube_format: Optional[str] = None,
):
"""
@ -820,14 +779,17 @@ class MediaPlugin(RunnablePlugin, ABC):
- :class:`platypush.message.event.media.MediaDownloadStartedEvent`
- :class:`platypush.message.event.media.MediaDownloadProgressEvent`
- :class:`platypush.message.event.media.MediaDownloadErrorEvent`
- :class:`platypush.message.event.media.MediaDownloadFinishedEvent`
- :class:`platypush.message.event.media.MediaDownloadPausedEvent`
- :class:`platypush.message.event.media.MediaDownloadResumedEvent`
- :class:`platypush.message.event.media.MediaDownloadCancelledEvent`
- :class:`platypush.message.event.media.MediaDownloadCompletedEvent`
:param url: Media URL.
:param filename: Media filename (default: inferred from the URL basename).
:param directory: Destination directory (default: ``download_dir``).
:param timeout: Network timeout in seconds (default: 10).
:param youtube: Set to True if the URL is a YouTube video, or any other
URL compatible with yt-dlp.
:param sync: If set to True, the download will be synchronous and the
action will return only when the download is completed.
:param youtube_format: Override the default YouTube format selection.
:return: The absolute path to the downloaded file.
"""
@ -836,14 +798,124 @@ class MediaPlugin(RunnablePlugin, ABC):
)
if self._is_youtube_resource(url):
self._download_youtube_url(
url, path, timeout=timeout, youtube_format=youtube_format
dl_thread = self._download_youtube_url(
url, path, youtube_format=youtube_format
)
else:
self._download_url(url, path, timeout=timeout)
dl_thread = self._download_url(url, path, timeout=timeout)
if sync:
dl_thread.join()
return path
@action
def pause_download(self, url: Optional[str] = None, path: Optional[str] = None):
"""
Pause a download in progress.
Either the URL or the path must be specified.
:param url: URL of the download.
:param path: Path of the download (default: any path associated with the URL).
"""
for thread in self._get_downloads(url=url, path=path):
thread.pause()
@action
def resume_download(self, url: Optional[str] = None, path: Optional[str] = None):
"""
Resume a paused download.
Either the URL or the path must be specified.
:param url: URL of the download.
:param path: Path of the download (default: any path associated with the URL).
"""
for thread in self._get_downloads(url=url, path=path):
thread.resume()
@action
def cancel_download(self, url: Optional[str] = None, path: Optional[str] = None):
"""
Cancel a download in progress.
Either the URL or the path must be specified.
:param url: URL of the download.
:param path: Path of the download (default: any path associated with the URL).
"""
for thread in self._get_downloads(url=url, path=path):
thread.stop()
@action
def clear_downloads(self, url: Optional[str] = None, path: Optional[str] = None):
"""
Clear completed/cancelled downloads from the queue.
:param url: URL of the download (default: all downloads).
:param path: Path of the download (default: any path associated with the URL).
"""
threads = (
self._get_downloads(url=url, path=path)
if url
else list(self._download_threads.values())
)
for thread in threads:
if thread.state not in (DownloadState.COMPLETED, DownloadState.CANCELLED):
continue
dl = self._download_threads.pop((thread.url, thread.path), None)
if dl:
dl.clear()
@action
def get_downloads(self, url: Optional[str] = None, path: Optional[str] = None):
"""
Get the download threads.
:param url: URL of the download (default: all downloads).
:param path: Path of the download (default: any path associated with the URL).
:return: .. schema:: media.download.MediaDownloadSchema(many=True)
"""
from platypush.schemas.media.download import MediaDownloadSchema
return MediaDownloadSchema().dump(
(
self._get_downloads(url=url, path=path)
if url
else list(self._download_threads.values())
),
many=True,
)
def _get_downloads(self, url: Optional[str] = None, path: Optional[str] = None):
assert url or path, 'URL or path must be specified'
threads = []
if url and path:
path = os.path.expanduser(path)
thread = self._download_threads.get((url, path))
if thread:
threads = [thread]
elif url:
threads = [
thread
for (url_, _), thread in self._download_threads.items()
if url_ == url
]
elif path:
path = os.path.expanduser(path)
threads = [
thread
for (_, path_), thread in self._download_threads.items()
if path_ == path
]
assert threads, f'No matching downloads found for [url={url}, path={path}]'
return threads
def _get_download_path(
self,
url: str,
@ -883,142 +955,46 @@ class MediaPlugin(RunnablePlugin, ABC):
return os.path.join(directory, filename)
def _download_url(self, url: str, path: str, timeout: int):
r = requests.get(url, timeout=timeout, stream=True)
r.raise_for_status()
download_thread = threading.Thread(
target=self._download_url_thread,
args=(r, open(path, 'wb')), # pylint: disable=consider-using-with
def _download_url(self, url: str, path: str, timeout: int) -> FileDownloadThread:
download_thread = FileDownloadThread(
url=url,
path=path,
timeout=timeout,
on_start=self._on_download_start,
post_event=self._post_event,
stop_event=self._should_stop,
)
download_thread.start()
self._download_threads[url, path] = download_thread
self._start_download(download_thread)
return download_thread
def _download_youtube_url(
self, url: str, path: str, timeout: int, youtube_format: Optional[str] = None
):
ytdl_cmd = [
self._ytdl,
*(
['-f', youtube_format or self.youtube_format]
if youtube_format or self.youtube_format
else []
),
url,
'-o',
'-',
]
self.logger.info('Executing command %r', ytdl_cmd)
download_thread = threading.Thread(
target=self._download_youtube_url_thread,
args=(
subprocess.Popen( # pylint: disable=consider-using-with
ytdl_cmd, stdout=subprocess.PIPE
),
open(path, 'wb'), # pylint: disable=consider-using-with
url,
),
kwargs={'timeout': timeout},
)
download_thread.start()
self._download_threads[url, path] = download_thread
def _download_url_thread(self, response: requests.Response, f: IO):
def on_close():
with suppress(IOError, OSError, requests.exceptions.RequestException):
response.close()
size = int(response.headers.get('Content-Length', 0)) or None
self._download_thread_wrapper(
iterator=lambda: response.iter_content(chunk_size=8192),
f=f,
url=response.url,
size=size,
on_close=on_close,
)
def _download_youtube_url_thread(
self, proc: subprocess.Popen, f: IO, url: str, timeout: int
):
def read():
if not proc.stdout:
return b''
return proc.stdout.read(8192)
def on_close():
with suppress(IOError, OSError):
proc.terminate()
proc.wait(timeout=5)
if proc.returncode is None:
proc.kill()
proc_start = time.time()
while not proc.stdout:
if time.time() - proc_start > timeout:
self.logger.warning('yt-dlp process timed out')
on_close()
return
self.wait_stop(1)
self._download_thread_wrapper(
iterator=lambda: iter(read, b''),
f=f,
self, url: str, path: str, youtube_format: Optional[str] = None
) -> YouTubeDownloadThread:
download_thread = YouTubeDownloadThread(
url=url,
size=None,
on_close=on_close,
path=path,
ytdl=self._ytdl,
youtube_format=youtube_format or self.youtube_format,
on_start=self._on_download_start,
post_event=self._post_event,
stop_event=self._should_stop,
)
def _download_thread_wrapper(
self,
iterator: Callable[[], Iterable[bytes]],
f: IO,
url: str,
size: Optional[int],
on_close: Callable[[], None] = lambda: None,
):
def post_event(event_type: Type[MediaDownloadEvent], **kwargs):
self._post_event(event_type, resource=url, path=f.name, **kwargs)
self._start_download(download_thread)
return download_thread
if (url, f.name) in self._download_threads:
def _on_download_start(self, thread: DownloadThread):
self._download_threads[thread.url, thread.path] = thread
def _start_download(self, thread: DownloadThread):
if (thread.url, thread.path) in self._download_threads:
self.logger.warning(
'A download of %s to %s is already in progress', url, f.name
'A download of %s to %s is already in progress', thread.url, thread.path
)
return
interrupted = False
try:
self._post_event(MediaDownloadStartedEvent, resource=url, path=f.name)
last_percent = 0
for chunk in iterator():
if not chunk or self.should_stop():
interrupted = self.should_stop()
break
f.write(chunk)
percent = f.tell() / size * 100 if size else 0
if percent and percent - last_percent > 1:
post_event(MediaDownloadProgressEvent, progress=percent)
last_percent = percent
if not interrupted:
post_event(MediaDownloadCompletedEvent)
except Exception as e:
self.logger.warning('Error while downloading URL: %s', e)
post_event(MediaDownloadErrorEvent, error=str(e))
finally:
on_close()
with suppress(IOError, OSError):
f.close()
self._download_threads.pop((url, f.name), None)
thread.start()
def _post_event(self, event_type: Type[MediaEvent], **kwargs):
evt = event_type(
@ -1052,4 +1028,11 @@ class MediaPlugin(RunnablePlugin, ABC):
self.wait_stop()
__all__ = [
'DownloadState',
'MediaPlugin',
'PlayerState',
]
# vim:sw=4:ts=4:et:

View file

@ -0,0 +1,341 @@
from abc import ABC, abstractmethod
from contextlib import suppress
from enum import Enum
import json
import logging
import signal
import subprocess
import threading
import time
from typing import Any, Callable, Optional, Type
import requests
from platypush.message.event.media import (
MediaDownloadCancelledEvent,
MediaDownloadClearEvent,
MediaDownloadCompletedEvent,
MediaDownloadErrorEvent,
MediaDownloadEvent,
MediaDownloadPausedEvent,
MediaDownloadProgressEvent,
MediaDownloadResumedEvent,
MediaDownloadStartedEvent,
)
from platypush.utils import wait_for_either
class DownloadState(Enum):
"""
Enum that represents the status of a download.
"""
IDLE = 'idle'
STARTED = 'started'
DOWNLOADING = 'downloading'
PAUSED = 'paused'
COMPLETED = 'completed'
CANCELLED = 'cancelled'
ERROR = 'error'
class DownloadThread(threading.Thread, ABC):
"""
Thread that downloads a URL to a file.
"""
_progress_update_interval = 1
""" Throttle the progress updates to this interval, in seconds. """
def __init__(
self,
path: str,
url: str,
post_event: Callable,
size: Optional[int] = None,
timeout: Optional[int] = 10,
on_start: Callable[['DownloadThread'], None] = lambda _: None,
on_close: Callable[['DownloadThread'], None] = lambda _: None,
stop_event: Optional[threading.Event] = None,
):
super().__init__(name=f'DownloadThread-{path}')
self.path = path
self.url = url
self.size = size
self.timeout = timeout
self.state = DownloadState.IDLE
self.progress = None
self.started_at = None
self.ended_at = None
self._upstream_stop_event = stop_event or threading.Event()
self._stop_event = threading.Event()
self._post_event = post_event
self._on_start = on_start
self._on_close = on_close
self._paused = threading.Event()
self._downloading = threading.Event()
self._last_progress_update_time = 0
self.logger = logging.getLogger(__name__)
def should_stop(self) -> bool:
return self._stop_event.is_set() or self._upstream_stop_event.is_set()
@abstractmethod
def _run(self) -> bool:
pass
def pause(self):
self.state = DownloadState.PAUSED
self._paused.set()
self._downloading.clear()
self.post_event(MediaDownloadPausedEvent)
def resume(self):
self.state = DownloadState.DOWNLOADING
self._paused.clear()
self._downloading.set()
self.post_event(MediaDownloadResumedEvent)
def run(self):
super().run()
interrupted = False
try:
self.on_start()
interrupted = not self._run()
if interrupted:
self.state = DownloadState.CANCELLED
else:
self.state = DownloadState.COMPLETED
except Exception as e:
self.state = DownloadState.ERROR
self.post_event(MediaDownloadErrorEvent, error=str(e))
self.logger.warning('Error while downloading URL: %s', e)
finally:
self.on_close()
def stop(self):
self.state = DownloadState.CANCELLED
self._stop_event.set()
self._downloading.clear()
def on_start(self):
self.state = DownloadState.STARTED
self.started_at = time.time()
self.post_event(MediaDownloadStartedEvent)
self._on_start(self)
def on_close(self):
self.ended_at = time.time()
if self.state == DownloadState.CANCELLED:
self.post_event(MediaDownloadCancelledEvent)
elif self.state == DownloadState.COMPLETED:
self.post_event(MediaDownloadCompletedEvent)
self._on_close(self)
def clear(self):
if self.state not in (DownloadState.COMPLETED, DownloadState.CANCELLED):
self.logger.info(
'Download thread for %s is still active, stopping', self.url
)
self.stop()
self.join(timeout=10)
self.post_event(MediaDownloadClearEvent)
def post_event(self, event_type: Type[MediaDownloadEvent], **kwargs):
kwargs = {
'resource': self.url,
'path': self.path,
'state': self.state.value,
'size': self.size,
'timeout': self.timeout,
'progress': self.progress,
'started_at': self.started_at,
'ended_at': self.ended_at,
**kwargs,
}
self._post_event(event_type, **kwargs)
def __setattr__(self, name: str, value: Optional[Any], /) -> None:
if name == 'progress' and value is not None:
if value < 0 or value > 100:
self.logger.debug('Invalid progress value:%s', value)
return
prev_progress = getattr(self, 'progress', None)
if prev_progress is None or (
int(prev_progress) != int(value)
and (
time.time() - self._last_progress_update_time
>= self._progress_update_interval
)
):
value = round(value, 2)
self._last_progress_update_time = time.time()
self.post_event(MediaDownloadProgressEvent, progress=value)
super().__setattr__(name, value)
class FileDownloadThread(DownloadThread):
"""
Thread that downloads a generic URL to a file.
"""
def _run(self):
interrupted = False
with requests.get(self.url, timeout=self.timeout, stream=True) as response:
response.raise_for_status()
self.size = int(response.headers.get('Content-Length', 0)) or None
with open(self.path, 'wb') as f:
self.on_start()
for chunk in response.iter_content(chunk_size=8192):
if not chunk or self.should_stop():
interrupted = self.should_stop()
if interrupted:
self.stop()
break
self.state = DownloadState.DOWNLOADING
f.write(chunk)
percent = f.tell() / self.size * 100 if self.size else 0
self.progress = percent
if self._paused.is_set():
wait_for_either(self._downloading, self._stop_event)
return not interrupted
class YouTubeDownloadThread(DownloadThread):
"""
Thread that downloads a YouTube URL to a file.
"""
def __init__(
self, *args, ytdl: str, youtube_format: Optional[str] = None, **kwargs
):
super().__init__(*args, **kwargs)
self._ytdl = ytdl
self._youtube_format = youtube_format
self._proc = None
self._proc_lock = threading.Lock()
def _parse_progress(self, line: str):
try:
progress = json.loads(line)
except json.JSONDecodeError:
return
status = progress.get('status')
if not status:
return
if status == 'finished':
self.progress = 100
return
if status == 'paused':
self.state = DownloadState.PAUSED
elif status == 'downloading':
self.state = DownloadState.DOWNLOADING
self.size = int(progress.get('total_bytes_estimate', 0)) or self.size
if self.size:
downloaded = int(progress.get('downloaded_bytes', 0))
self.progress = (downloaded / self.size) * 100
def _run(self):
ytdl_cmd = [
self._ytdl,
'--newline',
'--progress',
'--progress-delta',
str(self._progress_update_interval),
'--progress-template',
'%(progress)j',
*(['-f', self._youtube_format] if self._youtube_format else []),
self.url,
'-o',
self.path,
]
self.logger.info('Executing command %r', ytdl_cmd)
err = None
with subprocess.Popen(ytdl_cmd, stdout=subprocess.PIPE) as self._proc:
if self._proc.stdout:
for line in self._proc.stdout:
self.logger.debug(
'%s output: %s', self._ytdl, line.decode().strip()
)
self._parse_progress(line.decode())
if self.should_stop():
self.stop()
return self._proc.returncode == 0
if self._paused.is_set():
wait_for_either(self._downloading, self._stop_event)
if self._proc.returncode != 0:
err = self._proc.stderr.read().decode() if self._proc.stderr else None
raise RuntimeError(
f'{self._ytdl} failed with return code {self._proc.returncode}: {err}'
)
return True
def pause(self):
with self._proc_lock:
if self._proc:
self._proc.send_signal(signal.SIGSTOP)
super().pause()
def resume(self):
with self._proc_lock:
if self._proc:
self._proc.send_signal(signal.SIGCONT)
super().resume()
def stop(self):
state = None
with suppress(IOError, OSError), self._proc_lock:
if self._proc:
if self._proc.poll() is None:
self._proc.terminate()
self._proc.wait(timeout=3)
if self._proc.returncode is None:
self._proc.kill()
state = DownloadState.CANCELLED
elif self._proc.returncode != 0:
state = DownloadState.ERROR
else:
state = DownloadState.COMPLETED
self._proc = None
super().stop()
if state:
self.state = state
def on_close(self):
self.stop()
super().on_close()

View file

@ -0,0 +1,21 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class MediaResource:
"""
Models a media resource
"""
resource: str
url: str
title: Optional[str] = None
description: Optional[str] = None
filename: Optional[str] = None
image: Optional[str] = None
duration: Optional[float] = None
channel: Optional[str] = None
channel_url: Optional[str] = None
type: Optional[str] = None
resolution: Optional[str] = None

View file

@ -0,0 +1,12 @@
import enum
class PlayerState(enum.Enum):
"""
Models the possible states of a media player
"""
STOP = 'stop'
PLAY = 'play'
PAUSE = 'pause'
IDLE = 'idle'

View file

@ -179,21 +179,6 @@ class MediaKodiPlugin(MediaPlugin):
:param resource: URL or path to the media to be played
"""
youtube_id = self.get_youtube_id(resource)
if youtube_id:
try:
resource = self.get_youtube_url(youtube_id).output
except Exception as e:
self.logger.warning(
'youtube-dl error, falling back to Kodi YouTube plugin: {}'.format(
str(e)
)
)
resource = (
'plugin://plugin.video.youtube/?action=play_video&videoid='
+ youtube_id
)
if resource.startswith('file://'):
resource = resource[7:]
@ -585,7 +570,7 @@ class MediaKodiPlugin(MediaPlugin):
:type position: float
:param player_id: ID of the target player (default: configured/current player).
"""
return self.seek(position=position, player_id=player_id, *args, **kwargs)
return self.seek(*args, position=position, player_id=player_id, **kwargs)
@action
def back(self, offset=30, player_id=None, *args, **kwargs):

View file

@ -0,0 +1,65 @@
from marshmallow import fields
from marshmallow.schema import Schema
from platypush.plugins.media import DownloadState
from platypush.schemas import DateTime
class MediaDownloadSchema(Schema):
"""
Media download schema.
"""
url = fields.URL(
required=True,
metadata={
"description": "Download URL",
"example": "https://example.com/video.mp4",
},
)
path = fields.String(
required=True,
metadata={
"description": "Download path",
"example": "/path/to/download/video.mp4",
},
)
state = fields.Enum(
DownloadState,
required=True,
metadata={
"description": "Download state",
},
)
size = fields.Integer(
nullable=True,
metadata={
"description": "Download size (bytes)",
"example": 1024,
},
)
timeout = fields.Integer(
nullable=True,
metadata={
"description": "Download timeout (seconds)",
"example": 60,
},
)
started_at = DateTime(
nullable=True,
metadata={
"description": "Download start time",
},
)
ended_at = DateTime(
nullable=True,
metadata={
"description": "Download end time",
},
)