Refactor: make classes more modular

This commit is contained in:
arabcoders 2025-12-19 19:51:26 +03:00
parent 71cdcc220f
commit 5a5f16c9f8
13 changed files with 81 additions and 56 deletions

View file

@ -6,6 +6,7 @@ from queue import Empty, Queue
from aiohttp import web from aiohttp import web
from .Services import Services
from .Singleton import Singleton from .Singleton import Singleton
LOG: logging.Logger = logging.getLogger("BackgroundWorker") LOG: logging.Logger = logging.getLogger("BackgroundWorker")
@ -35,6 +36,7 @@ class BackgroundWorker(metaclass=Singleton):
return BackgroundWorker() return BackgroundWorker()
def attach(self, app: web.Application): def attach(self, app: web.Application):
Services.get_instance().add("background_worker", self)
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
LOG.debug("Starting background worker...") LOG.debug("Starting background worker...")

View file

@ -154,18 +154,25 @@ class DataStore:
async def get_items_paginated( async def get_items_paginated(
self, page: int = 1, per_page: int = 50, order: str = "DESC", status_filter: str | None = None self, page: int = 1, per_page: int = 50, order: str = "DESC", status_filter: str | None = None
): ) -> tuple[list[tuple[str, Download]], int, int, int]:
if page < 1: if page < 1:
msg = "page must be >= 1" msg = "page must be >= 1"
raise ValueError(msg) raise ValueError(msg)
if per_page < 1: if per_page < 1:
msg = "per_page must be >= 1" msg = "per_page must be >= 1"
raise ValueError(msg) raise ValueError(msg)
order = order.upper() order = order.upper()
if order not in ("ASC", "DESC"): if order not in ("ASC", "DESC"):
msg = f"order must be 'ASC' or 'DESC', got '{order}'" msg = f"order must be 'ASC' or 'DESC', got '{order}'"
raise ValueError(msg) raise ValueError(msg)
return await self._connection.paginate(str(self._type), page, per_page, order, status_filter)
items, total_items, current_page, total_pages = await self._connection.paginate(
str(self._type), page, per_page, order, status_filter
)
return [(item_id, Download(info=item)) for item_id, item in items], total_items, current_page, total_pages
async def bulk_delete(self, ids: Iterable[str]) -> int: async def bulk_delete(self, ids: Iterable[str]) -> int:
deleted = await self._connection.bulk_delete(str(self._type), ids) deleted = await self._connection.bulk_delete(str(self._type), ids)

View file

@ -23,6 +23,7 @@ from .Events import EventBus, Events
from .ItemDTO import Item, ItemDTO from .ItemDTO import Item, ItemDTO
from .Presets import Presets from .Presets import Presets
from .Scheduler import Scheduler from .Scheduler import Scheduler
from .Services import Services
from .Singleton import Singleton from .Singleton import Singleton
from .sqlite_store import SqliteStore from .sqlite_store import SqliteStore
from .Utils import ( from .Utils import (
@ -54,9 +55,9 @@ class DownloadQueue(metaclass=Singleton):
"Configuration instance." "Configuration instance."
self._notify: EventBus = EventBus.get_instance() self._notify: EventBus = EventBus.get_instance()
"Event bus instance." "Event bus instance."
self.done = DataStore(type=StoreType.HISTORY, connection=SqliteStore.get_instance(config.db_file)) self.done = DataStore(type=StoreType.HISTORY, connection=SqliteStore.get_instance())
"DataStore for the completed downloads." "DataStore for the completed downloads."
self.queue = DataStore(type=StoreType.QUEUE, connection=SqliteStore.get_instance(config.db_file)) self.queue = DataStore(type=StoreType.QUEUE, connection=SqliteStore.get_instance())
"DataStore for the download queue." "DataStore for the download queue."
self.workers = asyncio.Semaphore(self.config.max_workers) self.workers = asyncio.Semaphore(self.config.max_workers)
"Semaphore to limit the number of concurrent downloads." "Semaphore to limit the number of concurrent downloads."
@ -74,7 +75,7 @@ class DownloadQueue(metaclass=Singleton):
self.paused.set() self.paused.set()
@staticmethod @staticmethod
def get_instance() -> "DownloadQueue": def get_instance(config: Config | None = None) -> "DownloadQueue":
""" """
Get the instance of the DownloadQueue. Get the instance of the DownloadQueue.
@ -82,7 +83,7 @@ class DownloadQueue(metaclass=Singleton):
DownloadQueue: The instance of the DownloadQueue DownloadQueue: The instance of the DownloadQueue
""" """
return DownloadQueue() return DownloadQueue(config=config)
def _get_limit(self, extractor: str) -> asyncio.Semaphore: def _get_limit(self, extractor: str) -> asyncio.Semaphore:
""" """
@ -121,6 +122,7 @@ class DownloadQueue(metaclass=Singleton):
_ (web.Application): The application to attach the download queue to. _ (web.Application): The application to attach the download queue to.
""" """
Services.get_instance().add("queue", self)
async def event_handler(_, __): async def event_handler(_, __):
await self.initialize() await self.initialize()
@ -1021,7 +1023,6 @@ class DownloadQueue(metaclass=Singleton):
if mode in ("all", "done"): if mode in ("all", "done"):
for k, v in await self.done.saved_items(): for k, v in await self.done.saved_items():
v.get_file_sidecar()
items["done"][k] = v items["done"][k] = v
if mode in ("all", "queue"): if mode in ("all", "queue"):
@ -1034,7 +1035,6 @@ class DownloadQueue(metaclass=Singleton):
if k in items["done"]: if k in items["done"]:
continue continue
v.info.get_file_sidecar()
items["done"][k] = v.info items["done"][k] = v.info
return items return items

View file

@ -25,6 +25,7 @@ class Events:
CONNECTED: str = "connected" CONNECTED: str = "connected"
CONFIGURATION: str = "configuration" CONFIGURATION: str = "configuration"
ACTIVE_QUEUE: str = "active_queue"
LOG_INFO: str = "log_info" LOG_INFO: str = "log_info"
LOG_WARNING: str = "log_warning" LOG_WARNING: str = "log_warning"
@ -94,6 +95,7 @@ class Events:
return [ return [
Events.CONFIGURATION, Events.CONFIGURATION,
Events.CONNECTED, Events.CONNECTED,
Events.ACTIVE_QUEUE,
Events.LOG_INFO, Events.LOG_INFO,
Events.LOG_WARNING, Events.LOG_WARNING,
Events.LOG_ERROR, Events.LOG_ERROR,

View file

@ -14,7 +14,6 @@ from app.library.Services import Services
from .cache import Cache from .cache import Cache
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import EventBus from .Events import EventBus
from .router import RouteType, get_routes from .router import RouteType, get_routes
@ -24,8 +23,7 @@ LOG: logging.Logger = logging.getLogger("http_api")
class HttpAPI: class HttpAPI:
def __init__(self, root_path: Path, queue: DownloadQueue): def __init__(self, root_path: Path):
self.queue: DownloadQueue = queue or DownloadQueue.get_instance()
self.encoder: Encoder = Encoder() self.encoder: Encoder = Encoder()
self.config: Config = Config.get_instance() self.config: Config = Config.get_instance()
self._notify: EventBus = EventBus.get_instance() self._notify: EventBus = EventBus.get_instance()
@ -33,12 +31,11 @@ class HttpAPI:
self.cache = Cache() self.cache = Cache()
self.app: web.Application | None = None self.app: web.Application | None = None
services = Services.get_instance() services: Services = Services.get_instance()
services.add_all( services.add_all(
{ {
k: v k: v
for k, v in { for k, v in {
"queue": self.queue,
"encoder": self.encoder, "encoder": self.encoder,
"config": self.config, "config": self.config,
"notify": self._notify, "notify": self._notify,

View file

@ -11,7 +11,6 @@ from app.library.Services import Services
from app.library.Utils import load_modules from app.library.Utils import load_modules
from .config import Config from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import Event, EventBus, Events from .Events import Event, EventBus, Events
from .ItemDTO import Item from .ItemDTO import Item
@ -26,19 +25,16 @@ class HttpSocket:
config: Config config: Config
sio: socketio.AsyncServer sio: socketio.AsyncServer
queue: DownloadQueue
di_context: dict[str, Any] = {} di_context: dict[str, Any] = {}
def __init__( def __init__(
self, self,
root_path: Path, root_path: Path,
queue: DownloadQueue | None = None,
encoder: Encoder | None = None, encoder: Encoder | None = None,
config: Config | None = None, config: Config | None = None,
sio: socketio.AsyncServer | None = None, sio: socketio.AsyncServer | None = None,
): ):
self.config = config or Config.get_instance() self.config = config or Config.get_instance()
self.queue = queue or DownloadQueue.get_instance()
self._notify = EventBus.get_instance() self._notify = EventBus.get_instance()
self.sio = sio or socketio.AsyncServer( self.sio = sio or socketio.AsyncServer(
@ -63,7 +59,6 @@ class HttpSocket:
k: v k: v
for k, v in { for k, v in {
"config": self.config, "config": self.config,
"queue": self.queue,
"sio": self.sio, "sio": self.sio,
"encoder": encoder, "encoder": encoder,
"notify": self._notify, "notify": self._notify,
@ -104,7 +99,7 @@ class HttpSocket:
async def event_handler(data: Event, _): async def event_handler(data: Event, _):
if data and data.data: if data and data.data:
await self.queue.add(item=Item.format(data.data)) await Services.get_instance().get("queue").add(item=Item.format(data.data))
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add") self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")

View file

@ -397,11 +397,8 @@ class ItemDTO:
dict: The serialized item. dict: The serialized item.
""" """
if "finished" == self.status: if "finished" == self.status and not self._recomputed:
if not self._recomputed: self.archive_status()
self.archive_status()
self.get_file_sidecar()
item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields()) item, _ = clean_item(self.__dict__.copy(), ItemDTO.removed_fields())
return item return item
@ -661,4 +658,3 @@ class ItemDTO:
self.get_archive_id() self.get_archive_id()
self.get_archive_file() self.get_archive_file()
self.archive_status() self.archive_status()
self.get_file_sidecar()

View file

@ -13,6 +13,7 @@ from aiohttp import web
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .operations import Operation, matches_condition from .operations import Operation, matches_condition
from .Services import Services
from .Singleton import ThreadSafe from .Singleton import ThreadSafe
from .Utils import init_class from .Utils import init_class
@ -43,25 +44,24 @@ class _Op:
class SqliteStore(metaclass=ThreadSafe): class SqliteStore(metaclass=ThreadSafe):
@staticmethod @staticmethod
def get_instance(db_path: str | None = None) -> "SqliteStore": def get_instance(db_path: str | None = None) -> "SqliteStore":
return SqliteStore(db_path) return SqliteStore(db_path=db_path)
def attach(self, app: web.Application): def attach(self, app: web.Application):
"""Get/create singleton bound to db_path.""" Services.get_instance().add("sqlite_store", self)
app.on_shutdown.append(self.on_shutdown) app.on_shutdown.append(self.on_shutdown)
async def on_shutdown(self, _: web.Application): async def on_shutdown(self, _: web.Application):
"""Close singleton on app shutdown."""
LOG.debug("Shutting down SqliteStore...") LOG.debug("Shutting down SqliteStore...")
await self.close() await self.close()
LOG.debug("SqliteStore shut down complete.") LOG.debug("SqliteStore shut down complete.")
def __init__(self, db_path: str | None = None, *, max_pending: int = 200, flush_interval: float = 0.05): def __init__(self, db_path: str, *, max_pending: int = 200, flush_interval: float = 0.05):
self._db_path = db_path self._db_path: str = db_path
self._conn: aiosqlite.Connection | None = None self._conn: aiosqlite.Connection | None = None
self._queue: asyncio.Queue[_Op] | None = None self._queue: asyncio.Queue[_Op] | None = None
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._flush_interval = flush_interval self._flush_interval: float = flush_interval
self._max_pending = max_pending self._max_pending: int = max_pending
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def __aenter__(self) -> "SqliteStore": async def __aenter__(self) -> "SqliteStore":
@ -303,7 +303,6 @@ class SqliteStore(metaclass=ThreadSafe):
return items, total_items, page, total_pages return items, total_items, page, total_pages
# direct CRUD helpers (used by DataStore)
async def upsert(self, type_value: str, item: ItemDTO) -> None: async def upsert(self, type_value: str, item: ItemDTO) -> None:
await self._ensure_conn() await self._ensure_conn()
await self._upsert_now(type_value, item) await self._upsert_now(type_value, item)
@ -441,7 +440,7 @@ class SqliteStore(metaclass=ThreadSafe):
return return
if not self._db_path: if not self._db_path:
msg = "SqliteStore requires db_path or injected connection." msg = "No database path specified for SqliteStore."
raise RuntimeError(msg) raise RuntimeError(msg)
from app.library import migrate from app.library import migrate

View file

@ -17,7 +17,6 @@ from aiohttp import web
from app.library.BackgroundWorker import BackgroundWorker from app.library.BackgroundWorker import BackgroundWorker
from app.library.conditions import Conditions from app.library.conditions import Conditions
from app.library.config import Config from app.library.config import Config
from app.library.DataStore import SqliteStore
from app.library.dl_fields import DLFields from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events from app.library.Events import EventBus, Events
@ -27,6 +26,7 @@ from app.library.Notifications import Notification
from app.library.Presets import Presets from app.library.Presets import Presets
from app.library.Scheduler import Scheduler from app.library.Scheduler import Scheduler
from app.library.Services import Services from app.library.Services import Services
from app.library.sqlite_store import SqliteStore
from app.library.TaskDefinitions import TaskDefinitions from app.library.TaskDefinitions import TaskDefinitions
from app.library.Tasks import Tasks from app.library.Tasks import Tasks
@ -42,10 +42,8 @@ class Main:
self._config.set_app_path(str(ROOT_PATH)) self._config.set_app_path(str(ROOT_PATH))
self._app = web.Application() self._app = web.Application()
self._app.on_shutdown.append(self.on_shutdown) self._app.on_shutdown.append(self.on_shutdown)
self._background_worker = BackgroundWorker()
Services.get_instance().add("app", self._app) Services.get_instance().add("app", self._app)
Services.get_instance().add("background_worker", self._background_worker)
self._check_folders() self._check_folders()
@ -56,13 +54,12 @@ class Main:
except Exception: except Exception:
pass pass
self._queue = DownloadQueue(config=self._config) self._http = HttpAPI(root_path=ROOT_PATH)
self._http = HttpAPI(root_path=ROOT_PATH, queue=self._queue) self._socket = HttpSocket(root_path=ROOT_PATH)
self._socket = HttpSocket(root_path=ROOT_PATH, queue=self._queue)
def _check_folders(self): def _check_folders(self):
"""Check if the required folders exist and create them if they do not.""" """Check if the required folders exist and create them if they do not."""
folders = (self._config.download_path, self._config.temp_path, self._config.config_path) folders: tuple[str, str, str] = (self._config.download_path, self._config.temp_path, self._config.config_path)
for folder in folders: for folder in folders:
folder = Path(folder) folder = Path(folder)
@ -109,20 +106,20 @@ class Main:
if self._config.debug: if self._config.debug:
EventBus.get_instance().debug_enable() EventBus.get_instance().debug_enable()
SqliteStore.get_instance(db_path=self._config.db_file).attach(self._app)
BackgroundWorker.get_instance().attach(self._app)
Scheduler.get_instance().attach(self._app) Scheduler.get_instance().attach(self._app)
self._socket.attach(self._app) self._socket.attach(self._app)
self._http.attach(self._app) self._http.attach(self._app)
self._queue.attach(self._app)
Tasks.get_instance().attach(self._app)
Presets.get_instance().attach(self._app) Presets.get_instance().attach(self._app)
Tasks.get_instance().attach(self._app)
Notification.get_instance().attach(self._app) Notification.get_instance().attach(self._app)
Conditions.get_instance().attach(self._app) Conditions.get_instance().attach(self._app)
DLFields.get_instance().attach(self._app) DLFields.get_instance().attach(self._app)
TaskDefinitions.get_instance().attach(self._app) TaskDefinitions.get_instance().attach(self._app)
SqliteStore.get_instance().attach(self._app) DownloadQueue.get_instance().attach(self._app)
self._background_worker.attach(self._app)
EventBus.get_instance().emit( EventBus.get_instance().emit(
Events.LOADED, Events.LOADED,

View file

@ -89,6 +89,16 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
page=page, per_page=per_page, order=order, status_filter=status_filter page=page, per_page=per_page, order=order, status_filter=status_filter
) )
if store_type == StoreType.HISTORY:
for _, download in items:
if not download.info:
continue
try:
download.info.sidecar = download.get_file_sidecar()
except Exception:
download.info.sidecar = {}
return web.json_response( return web.json_response(
data={ data={
"type": store_type.value, "type": store_type.value,
@ -100,7 +110,7 @@ async def items_list(request: Request, queue: DownloadQueue, encoder: Encoder, c
"has_next": current_page < total_pages, "has_next": current_page < total_pages,
"has_prev": current_page > 1, "has_prev": current_page > 1,
}, },
"items": [item for _, item in items], "items": [download.info for _, download in items],
}, },
status=web.HTTPOk.status_code, status=web.HTTPOk.status_code,
dumps=encoder.encode, dumps=encoder.encode,
@ -227,6 +237,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
info: dict = { info: dict = {
**item.info.serialize(), **item.info.serialize(),
"ffprobe": {}, "ffprobe": {},
"sidecar": {},
} }
if "finished" == item.info.status and (filename := item.info.get_file()): if "finished" == item.info.status and (filename := item.info.get_file()):
@ -237,6 +248,11 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
except Exception: except Exception:
pass pass
try:
info["sidecar"] = item.info.get_file_sidecar()
except Exception:
pass
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode) return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)

View file

@ -52,6 +52,14 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
to=sid, to=sid,
) )
notify.emit(
Events.ACTIVE_QUEUE,
data={"queue": (await queue.get("queue"))["queue"]},
title="Sending initial active queue data",
message=f"Sending active queue data to client '{sid}'.",
to=sid,
)
@route(RouteType.SOCKET, "disconnect", "socket_disconnect") @route(RouteType.SOCKET, "disconnect", "socket_disconnect")
async def disconnect(sio: socketio.AsyncServer, sid: str, data: str = None): async def disconnect(sio: socketio.AsyncServer, sid: str, data: str = None):

View file

@ -5,6 +5,7 @@ import pytest
import pytest_asyncio import pytest_asyncio
from app.library.DataStore import DataStore, StoreType from app.library.DataStore import DataStore, StoreType
from app.library.Download import Download
from app.library.ItemDTO import ItemDTO from app.library.ItemDTO import ItemDTO
from app.library.sqlite_store import SqliteStore from app.library.sqlite_store import SqliteStore
@ -75,8 +76,9 @@ class TestDataStorePagination:
assert page == 1 assert page == 1
assert total_pages == 10 assert total_pages == 10
for _, item in items: for _, item in items:
assert isinstance(item, ItemDTO) assert isinstance(item, Download)
assert item._id.startswith("test-id-") assert isinstance(item.info, ItemDTO)
assert item.info._id.startswith("test-id-")
finally: finally:
await db.close() await db.close()
@ -253,7 +255,7 @@ class TestDataStorePagination:
assert len(items) == 50 # First page of finished items assert len(items) == 50 # First page of finished items
assert total == 100 # Only 100 finished items in fixture assert total == 100 # Only 100 finished items in fixture
for _item_id, item in items: for _item_id, item in items:
assert item.status == "finished" assert item.info.status == "finished"
# Filter for pending items only # Filter for pending items only
items, total, _page, _total_pages = await datastore.get_items_paginated( items, total, _page, _total_pages = await datastore.get_items_paginated(
@ -262,7 +264,7 @@ class TestDataStorePagination:
assert len(items) == 1 assert len(items) == 1
assert total == 1 assert total == 1
assert items[0][1].status == "pending" assert items[0][1].info.status == "pending"
finally: finally:
await db.close() await db.close()
@ -317,10 +319,10 @@ class TestDataStorePagination:
assert total == 2 # Only 2 non-finished items assert total == 2 # Only 2 non-finished items
assert len(items) == 2 assert len(items) == 2
for _item_id, item in items: for _item_id, item in items:
assert item.status != "finished" assert item.info.status != "finished"
# Verify we have pending and error # Verify we have pending and error
statuses = {item.status for _, item in items} statuses = {item.info.status for _, item in items}
assert statuses == {"pending", "error"} assert statuses == {"pending", "error"}
finally: finally:
await db.close() await db.close()

View file

@ -118,10 +118,6 @@ export const useSocketStore = defineStore('socket', () => {
config.add('folders', json.data.folders) config.add('folders', json.data.folders)
} }
if (json.data?.queue) {
stateStore.addAll('queue', json.data.queue || {})
}
if (typeof json.data?.history_count === 'number') { if (typeof json.data?.history_count === 'number') {
stateStore.setHistoryCount(json.data.history_count) stateStore.setHistoryCount(json.data.history_count)
} }
@ -129,6 +125,14 @@ export const useSocketStore = defineStore('socket', () => {
error.value = null; error.value = null;
}) })
on('active_queue', stream => {
const json = JSON.parse(stream);
if (!json?.data?.queue) {
return;
}
stateStore.addAll('queue', json.data.queue || {})
})
on('item_added', stream => { on('item_added', stream => {
const json = JSON.parse(stream); const json = JSON.parse(stream);
stateStore.add('queue', json.data._id, json.data); stateStore.add('queue', json.data._id, json.data);