Add first-class subscription groups

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:24:03 +08:00
parent 4930ed5d6b
commit f9d1b3cd3f
14 changed files with 786 additions and 84 deletions
+39 -4
View File
@@ -540,7 +540,8 @@ TRANSLATION = {
},
"Move Up": {
"source": [
"Furious.Widget.ServerTableView"
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView"
],
"RU": "Переместить вверх",
"ZH": "上移",
@@ -548,7 +549,8 @@ TRANSLATION = {
},
"Move Down": {
"source": [
"Furious.Widget.ServerTableView"
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView"
],
"RU": "Переместить вниз",
"ZH": "下移",
@@ -1977,6 +1979,7 @@ TRANSLATION = {
"Disabled": {
"source": [
"Furious.Backends.Xray.RoutingWindow",
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView"
],
"RU": "Отключено",
@@ -2757,8 +2760,8 @@ TRANSLATION = {
"source": [
"Furious.Window.SubscriptionPage"
],
"RU": "Введите название подписки.",
"ZH": "请输入订阅备注。",
"RU": "Введите примечание к подписки.",
"ZH": "请输入订阅别名。",
"isReviewed": "True"
},
"Please enter a valid subscription URL.": {
@@ -2840,5 +2843,37 @@ TRANSLATION = {
"RU": "Использовать URL подписки из буфера обмена?",
"ZH": "是否使用剪贴板中的订阅 URL?",
"isReviewed": "True"
},
"All Profiles": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Все профили",
"ZH": "全部配置",
"isReviewed": "True"
},
"Manual Profiles": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Профили, добавленные вручную",
"ZH": "手动添加的配置",
"isReviewed": "True"
},
"View Profiles": {
"source": [
"Furious.Window.SubscriptionPage"
],
"RU": "Показать профили",
"ZH": "查看配置",
"isReviewed": "True"
},
"Unknown Subscription": {
"source": [
"Furious.Widget.ServerTableView"
],
"RU": "Неизвестная подписка",
"ZH": "未知订阅",
"isReviewed": "True"
}
}
+73 -3
View File
@@ -24,6 +24,9 @@ from dataclasses import dataclass, field
from typing import Any
import copy
import hashlib
import json
import uuid
from .Configuration import ConfigFactory
@@ -32,6 +35,7 @@ __all__ = [
'ServerProfile',
'connectionOf',
'ensureProfile',
'profileConnectionFingerprint',
]
@@ -49,6 +53,9 @@ class ProfileMetadata:
latency: str = ''
speed: str = ''
extras: dict[str, Any] = field(default_factory=dict)
profileId: str = field(default_factory=lambda: str(uuid.uuid4()))
subscriptionManaged: bool = False
subscriptionProfileKey: str = ''
@classmethod
def fromMapping(cls, value: Mapping[str, Any] | None = None, **kwargs):
@@ -68,19 +75,38 @@ class ProfileMetadata:
if isinstance(favorite, str):
favorite = favorite.strip().casefold() in ('1', 'true', 'yes', 'on')
subscriptionSource = str(
data.pop('subscriptionSource', data.pop('subsId', '')) or ''
)
subscriptionManaged = data.pop('subscriptionManaged', None)
if subscriptionManaged is None:
# Before managed ownership was explicit, a non-empty subsId was
# written only for profiles imported from that subscription.
subscriptionManaged = bool(subscriptionSource)
elif isinstance(subscriptionManaged, str):
subscriptionManaged = subscriptionManaged.strip().casefold() in (
'1',
'true',
'yes',
'on',
)
known = {
'profileId': str(data.pop('profileId', '') or uuid.uuid4()),
'displayName': data.pop('displayName', data.pop('remark', '')),
'group': data.pop('group', ''),
'tags': tags,
'subscriptionSource': data.pop(
'subscriptionSource', data.pop('subsId', '')
),
'subscriptionSource': subscriptionSource,
'subscriptionManaged': bool(subscriptionManaged),
'subscriptionProfileKey': str(data.pop('subscriptionProfileKey', '') or ''),
'updatedAt': data.pop('updatedAt', ''),
'annotations': data.pop('annotations', ''),
'favorite': bool(favorite),
'latency': data.pop('latency', data.pop('delayResult', '')),
'speed': data.pop('speed', data.pop('speedResult', '')),
}
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
extras.update(data)
@@ -89,10 +115,13 @@ class ProfileMetadata:
def toMapping(self) -> dict[str, Any]:
"""Return the normalized persisted metadata mapping."""
return {
'profileId': self.profileId,
'displayName': self.displayName,
'group': self.group,
'tags': list(self.tags),
'subscriptionSource': self.subscriptionSource,
'subscriptionManaged': self.subscriptionManaged,
'subscriptionProfileKey': self.subscriptionProfileKey,
'updatedAt': self.updatedAt,
'annotations': self.annotations,
'favorite': self.favorite,
@@ -119,6 +148,12 @@ class ProfileMetadata:
)
elif attribute == 'favorite' and isinstance(value, str):
self.favorite = value.strip().casefold() in ('1', 'true', 'yes', 'on')
elif attribute == 'subscriptionSource':
self.subscriptionSource = str(value or '')
if not self.subscriptionSource:
self.subscriptionManaged = False
self.subscriptionProfileKey = ''
elif attribute in self.__dataclass_fields__ and attribute != 'extras':
setattr(self, attribute, value)
else:
@@ -183,6 +218,16 @@ class ServerProfile(MutableMapping[str, Any]):
"""Return an independent profile copy."""
return copy.deepcopy(self)
def independentCopy(self):
"""Return a manual copy with a new identity and no source owner."""
profile = self.deepcopy()
profile.metadata.profileId = str(uuid.uuid4())
profile.metadata.subscriptionSource = ''
profile.metadata.subscriptionManaged = False
profile.metadata.subscriptionProfileKey = ''
return profile
def replaceConnection(self, connection: ConfigFactory):
"""Return this profile's metadata composed with a new connection."""
return ServerProfile.fromConfiguration(
@@ -259,6 +304,11 @@ class ServerProfile(MutableMapping[str, Any]):
"""Return the subscription source identifier."""
return self.metadata.subscriptionSource
@property
def itemSubscriptionManaged(self) -> bool:
"""Return whether a subscription synchronizer owns this profile."""
return self.metadata.subscriptionManaged
@property
def itemLatency(self) -> str:
"""Return the last latency result."""
@@ -284,3 +334,23 @@ def ensureProfile(value, **metadata) -> ServerProfile:
return value
return ServerProfile.fromConfiguration(value, metadata)
def profileConnectionFingerprint(value) -> str:
"""Return a deterministic identity for a profile connection document."""
connection = connectionOf(value)
try:
serialized = json.dumps(
connection,
ensure_ascii=False,
separators=(',', ':'),
sort_keys=True,
)
except Exception:
# Any non-exit exceptions
serializer = getattr(connection, 'toJSONString', None)
serialized = serializer(indent=0) if callable(serializer) else str(connection)
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()
+2
View File
@@ -32,6 +32,7 @@ from .Profile import (
ServerProfile,
connectionOf,
ensureProfile,
profileConnectionFingerprint,
)
from .Protocol import Protocol
@@ -48,4 +49,5 @@ __all__ = [
'UJSONEncoder',
'connectionOf',
'ensureProfile',
'profileConnectionFingerprint',
]
+1
View File
@@ -242,6 +242,7 @@ class SubscriptionItem:
configuration: Optional[Mapping[str, Any]] = None
name: str = ''
metadata: Mapping[str, Any] = field(default_factory=dict)
upstreamId: str = ''
def __post_init__(self):
"""Require exactly one serialized or normalized profile value."""
+21 -1
View File
@@ -24,7 +24,7 @@ from Furious.Interface import *
from Furious.Models import ServerProfile
from Furious.Repository.Routings import UserRoutings
from Furious.Repository.Servers import UserServers
from Furious.Repository.Subscriptions import UserSubs
from Furious.Repository.Subscriptions import SubscriptionGroup, UserSubs
from Furious.Repository.TunSettings import UserTUNSettings
from typing import Union
@@ -89,6 +89,26 @@ class Storage:
"""Return the user subs value."""
return Storage._UserSubsStorage().data()
@staticmethod
def SubscriptionGroups() -> tuple[SubscriptionGroup, ...]:
"""Return first-class subscription groups in their display order."""
return Storage._UserSubsStorage().groups()
@staticmethod
def SubscriptionGroup(unique: str) -> SubscriptionGroup | None:
"""Return one subscription group by stable ID."""
return Storage._UserSubsStorage().group(unique)
@staticmethod
def upsertSubscriptionGroup(group: SubscriptionGroup):
"""Persist one subscription group through the shared repository."""
Storage._UserSubsStorage().upsertGroup(group)
@staticmethod
def removeSubscriptionGroup(unique: str) -> SubscriptionGroup | None:
"""Remove one subscription group through the shared repository."""
return Storage._UserSubsStorage().removeGroup(unique)
@staticmethod
def UserTUNSettings() -> dict[str, str]:
"""Return the user TUN settings value."""
+144 -8
View File
@@ -23,16 +23,18 @@ from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Models.Encoding import *
from dataclasses import dataclass
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
__all__ = ['UserSubs']
__all__ = ['SubscriptionGroup', 'UserSubs']
registerAppSettings('CustomSubscription')
@dataclass
class UserSubEntry:
"""Describe one user sub entry."""
class SubscriptionGroup:
"""Describe a subscription source and the profiles that it owns."""
remark: str = ''
webURL: str = ''
@@ -42,12 +44,95 @@ class UserSubEntry:
userAgent: str = ''
filter: str = ''
lastUpdated: str = ''
id: str = ''
sortOrder: int = 0
lastDecoderId: str = ''
lastSyncStatus: str = ''
lastSyncError: str = ''
profileCount: 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),
)
}
class UserSub:
"""Represent user sub."""
for name in (
'remark',
'webURL',
'autoupdate',
'proxy',
'userAgent',
'filter',
'lastUpdated',
'lastDecoderId',
'lastSyncStatus',
'lastSyncError',
):
known[name] = str(known[name] or '')
unique: dict[str, dict]
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
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,
}
)
return result
class UserSubs(Mixins.CleanupOnExit, StorageBackend):
@@ -69,7 +154,19 @@ class UserSubs(Mixins.CleanupOnExit, StorageBackend):
return {}
self._data = restore()
restored = restore()
self._data = restored if isinstance(restored, dict) else {}
# Normalize legacy URL-only entries into the current group schema. The
# dictionary key remains the stable group ID used by existing profiles.
for order, (unique, value) in enumerate(tuple(self._data.items())):
group = SubscriptionGroup.fromMapping(unique, value)
if not group.sortOrder:
group.sortOrder = order
self._data[unique] = group.toMapping()
def sync(self):
"""Persist the current user subs data."""
@@ -85,6 +182,45 @@ class UserSubs(Mixins.CleanupOnExit, StorageBackend):
"""Return the data managed by the user subs."""
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 upsertGroup(self, group: SubscriptionGroup):
"""Insert or replace one group without changing its identity."""
if not group.id:
raise ValueError('subscription group ID must not be empty')
self._data[group.id] = group.toMapping()
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."""
self.sync()
+2 -1
View File
@@ -20,5 +20,6 @@
from __future__ import annotations
from .Storage import Storage
from .Subscriptions import SubscriptionGroup
__all__ = ['Storage']
__all__ = ['Storage', 'SubscriptionGroup']
+14 -1
View File
@@ -19,7 +19,7 @@
from __future__ import annotations
from Furious.Models import ServerProfile
from Furious.Models import ServerProfile, profileConnectionFingerprint
from Furious.Plugins import getPluginRegistry, profileFromAny
from dataclasses import dataclass, field
@@ -71,12 +71,14 @@ class SubscriptionImportService:
profiles = []
rejected = 0
identityOccurrences = {}
for item in result.items:
value = item.configuration if item.configuration is not None else item.uri
metadata = {
**dict(item.metadata),
'subscriptionSource': source.id,
'subscriptionManaged': True,
'updatedAt': source.updatedAt,
}
@@ -89,6 +91,17 @@ class SubscriptionImportService:
**metadata,
)
baseIdentity = (
f'upstream:{item.upstreamId}'
if item.upstreamId
else f'config:{profileConnectionFingerprint(profile)}'
)
occurrence = identityOccurrences.get(baseIdentity, 0)
identityOccurrences[baseIdentity] = occurrence + 1
profile.metadata.subscriptionProfileKey = (
baseIdentity if occurrence == 0 else f'{baseIdentity}#{occurrence + 1}'
)
if not self.registry.validateConfig(profile):
profiles.append(profile)
else:
+174
View File
@@ -0,0 +1,174 @@
# 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/>.
"""Reconcile subscription-owned profiles without affecting other groups."""
from __future__ import annotations
from Furious.Models import ServerProfile, profileConnectionFingerprint
from dataclasses import dataclass
__all__ = ['SubscriptionSyncResult', 'SubscriptionSynchronizer']
@dataclass(frozen=True)
class SubscriptionSyncResult:
"""Describe one atomic subscription-group synchronization."""
groupId: str
added: int
updated: int
removed: int
profileIds: tuple[str, ...]
removedProfileIds: tuple[str, ...]
changedProfileIds: tuple[str, ...]
class SubscriptionSynchronizer:
"""Own stable, group-scoped profile reconciliation semantics."""
LocalMetadataFields = (
'annotations',
'favorite',
'group',
'latency',
'speed',
'tags',
)
@staticmethod
def _ensureKeys(profiles: list[ServerProfile], groupId: str):
"""Migrate legacy group profiles to deterministic occurrence keys."""
occurrences = {}
for profile in profiles:
metadata = profile.metadata
if metadata.subscriptionSource != groupId:
continue
if not metadata.subscriptionManaged:
continue
if metadata.subscriptionProfileKey:
continue
baseIdentity = f'config:{profileConnectionFingerprint(profile)}'
occurrence = occurrences.get(baseIdentity, 0)
occurrences[baseIdentity] = occurrence + 1
metadata.subscriptionProfileKey = (
baseIdentity if occurrence == 0 else f'{baseIdentity}#{occurrence + 1}'
)
def reconcile(
self,
profiles: list[ServerProfile],
incomingProfiles,
groupId: str,
) -> SubscriptionSyncResult:
"""Atomically replace only profiles managed by *groupId*."""
if not groupId:
raise ValueError('subscription group ID must not be empty')
incoming = list(incomingProfiles)
self._ensureKeys(profiles, groupId)
managedIndexes = [
index
for index, profile in enumerate(profiles)
if profile.itemSubscription == groupId and profile.itemSubscriptionManaged
]
insertionIndex = min(managedIndexes) if managedIndexes else len(profiles)
existingByKey = {
profiles[index].metadata.subscriptionProfileKey: profiles[index]
for index in managedIndexes
}
synchronized = []
updated = 0
added = 0
changedProfileIds = []
for profile in incoming:
metadata = profile.metadata
metadata.subscriptionSource = groupId
metadata.subscriptionManaged = True
if not metadata.subscriptionProfileKey:
metadata.subscriptionProfileKey = (
f'config:{profileConnectionFingerprint(profile)}'
)
existing = existingByKey.pop(metadata.subscriptionProfileKey, None)
if existing is None:
added += 1
synchronized.append(profile)
continue
oldFingerprint = profileConnectionFingerprint(existing)
for fieldName in self.LocalMetadataFields:
setattr(metadata, fieldName, getattr(existing.metadata, fieldName))
metadata.profileId = existing.metadata.profileId
existing.connection = profile.connection
existing.metadata = metadata
existing.deleted = False
synchronized.append(existing)
updated += 1
if oldFingerprint != profileConnectionFingerprint(existing):
changedProfileIds.append(metadata.profileId)
removedProfiles = tuple(existingByKey.values())
for profile in removedProfiles:
profile.deleted = True
unmanagedOrOther = [
profile
for profile in profiles
if not (
profile.itemSubscription == groupId and profile.itemSubscriptionManaged
)
]
profiles[:] = (
unmanagedOrOther[:insertionIndex]
+ synchronized
+ unmanagedOrOther[insertionIndex:]
)
for index, profile in enumerate(profiles):
profile.index = index
profile.deleted = False
return SubscriptionSyncResult(
groupId=groupId,
added=added,
updated=updated,
removed=len(removedProfiles),
profileIds=tuple(profile.metadata.profileId for profile in synchronized),
removedProfileIds=tuple(
profile.metadata.profileId for profile in removedProfiles
),
changedProfileIds=tuple(changedProfileIds),
)
+3
View File
@@ -47,6 +47,7 @@ from .SubscriptionImporter import (
SubscriptionImportService,
SubscriptionSource,
)
from .SubscriptionSync import SubscriptionSyncResult, SubscriptionSynchronizer
from .TrafficStatsManager import (
TrafficStatsSample,
TrafficStatsManager,
@@ -77,6 +78,8 @@ __all__ = [
'SubscriptionImportResult',
'SubscriptionImportService',
'SubscriptionSource',
'SubscriptionSyncResult',
'SubscriptionSynchronizer',
'TrafficStatsSample',
'TrafficStatsManager',
'UpdateManager',
+151 -49
View File
@@ -35,6 +35,7 @@ from Furious.Service import (
ConnectionManager,
SubscriptionImportService,
SubscriptionSource,
SubscriptionSynchronizer,
coreLogCallback,
)
from Furious.Widget.WaitingSpinner import *
@@ -161,9 +162,10 @@ class SubscriptionManager(WebGETManager):
super().__init__(parent, actionMessage=actionMessage, mustCallOnce=False)
self.importer = SubscriptionImportService()
self.synchronizer = SubscriptionSynchronizer()
def handleItemDeletionAndInsertion(self, **kwargs):
"""Handle item deletion and insertion."""
def handleSynchronizationResults(self, **kwargs):
"""Commit successful group-scoped synchronization results."""
successArgs = kwargs.pop('successArgs', list())
failureArgs = kwargs.pop('failureArgs', list())
showMessageBox = kwargs.pop('showMessageBox', True)
@@ -174,45 +176,22 @@ class SubscriptionManager(WebGETManager):
parent = self.parent()
if isinstance(parent, ServerTableView):
isConnected = APP().isSystemTrayConnected()
subsIndexes = list(
index
for index, server in enumerate(Storage.UserServers())
if server.itemSubscription == unique
result = parent.synchronizeSubscriptionProfiles(
unique,
profiles,
self.synchronizer,
)
param['syncResult'] = result
subsGroupIndex = -1
activatedIndex = Storage.UserActivatedItemIndex()
group = Storage.SubscriptionGroup(unique)
if activatedIndex in subsIndexes:
for index, server in enumerate(Storage.UserServers()):
if index <= activatedIndex:
if server.itemSubscription == unique:
subsGroupIndex += 1
else:
break
if group is not None:
group.lastDecoderId = param.get('decoderId', '')
group.lastSyncStatus = 'success'
group.lastSyncError = ''
group.profileCount = len(result.profileIds)
parent.deleteItemByIndex(
subsIndexes,
showTrayMessage=bool(subsGroupIndex < 0),
showProgress=False,
)
remaining = len(Storage.UserServers())
for profile in profiles:
parent.appendNewItemByFactory(profile)
if subsGroupIndex >= 0:
newIndex = remaining + subsGroupIndex
if newIndex < len(Storage.UserServers()):
parent.activateItemByIndex(newIndex, True)
if isConnected and not APP().isSystemTrayConnected():
# Trigger connect
APP().systemTray.ConnectAction.trigger()
Storage.upsertSubscriptionGroup(group)
if showMessageBox:
mbox = MBoxUpdateSubsInfo(
@@ -240,7 +219,7 @@ class SubscriptionManager(WebGETManager):
depthMap['depth'] -= 1
if depthMap['depth'] == 0:
self.handleItemDeletionAndInsertion(**kwargs)
self.handleSynchronizationResults(**kwargs)
def successCallback(self, networkReply, **kwargs):
"""Handle a successful network operation."""
@@ -281,6 +260,13 @@ class SubscriptionManager(WebGETManager):
if result is None or not result.profiles:
failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs})
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
if group is not None:
group.lastSyncStatus = 'error'
group.lastSyncError = 'UnsupportedSubscriptionFormat'
Storage.upsertSubscriptionGroup(group)
else:
logger.info(
f'update subs ({remark}, {webURL}) success. '
@@ -288,14 +274,27 @@ class SubscriptionManager(WebGETManager):
f'rejected {result.rejectedItems}'
)
successArgs.append({'profiles': result.profiles, **kwargs})
successArgs.append(
{
**kwargs,
'profiles': result.profiles,
'decoderId': result.decoderId,
}
)
unique = kwargs.get('unique', '')
if unique in Storage.UserSubs():
Storage.UserSubs()[unique]['lastUpdated'] = (
datetime.datetime.now().astimezone().isoformat(timespec='seconds')
)
group = Storage.SubscriptionGroup(unique)
if group is not None:
group.lastUpdated = (
datetime.datetime.now()
.astimezone()
.isoformat(timespec='seconds')
)
group.lastDecoderId = result.decoderId
Storage.upsertSubscriptionGroup(group)
def failureCallback(self, networkReply, **kwargs):
"""Handle a failed network operation."""
@@ -310,6 +309,13 @@ class SubscriptionManager(WebGETManager):
failureArgs.append({'error': networkReply.errorString(), **kwargs})
group = Storage.SubscriptionGroup(kwargs.get('unique', ''))
if group is not None:
group.lastSyncStatus = 'error'
group.lastSyncError = networkReply.errorString()
Storage.upsertSubscriptionGroup(group)
def updateSubsByWebGET(self, **kwargs):
"""Update subs by web get."""
url = kwargs.get('webURL', '')
@@ -335,9 +341,20 @@ class SubscriptionManager(WebGETManager):
"""Update subs by unique."""
subscription = Storage.UserSubs().get(unique)
if not subscription or not subscription.get('enabled', True):
if (
not subscription
or not subscription.get('enabled', True)
or not subscription.get('webURL')
):
return
group = Storage.SubscriptionGroup(unique)
if group is not None:
group.lastSyncStatus = 'syncing'
group.lastSyncError = ''
Storage.upsertSubscriptionGroup(group)
depthMap = kwargs.get('depthMap', {'depth': 1})
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
@@ -1083,9 +1100,19 @@ class ServerTableColumn:
def _subscriptionRemark(item: ServerProfile) -> str:
"""Resolve a persisted subscription ID to its user-visible remark."""
if not item.itemSubscriptionManaged:
return ''
subscription = Storage.UserSubs().get(item.itemSubscription, {})
return subscription.get('remark', '')
if not subscription:
return _('Unknown Subscription')
remark = subscription.get('remark', '') or item.itemSubscription
return (
remark if subscription.get('enabled', True) else f'{remark} ({_("Disabled")})'
)
class UserServersTableModel(QtCore.QAbstractTableModel):
@@ -1299,6 +1326,7 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
self.searchCaseSensitive = False
self.searchUseRegex = True
self.searchRegex = None
self.subscriptionFilter = None
self.sortSuspended = False
self.setSortRole(UserServersTableModel.SortRole)
@@ -1357,16 +1385,33 @@ class UserServersSortFilterProxyModel(QtCore.QSortFilterProxyModel):
self.invalidateFilter()
def setSubscriptionFilter(self, unique: str | None):
"""Limit rows to manual profiles or one subscription group."""
self.subscriptionFilter = unique
self.invalidateFilter()
def filterAcceptsRow(self, sourceRow: int, sourceParent) -> bool:
"""Filter accepts row."""
if not self.searchPattern or self.searchRegex is None:
return True
model = self.sourceModel()
if model is None:
return True
if 0 <= sourceRow < len(Storage.UserServers()):
profile = Storage.UserServers()[sourceRow]
if self.subscriptionFilter == '':
if profile.itemSubscriptionManaged:
return False
elif (
self.subscriptionFilter is not None
and profile.itemSubscription != self.subscriptionFilter
):
return False
if not self.searchPattern or self.searchRegex is None:
return True
searchableText = '\n'.join(
str(
model.data(
@@ -1445,6 +1490,7 @@ class ServerTableView(
self.setModel(self.proxyModel)
self.subsManager = SubscriptionManager(parent=self)
self.subsManager.subscriptionsChanged.connect(self.flushAll)
self.downloadSpeedScheduler = DownloadSpeedTestScheduler(
self,
@@ -1972,6 +2018,10 @@ class ServerTableView(
"""Clear search."""
self.search('')
def filterBySubscription(self, unique: str | None):
"""Show all, manual, or one subscription group's profiles."""
self.proxyModel.setSubscriptionFilter(unique)
def addServerViaGui(
self,
protocol,
@@ -2138,9 +2188,10 @@ class ServerTableView(
for index in indexes:
if 0 <= index < len(Storage.UserServers()):
deepcopy = Storage.UserServers()[index].deepcopy()
deepcopy = Storage.UserServers()[index].independentCopy()
# Do not clone subsId
# A duplicate is a new manual profile, not another profile
# managed by the source subscription.
self.appendNewItem(
remark=deepcopy.itemRemark,
config=deepcopy,
@@ -2483,6 +2534,57 @@ class ServerTableView(
self.subsManager.configureHttpProxy(httpProxy)
self.subsManager.updateSubs(**kwargs)
def synchronizeSubscriptionProfiles(
self,
unique: str,
profiles,
synchronizer: SubscriptionSynchronizer | None = None,
):
"""Apply one group update without replacing unrelated profiles."""
synchronizer = synchronizer or SubscriptionSynchronizer()
servers = Storage.UserServers()
activatedIndex = Storage.UserActivatedItemIndex()
activeProfileId = ''
activeWasManagedByGroup = False
if 0 <= activatedIndex < len(servers):
active = servers[activatedIndex]
activeProfileId = active.metadata.profileId
activeWasManagedByGroup = (
active.itemSubscription == unique and active.itemSubscriptionManaged
)
wasConnected = APP().isSystemTrayConnected()
self.sourceModel.beginResetModel()
try:
result = synchronizer.reconcile(servers, profiles, unique)
finally:
self.sourceModel.endResetModel()
newActivatedIndex = next(
(
index
for index, profile in enumerate(servers)
if profile.metadata.profileId == activeProfileId
),
-1,
)
AppSettings.set('ActivatedItemIndex', str(newActivatedIndex))
self.sourceModel.refreshIndexes()
self.proxyModel.invalidate()
if wasConnected and activeProfileId:
if newActivatedIndex < 0 and activeWasManagedByGroup:
APP().systemTray.ConnectAction.doDisconnect()
elif activeProfileId in result.changedProfileIds:
APP().systemTray.ConnectAction.doReconnect()
return result
def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile):
"""Append new item by factory."""
factory = ensureProfile(factory)
+76 -16
View File
@@ -335,6 +335,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
1
for profile in Storage.UserServers()
if profile.itemSubscription == unique
and profile.itemSubscriptionManaged
)
)
@@ -468,6 +469,7 @@ _TRANSLATABLE_HEADERS = [
class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
"""Represent user subs Qt table view."""
groupsChanged = QtCore.Signal()
RowHeight = 42
AutoUpdateOptions = {
@@ -557,6 +559,15 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
self.setDefaultDropAction(QtCore.Qt.DropAction.IgnoreAction)
contextMenuActions = [
AppQAction(
_('Move Up'),
callback=lambda: self.moveSelectedGroup(-1),
),
AppQAction(
_('Move Down'),
callback=lambda: self.moveSelectedGroup(1),
),
AppQSeperator(),
AppQAction(
_('Delete'),
callback=lambda: self.deleteSelectedItem(),
@@ -614,7 +625,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
deleteIndex,
)
Storage.UserSubs().pop(deleteUnique)
Storage.removeSubscriptionGroup(deleteUnique)
# Begin timer cleanup
qtimer = self.timers[deleteIndex]
@@ -642,7 +653,11 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
if callable(self.deleteUniqueCallback):
self.deleteUniqueCallback(deleteUnique)
for order, value in enumerate(Storage.UserSubs().values()):
value['sortOrder'] = order
self.flushAll()
self.groupsChanged.emit()
else:
# Do not delete
pass
@@ -668,6 +683,44 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
# Show the MessageBox asynchronously
mbox.open()
def moveSelectedGroup(self, offset: int):
"""Move one group while preserving its stable ID and timer."""
indexes = self.selectedIndex
if len(indexes) != 1 or offset not in (-1, 1):
return
source = indexes[0]
target = source + offset
items = list(Storage.UserSubs().items())
if target < 0 or target >= len(items):
return
self.sourceModel.layoutAboutToBeChanged.emit()
item = items.pop(source)
items.insert(target, item)
Storage.UserSubs().clear()
Storage.UserSubs().update(items)
timer = self.timers.pop(source)
timerConnected = self.timerConnected.pop(source)
self.timers.insert(target, timer)
self.timerConnected.insert(target, timerConnected)
for order, (unique, value) in enumerate(items):
value['sortOrder'] = order
self.sourceModel.layoutChanged.emit()
self.selectRow(target)
self.groupsChanged.emit()
def _configureAutoUpdate(self, row: int):
"""Configure one subscription timer independently of table columns."""
unique = list(Storage.UserSubs().keys())[row]
@@ -800,23 +853,26 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
kwargs.pop('lastUpdated', ''),
)
# The internal subs object
subsob = {
unique: {
'remark': remark,
'webURL': webURL,
'enabled': bool(enabled),
'autoupdate': autoupdate,
'proxy': proxy,
'userAgent': userAgent,
'filter': profileFilter,
'lastUpdated': lastUpdated,
}
}
group = Storage.SubscriptionGroup(unique) or SubscriptionGroup(
id=unique,
sortOrder=len(Storage.UserSubs()),
)
group.remark = remark
group.webURL = webURL
group.enabled = bool(enabled)
group.autoupdate = autoupdate
group.proxy = proxy
group.userAgent = userAgent
group.filter = profileFilter
group.lastUpdated = lastUpdated
subsob = {unique: group.toMapping()}
if unique in Storage.UserSubs():
row = list(Storage.UserSubs().keys()).index(unique)
Storage.UserSubs().update(subsob)
Storage.upsertSubscriptionGroup(group)
self.flushRow(row, subsob[unique])
else:
row = self.sourceModel.rowCount()
@@ -826,11 +882,15 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
self.timerConnected.append(False)
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
Storage.UserSubs().update(subsob)
Storage.upsertSubscriptionGroup(group)
self.sourceModel.endInsertRows()
self.flushRow(row, subsob[unique])
self.groupsChanged.emit()
@staticmethod
def updateSubsByUnique(
unique: str, httpProxy: Union[str, Callable, None], **kwargs
+50
View File
@@ -548,8 +548,11 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
)
self.searchButton = SearchButton()
self.subscriptionFilterComboBox = AppQComboBox()
self.subscriptionFilterComboBox.setMinimumWidth(180)
self.searchLayout.addWidget(self.searchLineEdit)
self.searchLayout.addWidget(self.subscriptionFilterComboBox)
self.searchLayout.addWidget(self.searchButton)
self._layout.addLayout(self.searchLayout)
@@ -558,11 +561,22 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
self.searchButton.clicked.connect(
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
)
self.searchLineEdit.returnPressed.connect(
lambda: self.userServersQTableWidget.search(self.searchLineEdit.text())
)
self.searchLineEdit.textChanged.connect(self.handleUserServersSearchTextChanged)
self.subscriptionFilterComboBox.currentIndexChanged.connect(
self.handleSubscriptionFilterChanged
)
self.userServersQTableWidget.subsManager.subscriptionsChanged.connect(
self.refreshSubscriptionFilter
)
self.refreshSubscriptionFilter()
self.setCentralWidget(self._widget)
@QtCore.Slot(str)
@@ -571,6 +585,42 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
if not text:
self.userServersQTableWidget.clearSearch()
@QtCore.Slot()
def refreshSubscriptionFilter(self):
"""Refresh the stable group selector without losing its selection."""
selected = self.subscriptionFilterComboBox.currentData()
with Mixins.QBlockSignalContext(self.subscriptionFilterComboBox):
self.subscriptionFilterComboBox.clear()
self.subscriptionFilterComboBox.addItem(_('All Profiles'), None)
self.subscriptionFilterComboBox.addItem(_('Manual Profiles'), '')
for group in Storage.SubscriptionGroups():
self.subscriptionFilterComboBox.addItem(
group.remark or group.webURL or group.id,
group.id,
)
index = self.subscriptionFilterComboBox.findData(selected)
self.subscriptionFilterComboBox.setCurrentIndex(max(0, index))
self.handleSubscriptionFilterChanged()
@QtCore.Slot()
def handleSubscriptionFilterChanged(self):
"""Apply the selected ownership group to the server table."""
self.userServersQTableWidget.filterBySubscription(
self.subscriptionFilterComboBox.currentData()
)
def showSubscriptionGroup(self, unique: str):
"""Select one group in the Home profile filter."""
index = self.subscriptionFilterComboBox.findData(unique)
if index >= 0:
self.subscriptionFilterComboBox.setCurrentIndex(index)
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
"""Update subs by unique."""
self.userServersQTableWidget.updateSubsByUnique(unique, httpProxy, **kwargs)
+36 -1
View File
@@ -213,6 +213,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self.editButton = AppQPushButton(_('Edit'))
self.deleteButton = AppQPushButton(_('Delete'))
self.copyURLButton = AppQPushButton(_('Copy URL'))
self.viewProfilesButton = AppQPushButton(_('View Profiles'))
self.updateSelectedButton = AppQPushButton(_('Update Selected'))
self.updateAllButton = AppQPushButton(_('Update All'))
@@ -221,6 +222,9 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
parent=self,
)
self.table.doubleClicked.connect(lambda _index: self.editSelected())
self.table.groupsChanged.connect(
self.serverTable.subsManager.subscriptionsChanged.emit
)
self.serverTable.subsManager.subscriptionsChanged.connect(self.table.flushAll)
@@ -228,6 +232,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self.editButton.clicked.connect(self.editSelected)
self.deleteButton.clicked.connect(self.table.deleteSelectedItem)
self.copyURLButton.clicked.connect(self.copySelectedURL)
self.viewProfilesButton.clicked.connect(self.viewSelectedProfiles)
self.updateSelectedButton.clicked.connect(self.updateSelected)
self.updateAllButton.clicked.connect(self.updateAll)
@@ -251,6 +256,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
actions.addWidget(self.editButton)
actions.addWidget(self.deleteButton)
actions.addWidget(self.copyURLButton)
actions.addWidget(self.viewProfilesButton)
actions.addStretch(1)
content = QWidget()
@@ -273,11 +279,19 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
indexes = [
index
for index, server in enumerate(Storage.UserServers())
if server.itemSubscription == unique
if server.itemSubscription == unique and server.itemSubscriptionManaged
]
self.serverTable.deleteItemByIndex(indexes, showProgress=False)
for server in Storage.UserServers():
if server.itemSubscription == unique:
server.metadata.subscriptionSource = ''
server.metadata.subscriptionManaged = False
server.metadata.subscriptionProfileKey = ''
self.serverTable.flushAll()
def _selectedUnique(self):
"""Return the first selected subscription ID, if any."""
selected = self.table.selectedUniques
@@ -389,6 +403,26 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
str(Storage.UserSubs()[unique].get('webURL', ''))
)
@QtCore.Slot()
def viewSelectedProfiles(self):
"""Open Home filtered to the selected subscription group."""
unique = self._selectedUnique()
if unique is None:
return
mainWindow = self.window()
homePage = getattr(mainWindow, 'homePage', None)
if homePage is not None:
homePage.showSubscriptionGroup(unique)
showPage = getattr(mainWindow, 'showPage', None)
if callable(showPage):
showPage('home')
def _selectedProxy(self):
"""Resolve the proxy policy selected for manual synchronization."""
key = self.proxyComboBox.currentData() or ''
@@ -445,6 +479,7 @@ class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
(self.editButton, 'pencil-square.svg'),
(self.deleteButton, 'trash.svg'),
(self.copyURLButton, 'link-45deg.svg'),
(self.viewProfilesButton, 'funnel.svg'),
(self.updateSelectedButton, 'arrow-repeat.svg'),
(self.updateAllButton, 'cloud-arrow-down.svg'),
):