Skip to content

Commit a64d2d6

Browse files
committed
Merge feature/parallel-sync into main (v1.6.1)
Sync parallèle de toutes les virtual libraries + barres de progression inline.
2 parents d477474 + bc18b36 commit a64d2d6

10 files changed

Lines changed: 568 additions & 404 deletions

File tree

docs/CONFIGURATION.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ Cliquer **"+ Add Connector"** et remplir :
6767
| URL du serveur | `http://192.168.1.200:8096` ou `https://media.example.com/emby` | URL complète avec port **et chemin de base** Emby (non requis pour Plex via plex.tv) |
6868
| Mode d'authentification | `User Credentials` | Voir section Authentication ci-dessous |
6969
| Mode de métadonnées | `Remote Sync` | Voir section Metadata Source ci-dessous |
70+
| Bibliothèques parallèles max | `4` | Nombre de bibliothèques synchées simultanément pour ce connecteur (Phase 1 uniquement) |
7071

7172
**Important — URL du serveur** : inclure le chemin de base si Emby est derrière un reverse proxy.
7273
Exemple : si Emby répond sur `https://media.example.com/emby/Items/...`, l'URL à configurer est `https://media.example.com/emby`.
@@ -103,7 +104,10 @@ Timeout proxy stream : 30 secondes
103104

104105
### Étape 6 : Lancer la première synchronisation
105106

106-
Cliquer **"Synchronise Now"** pour déclencher la première sync manuellement. La barre de progression avance bibliothèque par bibliothèque.
107+
Cliquer **"Synchronise Now"** pour déclencher la première sync manuellement. Toutes les bibliothèques activées sont synchronisées en parallèle. L'avancement est affiché en temps réel dans l'arbre des connecteurs :
108+
- **Barre bleue** : Phase 1 (génération des `.strm` / `.nfo` / artwork)
109+
- **Barre verte** : Phase 2 (injection des métadonnées Emby post-scan)
110+
- Les deux barres partagent la même échelle (total d'items en Phase 1)
107111

108112
Les dossiers virtuels Emby sont créés automatiquement par le plugin — aucune configuration manuelle dans le Dashboard Emby n'est nécessaire.
109113

docs/ROADMAP.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,17 @@
146146
- [ ] Backpropagation temps réel vers le serveur source (play/pause/stop/avancement) — issue #34
147147
- [ ] Détection delta / suppressions — issue #12
148148
- [ ] `JellyfinConnector` — issue #15
149+
150+
---
151+
152+
## Phase 6 — Sync parallèle & UI temps réel ✅ Terminé (v1.6.1)
153+
154+
- [x] **Sync 100 % parallèle** : toutes les bibliothèques de tous les connecteurs synchées simultanément (`Task.WhenAll`) — issues #30 et #35
155+
- [x] **Phase 2 autonome** : chaque bibliothèque enchaîne Phase 1 → Phase 2 de façon indépendante, sans attendre les autres
156+
- [x] **`MaxParallelLibraries`** : limite configurable par connecteur (défaut : 4), appliquée via `SemaphoreSlim` sur Phase 1 uniquement (Phase 2 = polling Emby, pas de charge réseau distante)
157+
- [x] **`SyncState`** redesigné : `ConcurrentDictionary<string, LibrarySyncEntry>` avec champs `Volatile.Read/Write` pour la thread-safety, statuts `Pending / RunningPhase1 / RunningPhase2 / Done / Failed`
158+
- [x] **Barres de progression inline** : double barre (Phase 1 bleue / Phase 2 verte) sur chaque ligne de l'arbre (bibliothèque, type, connecteur), affichage via polling HTTP `/virtuallib/sync/status` toutes les 2 s
159+
- [x] **Barre globale** dans le header "Remote Connectors", compteur à droite
160+
- [x] **Barres pleine largeur** : s'étirent sur tout l'espace disponible (`flex:1`) — même dénominateur (`p1Total`) pour les deux phases, évite les sauts de progression
161+
- [x] **Épaisseur par niveau** : connecteur 9 px / type 6 px / bibliothèque 4 px
162+
- [x] **Pistes bicolores** : fond clair (20 % opacité) indique le total, remplissage foncé indique l'avancement

docs/core/SYNC.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Synchronisation — SyncService & LibrarySyncJob
22

3+
## Orchestration parallèle (v1.6.1)
4+
5+
Toutes les bibliothèques activées de tous les connecteurs sont synchronisées **simultanément** (`Task.WhenAll`). Chaque bibliothèque est traitée de façon autonome et indépendante.
6+
7+
```
8+
SyncAll / LibrarySyncJob.Execute()
9+
10+
├── ConnectorA : SemaphoreSlim(MaxParallelLibraries=4)
11+
│ ├── LibA1 → Phase1 ──(sémaphore relâché)──► Phase2
12+
│ ├── LibA2 → Phase1 ──(sémaphore relâché)──► Phase2
13+
│ └── LibA3 → Phase1 ──(sémaphore relâché)──► Phase2
14+
15+
└── ConnectorB : SemaphoreSlim(MaxParallelLibraries=4)
16+
└── LibB1 → Phase1 ──(sémaphore relâché)──► Phase2
17+
```
18+
19+
- Le `SemaphoreSlim` limite la Phase 1 (appels réseau distants) mais **pas** la Phase 2 (polling Emby local).
20+
- `SyncState` (ConcurrentDictionary) centralise les compteurs Phase1/Phase2 de chaque bibliothèque pour le polling UI.
21+
- Statuts : `Pending → RunningPhase1 → RunningPhase2 → Done | Failed`
22+
23+
---
24+
325
## Algorithme en deux phases
426

527
```

src/VirtualLib/Api/ConfigController.cs

Lines changed: 143 additions & 165 deletions
Large diffs are not rendered by default.

src/VirtualLib/Core/LibrarySyncJob.cs

Lines changed: 103 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@
77
namespace VirtualLib;
88

99
/// <summary>
10-
/// Emby scheduled task — runs a full sync of all enabled connectors
10+
/// Emby scheduled task — runs a full parallel sync of all enabled connectors
1111
/// on the interval configured in the plugin settings.
1212
/// </summary>
1313
public sealed class LibrarySyncJob : IScheduledTask, IConfigurableScheduledTask
1414
{
1515
private readonly SyncService _syncService;
1616
private readonly ILibraryManager _libraryManager;
17+
private readonly ILibraryMonitor _libraryMonitor;
1718

18-
public LibrarySyncJob(ILibraryManager libraryManager, IItemRepository itemRepository, IUserDataManager userDataManager, IUserManager userManager)
19+
public LibrarySyncJob(ILibraryManager libraryManager, ILibraryMonitor libraryMonitor, IItemRepository itemRepository, IUserDataManager userDataManager, IUserManager userManager)
1920
{
2021
_libraryManager = libraryManager;
22+
_libraryMonitor = libraryMonitor;
2123
_syncService = new SyncService(
2224
new ConnectorFactory(new DefaultHttpClientFactory(), NullLoggerFactory.Instance),
2325
new StrmGenerator(),
@@ -57,49 +59,47 @@ public async Task Execute(CancellationToken cancellationToken, IProgress<double>
5759
var enabledConnectors = config.Connectors.Where(c => c.Enabled).ToList();
5860
if (enabledConnectors.Count == 0) return;
5961

60-
var virtualLibRoot = config.VirtualLibraryRootPath;
61-
var proxyBaseUrl = config.ProxyBaseUrl;
62+
var root = config.VirtualLibraryRootPath;
63+
var proxyUrl = config.ProxyBaseUrl;
6264

63-
int totalCreated = 0;
64-
double step = 100.0 / enabledConnectors.Count;
65-
var allPending = new List<LibraryPendingMetadata>();
66-
67-
for (var i = 0; i < enabledConnectors.Count; i++)
65+
// Register all libraries (allows polling the config page during scheduled sync)
66+
if (!SyncState.TryStart()) return; // another sync already running
67+
foreach (var conn in enabledConnectors)
68+
foreach (var libId in conn.LibraryIds)
6869
{
69-
cancellationToken.ThrowIfCancellationRequested();
70+
var known = conn.KnownLibraries.FirstOrDefault(l => l.Id == libId);
71+
SyncState.RegisterLibrary(conn.Id, conn.DisplayName, libId,
72+
known?.Name ?? libId, known?.Type ?? string.Empty);
73+
}
7074

71-
var connectorConfig = enabledConnectors[i];
75+
int totalLibs = enabledConnectors.Sum(c => c.LibraryIds.Count);
76+
int doneLibs = 0;
7277

73-
var syncProgress = new Progress<SyncProgress>(p =>
74-
{
75-
var pct = (i * step) + (step * p.Current / Math.Max(p.Total, 1));
76-
progress.Report(pct);
77-
});
78-
79-
var (result, pending) = await _syncService.SyncConnectorAsync(
80-
connectorConfig,
81-
virtualLibRoot,
82-
proxyBaseUrl,
83-
syncProgress,
84-
cancellationToken);
85-
86-
totalCreated += result.ItemsCreated;
87-
allPending.AddRange(pending);
88-
progress.Report((i + 1) * step);
89-
}
78+
var results = new System.Collections.Concurrent.ConcurrentBag<SyncResult>();
79+
var semaphores = enabledConnectors.ToDictionary(
80+
c => c.Id,
81+
c => new SemaphoreSlim(Math.Max(1, c.MaxParallelLibraries)));
9082

91-
if (totalCreated > 0)
92-
{
93-
_libraryManager.QueueLibraryScan();
94-
// Fire-and-forget metadata push (phase 2) for the scheduled task
95-
var snapshot = allPending;
96-
_ = Task.Run(async () =>
83+
var syncSvc = _syncService;
84+
var libMon = _libraryMonitor;
85+
86+
var allTasks = enabledConnectors
87+
.SelectMany(conn => conn.LibraryIds.Select(libId => Task.Run(async () =>
9788
{
98-
foreach (var g in snapshot)
99-
await _syncService.PushMetadataAsync(g.Items, g.LibraryName, null, CancellationToken.None);
100-
});
101-
}
89+
await SyncLibraryAutonomousAsync(conn, libId, root, proxyUrl, syncSvc, libMon,
90+
semaphores[conn.Id], results, cancellationToken);
91+
92+
var done = Interlocked.Increment(ref doneLibs);
93+
progress.Report(done * 100.0 / Math.Max(totalLibs, 1));
94+
}, cancellationToken)))
95+
.ToList();
96+
97+
await Task.WhenAll(allTasks);
10298

99+
config.Connectors = Plugin.Instance?.Configuration.Connectors ?? config.Connectors;
100+
Plugin.Instance?.SaveConfiguration();
101+
102+
SyncState.Finish(results.ToList());
103103
progress.Report(100);
104104
}
105105

@@ -110,4 +110,69 @@ public async Task Execute(CancellationToken cancellationToken, IProgress<double>
110110
public bool IsHidden => false;
111111
public bool IsEnabled => true;
112112
public bool IsLogged => true;
113+
114+
// -------------------------------------------------------------------------
115+
// Private — mirrors ConfigController.SyncLibraryAutonomousAsync
116+
// -------------------------------------------------------------------------
117+
118+
private static async Task SyncLibraryAutonomousAsync(
119+
ConnectorConfig conn,
120+
string libraryId,
121+
string root,
122+
string proxyUrl,
123+
SyncService syncSvc,
124+
ILibraryMonitor libMon,
125+
SemaphoreSlim semaphore,
126+
System.Collections.Concurrent.ConcurrentBag<SyncResult> results,
127+
CancellationToken ct)
128+
{
129+
await semaphore.WaitAsync(ct);
130+
try
131+
{
132+
var prog1 = new Progress<SyncProgress>(p =>
133+
SyncState.UpdatePhase1(conn.Id, libraryId, p.Current, p.Total));
134+
135+
var (result, pending) = await syncSvc.SyncLibraryAsync(
136+
conn, libraryId, root, proxyUrl, prog1, ct);
137+
138+
results.Add(result);
139+
140+
var lp = pending.FirstOrDefault();
141+
if (lp is not null && Directory.Exists(lp.LibraryFolderPath))
142+
libMon.ReportFileSystemChanged(lp.LibraryFolderPath);
143+
144+
if (result.Success)
145+
{
146+
var total = result.ItemsCreated + result.ItemsSkipped + result.ItemsFailed;
147+
SyncState.UpdatePhase1(conn.Id, libraryId, total, total);
148+
}
149+
150+
semaphore.Release();
151+
152+
if (lp is not null && lp.Items.Count > 0)
153+
{
154+
SyncState.Phase2Start(conn.Id, libraryId, lp.Items.Count);
155+
await Task.Delay(5_000, ct);
156+
157+
var prog2 = new Progress<SyncProgress>(p =>
158+
SyncState.UpdatePhase2(conn.Id, libraryId, p.Current, p.Total));
159+
160+
await syncSvc.PushMetadataAsync(lp.Items, lp.LibraryName, prog2, ct);
161+
}
162+
163+
SyncState.MarkDone(conn.Id, libraryId);
164+
}
165+
catch (OperationCanceledException)
166+
{
167+
SyncState.MarkFailed(conn.Id, libraryId, "Cancelled");
168+
try { semaphore.Release(); } catch { /* already released */ }
169+
throw;
170+
}
171+
catch (Exception ex)
172+
{
173+
SyncState.MarkFailed(conn.Id, libraryId, ex.Message);
174+
results.Add(SyncResult.Failure(conn.DisplayName, ex.Message, TimeSpan.Zero));
175+
try { semaphore.Release(); } catch { /* already released */ }
176+
}
177+
}
113178
}

0 commit comments

Comments
 (0)