Files
LorenEteval_Furious/Furious/Repository/Subscriptions.py
T
2026-09-18 22:03:05 +08:00

337 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Copyright (C) 2024present 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()