mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
337 lines
11 KiB
Python
337 lines
11 KiB
Python
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
|
||
#
|
||
# This file is part of Furious.
|
||
#
|
||
# This program is free software: you can redistribute it and/or modify
|
||
# it under the terms of the GNU General Public License as published by
|
||
# the Free Software Foundation, either version 3 of the License, or
|
||
# (at your option) any later version.
|
||
#
|
||
# This program is distributed in the hope that it will be useful,
|
||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
# GNU General Public License for more details.
|
||
#
|
||
# You should have received a copy of the GNU General Public License
|
||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
|
||
"""Persist subscription definitions."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from Furious.Frozenlib import *
|
||
from Furious.Interface import *
|
||
from Furious.Models.Encoding import *
|
||
|
||
from collections.abc import Mapping
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
import logging
|
||
|
||
__all__ = ['SubscriptionGroup', 'UserSubs']
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
registerAppSettings('CustomSubscription')
|
||
|
||
|
||
@dataclass
|
||
class SubscriptionGroup:
|
||
"""Describe a subscription source and the profiles that it owns."""
|
||
|
||
remark: str = ''
|
||
webURL: str = ''
|
||
enabled: bool = True
|
||
autoupdate: str = ''
|
||
proxy: str = ''
|
||
userAgent: str = ''
|
||
filter: str = ''
|
||
lastUpdated: str = ''
|
||
id: str = ''
|
||
sortOrder: int = 0
|
||
lastDecoderId: str = ''
|
||
lastSyncStatus: str = ''
|
||
lastSyncError: str = ''
|
||
profileCount: int = 0
|
||
subscriptionUpload: int = 0
|
||
subscriptionDownload: int = 0
|
||
subscriptionTotal: int = 0
|
||
subscriptionExpire: int = 0
|
||
extras: dict[str, Any] = field(default_factory=dict, repr=False)
|
||
|
||
@classmethod
|
||
def fromMapping(cls, unique: str, value: Mapping[str, Any] | None = None):
|
||
"""Restore a group while preserving fields from newer installations."""
|
||
data = dict(value or {})
|
||
nestedExtras = data.pop('extras', {})
|
||
known = {
|
||
name: data.pop(name, default)
|
||
for name, default in (
|
||
('remark', ''),
|
||
('webURL', ''),
|
||
('enabled', True),
|
||
('autoupdate', ''),
|
||
('proxy', ''),
|
||
('userAgent', ''),
|
||
('filter', ''),
|
||
('lastUpdated', ''),
|
||
('sortOrder', 0),
|
||
('lastDecoderId', data.pop('decoderId', '')),
|
||
('lastSyncStatus', ''),
|
||
('lastSyncError', ''),
|
||
('profileCount', 0),
|
||
('subscriptionUpload', 0),
|
||
('subscriptionDownload', 0),
|
||
('subscriptionTotal', 0),
|
||
('subscriptionExpire', 0),
|
||
)
|
||
}
|
||
|
||
for name in (
|
||
'remark',
|
||
'webURL',
|
||
'autoupdate',
|
||
'proxy',
|
||
'userAgent',
|
||
'filter',
|
||
'lastUpdated',
|
||
'lastDecoderId',
|
||
'lastSyncStatus',
|
||
'lastSyncError',
|
||
):
|
||
known[name] = str(known[name] or '')
|
||
|
||
if isinstance(known['enabled'], str):
|
||
known['enabled'] = known['enabled'].strip().casefold() in (
|
||
'1',
|
||
'true',
|
||
'yes',
|
||
'on',
|
||
)
|
||
else:
|
||
known['enabled'] = bool(known['enabled'])
|
||
|
||
for name in ('sortOrder', 'profileCount'):
|
||
try:
|
||
known[name] = max(0, int(known[name]))
|
||
except (TypeError, ValueError):
|
||
known[name] = 0
|
||
|
||
for name in (
|
||
'subscriptionUpload',
|
||
'subscriptionDownload',
|
||
'subscriptionTotal',
|
||
'subscriptionExpire',
|
||
):
|
||
try:
|
||
known[name] = min(max(0, int(known[name])), (1 << 63) - 1)
|
||
except (TypeError, ValueError):
|
||
known[name] = 0
|
||
|
||
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
|
||
extras.update(data)
|
||
|
||
return cls(id=str(unique), **known, extras=extras)
|
||
|
||
def toMapping(self) -> dict[str, Any]:
|
||
"""Return the backward-compatible persisted group mapping."""
|
||
result = dict(self.extras)
|
||
result.update(
|
||
{
|
||
'remark': self.remark,
|
||
'webURL': self.webURL,
|
||
'enabled': self.enabled,
|
||
'autoupdate': self.autoupdate,
|
||
'proxy': self.proxy,
|
||
'userAgent': self.userAgent,
|
||
'filter': self.filter,
|
||
'lastUpdated': self.lastUpdated,
|
||
'sortOrder': self.sortOrder,
|
||
'lastDecoderId': self.lastDecoderId,
|
||
'lastSyncStatus': self.lastSyncStatus,
|
||
'lastSyncError': self.lastSyncError,
|
||
'profileCount': self.profileCount,
|
||
'subscriptionUpload': self.subscriptionUpload,
|
||
'subscriptionDownload': self.subscriptionDownload,
|
||
'subscriptionTotal': self.subscriptionTotal,
|
||
'subscriptionExpire': self.subscriptionExpire,
|
||
}
|
||
)
|
||
|
||
return result
|
||
|
||
|
||
class UserSubs(Mixins.CleanupOnExit, StorageBackend):
|
||
# unique: { remark, webURL }
|
||
"""Manage the persisted subscription collection."""
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
"""Initialize the UserSubs."""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self._restoreFailed = False
|
||
|
||
def restore():
|
||
"""Restore the user subs."""
|
||
raw = AppSettings.get('CustomSubscription')
|
||
|
||
if raw is None:
|
||
return {}
|
||
|
||
try:
|
||
data = UJSONEncoder.decode(PyBase64Encoder.decode(raw))
|
||
|
||
if isinstance(data, dict):
|
||
return data
|
||
|
||
raise TypeError('subscription repository root must be an object')
|
||
except Exception:
|
||
# Any non-exit exceptions
|
||
|
||
self._restoreFailed = True
|
||
|
||
logger.exception('failed to restore persisted subscriptions')
|
||
|
||
return {}
|
||
|
||
restored = restore()
|
||
|
||
self._data = {}
|
||
normalized = {}
|
||
|
||
# Publish only a completely hydrated collection. Failed startup cleanup
|
||
# must not serialize a partly normalized document over recoverable input.
|
||
try:
|
||
for order, (unique, value) in enumerate(restored.items()):
|
||
if not isinstance(value, Mapping):
|
||
raise TypeError('subscription repository records must be objects')
|
||
|
||
group = SubscriptionGroup.fromMapping(unique, value)
|
||
|
||
if not group.sortOrder:
|
||
group.sortOrder = order
|
||
|
||
normalized[unique] = group.toMapping()
|
||
except Exception as ex:
|
||
# Any non-exit exceptions
|
||
|
||
self._restoreFailed = True
|
||
|
||
logger.error(
|
||
'failed to restore subscription records (%s)', type(ex).__name__
|
||
)
|
||
else:
|
||
self._data = normalized
|
||
|
||
def sync(self):
|
||
"""Persist the current user subs data."""
|
||
AppSettings.set(
|
||
'CustomSubscription',
|
||
PyBase64Encoder.encode(
|
||
UJSONEncoder.encode(self._data).encode(),
|
||
),
|
||
)
|
||
self._restoreFailed = False
|
||
|
||
def data(self) -> dict[str, dict]:
|
||
"""Return the live mutable collection managed by this repository."""
|
||
return self._data
|
||
|
||
def groups(self) -> tuple[SubscriptionGroup, ...]:
|
||
"""Return subscription groups ordered independently from table rows."""
|
||
return tuple(
|
||
sorted(
|
||
(
|
||
SubscriptionGroup.fromMapping(unique, value)
|
||
for unique, value in self._data.items()
|
||
),
|
||
key=lambda group: (group.sortOrder, group.remark.casefold(), group.id),
|
||
)
|
||
)
|
||
|
||
def group(self, unique: str) -> SubscriptionGroup | None:
|
||
"""Return one group by stable ID."""
|
||
value = self._data.get(unique)
|
||
|
||
return (
|
||
SubscriptionGroup.fromMapping(unique, value)
|
||
if isinstance(value, Mapping)
|
||
else None
|
||
)
|
||
|
||
def moveGroups(self, groupIds, position: str) -> bool:
|
||
"""Move selected groups one position while preserving relative order."""
|
||
selected = {str(groupId) for groupId in groupIds}
|
||
items = list(self._data.items())
|
||
selected.intersection_update(unique for unique, _value in items)
|
||
|
||
if not selected or position not in ('up', 'down'):
|
||
return False
|
||
|
||
originalOrder = list(items)
|
||
|
||
def isSelected(item):
|
||
"""Return whether the group belongs to the selected identity set."""
|
||
return item[0] in selected
|
||
|
||
if position == 'up':
|
||
for index in range(1, len(items)):
|
||
if isSelected(items[index]) and not isSelected(items[index - 1]):
|
||
items[index - 1], items[index] = items[index], items[index - 1]
|
||
else:
|
||
for index in range(len(items) - 2, -1, -1):
|
||
if isSelected(items[index]) and not isSelected(items[index + 1]):
|
||
items[index], items[index + 1] = items[index + 1], items[index]
|
||
|
||
if items == originalOrder:
|
||
return False
|
||
|
||
self._data.clear()
|
||
self._data.update(items)
|
||
|
||
for order, value in enumerate(self._data.values()):
|
||
value['sortOrder'] = order
|
||
|
||
return True
|
||
|
||
def upsertGroup(self, group: SubscriptionGroup):
|
||
"""Insert or replace one group without changing its identity."""
|
||
self.upsertGroups((group,))
|
||
|
||
def upsertGroups(self, groups):
|
||
"""Atomically insert or replace a batch of validated groups."""
|
||
staged = {}
|
||
|
||
for group in groups:
|
||
if not isinstance(group, SubscriptionGroup):
|
||
raise TypeError('subscription group must be a SubscriptionGroup')
|
||
|
||
if not group.id:
|
||
raise ValueError('subscription group ID must not be empty')
|
||
|
||
staged[group.id] = group.toMapping()
|
||
|
||
self._data.update(staged)
|
||
|
||
def removeGroup(self, unique: str) -> SubscriptionGroup | None:
|
||
"""Remove and return one group definition."""
|
||
value = self._data.pop(unique, None)
|
||
|
||
return (
|
||
SubscriptionGroup.fromMapping(unique, value)
|
||
if isinstance(value, Mapping)
|
||
else None
|
||
)
|
||
|
||
def cleanup(self):
|
||
"""Release resources owned by the user subs."""
|
||
if self._restoreFailed and not self._data:
|
||
logger.warning(
|
||
'preserving unreadable persisted subscriptions during cleanup'
|
||
)
|
||
|
||
return
|
||
|
||
self.sync()
|