Skip to content

Commit a2fc00c

Browse files
authored
Merge pull request #21 from CCoupel/feature/issue-13-full-metadata
feat: v1.1.0 — Full metadata sync, MetadataMode, RemoteSyncFull, progress UI
2 parents 3d4016c + 3d4d060 commit a2fc00c

14 files changed

Lines changed: 516 additions & 76 deletions

docs/CONFIGURATION.md

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,23 +48,39 @@ Ce répertoire sera créé automatiquement par le plugin. Il doit être accessib
4848

4949
### Étape 3 : Ajouter un serveur source
5050

51-
Cliquer **"Ajouter un serveur"** et remplir :
51+
Cliquer **"+ Add Connector"** et remplir :
5252

5353
| Champ | Exemple | Description |
5454
|---|---|---|
5555
| Nom d'affichage | `Serveur B - Bureau` | Nom libre pour identifier la source |
5656
| Type de serveur | `Emby` | Emby / Jellyfin / Plex |
5757
| 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 |
58-
| Clé API | `abc123def456...` | Clé API du serveur source |
58+
| Mode d'authentification | `User Credentials` | Voir section Authentication ci-dessous |
59+
| Mode de métadonnées | `Remote Sync` | Voir section Metadata Source ci-dessous |
5960

6061
**Important — URL du serveur** : inclure le chemin de base si Emby est derrière un reverse proxy.
6162
Exemple : si Emby répond sur `https://media.example.com/emby/Items/...`, l'URL à configurer est `https://media.example.com/emby`.
6263

63-
Cliquer **"Tester la connexion"** pour valider avant de sauvegarder.
64+
Cliquer **"Test Connection"** pour valider avant de sauvegarder.
65+
66+
#### Authentification
67+
68+
| Mode | Description |
69+
|---|---|
70+
| **API Key** | Accès admin complet, aucun suivi d'usage sur le serveur distant |
71+
| **User Credentials** | Le serveur distant voit les sessions de cet utilisateur, applique ses restrictions et enregistre l'historique de lecture |
72+
73+
#### Metadata Source
74+
75+
| Mode | Description |
76+
|---|---|
77+
| **Remote Sync** | Le plugin télécharge métadonnées et images depuis le serveur distant. Items avec `.nfo` existant ignorés (incrémental). |
78+
| **Remote Sync Full** | Identique à Remote Sync mais réécrit tous les `.nfo` et re-télécharge les images à chaque sync. |
79+
| **Local Scraping** | Seuls les `.strm` sont créés. Emby utilise ses propres scrapers (TMDB, TVDB, FanArt) pour enrichir la bibliothèque. Nécessite un accès internet. |
6480

6581
### Étape 4 : Sélectionner les bibliothèques à synchroniser
6682

67-
Après un test de connexion réussi, la liste des bibliothèques disponibles sur le serveur source s'affiche. Cocher celles à inclure.
83+
Après un test de connexion réussi, la liste des bibliothèques disponibles s'affiche. Cocher celles à inclure — les dossiers virtuels Emby sont créés automatiquement.
6884

6985
### Étape 5 : Configurer la synchronisation automatique
7086

@@ -75,17 +91,11 @@ Timeout proxy stream : 30 secondes
7591

7692
### Étape 6 : Lancer la première synchronisation
7793

78-
Cliquer **"Synchroniser maintenant"** pour déclencher la première sync manuellement.
94+
Cliquer **"Synchronise Now"** pour déclencher la première sync manuellement. La barre de progression avance bibliothèque par bibliothèque.
7995

80-
### Étape 7 : Ajouter la bibliothèque virtuelle dans Emby
96+
Les dossiers virtuels Emby sont créés automatiquement par le plugin — aucune configuration manuelle dans le Dashboard Emby n'est nécessaire.
8197

82-
Après la sync :
83-
1. Dashboard → Bibliothèques → Ajouter une bibliothèque
84-
2. Type : Films (ou Séries selon le contenu)
85-
3. Dossier : `/media/virtual-libraries/Films_ServeurB`
86-
4. Valider
87-
88-
Les médias du serveur B apparaissent maintenant dans l'interface d'Emby A.
98+
Les médias du serveur distant apparaissent dans l'interface d'Emby après le scan de bibliothèque déclenché automatiquement en fin de sync.
8999

90100
---
91101

@@ -235,9 +245,15 @@ Normal : la terminaison TLS est faite par le reverse proxy (traefik). Le serveur
235245

236246
### La sync ne détecte pas les nouveaux ajouts
237247

238-
- La sync delta compare les IDs distants avec l'index local (`.index/{connectorId}.json`)
239-
- Si l'index est corrompu, le supprimer pour forcer une re-sync complète
240-
- Vérifier l'intervalle de sync configuré
248+
- Les items ajoutés sur le serveur distant sont détectés automatiquement à la prochaine sync (aucun `.nfo` local → créé)
249+
- Vérifier l'intervalle de sync configuré (défaut : 6h)
250+
- Déclencher une sync manuelle depuis le dashboard plugin
251+
252+
### Les métadonnées sont incomplètes ou manquantes
253+
254+
- Vérifier que le mode **Remote Sync** ou **Remote Sync Full** est sélectionné (pas Local Scraping)
255+
- Si les métadonnées ne se mettent pas à jour malgré une sync : passer en mode **Remote Sync Full** pour forcer la réécriture des `.nfo`
256+
- Les items dont la récupération de métadonnées a échoué sont comptés "failed" et retentés à la prochaine sync
241257

242258
---
243259

docs/ROADMAP.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,26 @@
2525

2626
---
2727

28-
## Phase 2a — Synchronisation des métadonnées *(en cours)*
28+
## Phase 2a — Synchronisation des métadonnées ✅ Terminé (v1.1.0)
2929

3030
- [x] `SyncService` : orchestration sync par connector + bibliothèque
31-
- [x] Génération `.strm` + `.nfo` + téléchargement artwork
32-
- [x] Skip intelligent : toujours régénérer le `.strm` (cheap), ne sauter que si `.nfo` déjà présent
33-
- [ ] `LibrarySyncJob` : tâche planifiée (`IScheduledTask`) configurable
34-
- [ ] Détection delta : index JSON local `{connectorId}.json`
31+
- [x] Génération `.strm` + `.nfo` + téléchargement artwork (poster, fanart, landscape, logo)
32+
- [x] Métadonnées complètes : cast, directors, writers, tagline, trailer URL
33+
- [x] Skip intelligent : toujours régénérer le `.strm`, ne sauter le `.nfo` qu'en mode `RemoteSync`
34+
- [x] `MetadataMode` par connecteur : `RemoteSync` (incrémental) / `RemoteSyncFull` (force) / `LocalScraping`
35+
- [x] `LibraryOptions` Emby appliquées selon le mode (fetchers TMDB/TVDB/FanArt, cache, chapitres)
36+
- [x] `LibrarySyncJob` : tâche planifiée (`IScheduledTask`) avec intervalle configurable
37+
- [x] Mise à jour dynamique du trigger sans redémarrage
38+
- [x] `QueueLibraryScan()` déclenché si des items ont été créés
39+
- [x] Compteurs d'items distants par bibliothèque (endpoint `/item-counts`)
40+
- [x] Fix `Users/Me` 500 : `GetUserIdAsync` sans appel à `/Users/Me`
41+
- [x] Progression sync par librairie dans l'UI (itération client-side)
42+
43+
**Reste en backlog :**
44+
- [ ] Détection delta : index JSON local `{connectorId}.json` (issue #12)
3545
- [ ] Gestion des suppressions (items supprimés sur la source)
36-
- [ ] Logs de sync dans le dashboard
37-
- [ ] Tests intégration sync job
46+
- [ ] Tests intégration sync job (issue #14)
47+
- [ ] Progression sync par item en temps réel (issue #20)
3848

3949
---
4050

src/VirtualLib/Api/ConfigController.cs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using MediaBrowser.Controller.Library;
44
using MediaBrowser.Controller.Net;
55
using MediaBrowser.Model.Services;
6+
using MediaBrowser.Model.Tasks;
67
using Microsoft.Extensions.Logging.Abstractions;
78
using VirtualLib.Core;
89
using VirtualLib.Core.Models;
@@ -28,6 +29,7 @@ public sealed class CreateConnector : IReturn<ConnectorConfig>
2829
public string ApiKey { get; set; } = string.Empty;
2930
public string Username { get; set; } = string.Empty;
3031
public string Password { get; set; } = string.Empty;
32+
public MetadataMode MetadataMode { get; set; } = MetadataMode.RemoteSync;
3133
public List<string> LibraryIds { get; set; } = new();
3234
public bool Enabled { get; set; } = true;
3335
}
@@ -44,6 +46,7 @@ public sealed class UpdateConnector : IReturn<ConnectorConfig>
4446
public string ApiKey { get; set; } = string.Empty;
4547
public string Username { get; set; } = string.Empty;
4648
public string Password { get; set; } = string.Empty;
49+
public MetadataMode MetadataMode { get; set; } = MetadataMode.RemoteSync;
4750
public List<string> LibraryIds { get; set; } = new();
4851
public bool Enabled { get; set; } = true;
4952
}
@@ -172,9 +175,13 @@ public sealed class ConfigController : BaseApiService
172175
NullLogger<SyncService>.Instance));
173176

174177
private readonly LibraryProvisioner _libraryProvisioner;
178+
private readonly ILibraryManager _libraryManager;
179+
private readonly ITaskManager _taskManager;
175180

176-
public ConfigController(ILibraryManager libraryManager)
181+
public ConfigController(ILibraryManager libraryManager, ITaskManager taskManager)
177182
{
183+
_libraryManager = libraryManager;
184+
_taskManager = taskManager;
178185
_libraryProvisioner = new LibraryProvisioner(libraryManager, NullLogger<LibraryProvisioner>.Instance);
179186
}
180187

@@ -257,6 +264,7 @@ public object Post(CreateConnector request)
257264
ApiKey = request.ApiKey,
258265
Username = request.Username,
259266
Password = request.Password,
267+
MetadataMode = request.MetadataMode,
260268
LibraryIds = request.LibraryIds,
261269
Enabled = request.Enabled
262270
};
@@ -291,6 +299,7 @@ public object Put(UpdateConnector request)
291299
Username = request.Username,
292300
// Preserve existing password if the client sent an empty string (placeholder pattern)
293301
Password = string.IsNullOrEmpty(request.Password) ? existing.Password : request.Password,
302+
MetadataMode = request.MetadataMode,
294303
LibraryIds = request.LibraryIds,
295304
Enabled = request.Enabled,
296305
KnownLibraries = existing.KnownLibraries
@@ -307,7 +316,7 @@ public object Put(UpdateConnector request)
307316
var removedIds = existing.LibraryIds.Except(request.LibraryIds).ToList();
308317

309318
foreach (var lib in updated.KnownLibraries.Where(l => addedIds.Contains(l.Id)))
310-
_libraryProvisioner.EnsureVirtualFolder(updated.DisplayName, lib.Name, lib.Type, virtualLibRoot);
319+
_libraryProvisioner.EnsureVirtualFolder(updated.DisplayName, lib.Name, lib.Type, virtualLibRoot, updated.MetadataMode);
311320

312321
foreach (var lib in existing.KnownLibraries.Where(l => removedIds.Contains(l.Id)))
313322
_libraryProvisioner.RemoveVirtualFolder(existing.DisplayName, lib.Name);
@@ -362,6 +371,21 @@ public object Put(SaveSettings request)
362371
config.ProxyTimeoutSeconds = request.ProxyTimeoutSeconds;
363372
Plugin.Instance.SaveConfiguration();
364373

374+
// Update the scheduled task trigger to reflect the new interval
375+
var worker = _taskManager.ScheduledTasks
376+
.FirstOrDefault(t => t.ScheduledTask is LibrarySyncJob);
377+
if (worker != null)
378+
{
379+
worker.Triggers = new[]
380+
{
381+
new TaskTriggerInfo
382+
{
383+
Type = TaskTriggerInfo.TriggerInterval,
384+
IntervalTicks = TimeSpan.FromHours(config.SyncIntervalHours).Ticks
385+
}
386+
};
387+
}
388+
365389
return ResultFactory.GetResult(Request, new GlobalSettings
366390
{
367391
VirtualLibraryRootPath = config.VirtualLibraryRootPath,
@@ -467,6 +491,9 @@ public object Post(SyncLibrary request)
467491
progress: null,
468492
CancellationToken.None).GetAwaiter().GetResult();
469493

494+
if (result.Success && result.ItemsCreated > 0)
495+
_libraryManager.QueueLibraryScan();
496+
470497
return ResultFactory.GetResult(Request, result, NoHeaders);
471498
}
472499

@@ -556,7 +583,8 @@ public object Get(GetConnectorLibraries request)
556583
if (!string.IsNullOrEmpty(virtualLibRoot))
557584
{
558585
foreach (var lib in connectorConfig.KnownLibraries)
559-
_libraryProvisioner.EnsureVirtualFolder(connectorConfig.DisplayName, lib.Name, lib.Type, virtualLibRoot);
586+
_libraryProvisioner.EnsureVirtualFolder(
587+
connectorConfig.DisplayName, lib.Name, lib.Type, virtualLibRoot, connectorConfig.MetadataMode);
560588
}
561589

562590
return ResultFactory.GetResult(Request, libList, NoHeaders);
@@ -587,6 +615,9 @@ public object Post(SyncAll request)
587615
results.Add(result);
588616
}
589617

618+
if (results.Any(r => r.Success && r.ItemsCreated > 0))
619+
_libraryManager.QueueLibraryScan();
620+
590621
return ResultFactory.GetResult(Request, results, NoHeaders);
591622
}
592623

@@ -611,6 +642,9 @@ public object Post(SyncConnector request)
611642
progress: null,
612643
CancellationToken.None).GetAwaiter().GetResult();
613644

645+
if (result.Success && result.ItemsCreated > 0)
646+
_libraryManager.QueueLibraryScan();
647+
614648
return ResultFactory.GetResult(Request, result, NoHeaders);
615649
}
616650

@@ -626,6 +660,7 @@ private void ProvisionEnabledLibraries(ConnectorConfig connectorConfig, string v
626660
.Where(l => connectorConfig.LibraryIds.Contains(l.Id));
627661

628662
foreach (var lib in enabledLibs)
629-
_libraryProvisioner.EnsureVirtualFolder(connectorConfig.DisplayName, lib.Name, lib.Type, virtualLibRoot);
663+
_libraryProvisioner.EnsureVirtualFolder(
664+
connectorConfig.DisplayName, lib.Name, lib.Type, virtualLibRoot, connectorConfig.MetadataMode);
630665
}
631666
}

src/VirtualLib/Connectors/EmbyConnector.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using VirtualLib.Connectors.Internal;
55
using VirtualLib.Core;
66
using VirtualLib.Core.Models;
7+
using PersonInfo = VirtualLib.Core.Models.PersonInfo;
78

89
namespace VirtualLib.Connectors;
910

@@ -286,7 +287,10 @@ public async Task<MediaMetadata> GetMetadataAsync(
286287
string itemId,
287288
CancellationToken cancellationToken = default)
288289
{
289-
var url = $"Items/{itemId}?Fields=Overview,Genres,Studios,ProviderIds,People,Tags";
290+
var userId = await GetUserIdAsync(cancellationToken);
291+
var url = userId is not null
292+
? $"Users/{userId}/Items/{itemId}?Fields=Overview,Genres,Studios,ProviderIds,People,Tags,RemoteTrailers,Taglines"
293+
: $"Items/{itemId}?Fields=Overview,Genres,Studios,ProviderIds,People,Tags,RemoteTrailers,Taglines";
290294
using var response = await GetWithRetryAsync(url, cancellationToken);
291295
response.EnsureSuccessStatusCode();
292296

@@ -488,7 +492,21 @@ private MediaMetadata MapMetadata(EmbyItem item)
488492
Genres = item.Genres?.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>(),
489493
Studios = item.Studios?.Select(s => s.Name).ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>(),
490494
Tags = item.Tags?.AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>(),
491-
OfficialRating = item.OfficialRating
495+
OfficialRating = item.OfficialRating,
496+
Cast = item.People?
497+
.Where(p => string.Equals(p.Type, "Actor", StringComparison.OrdinalIgnoreCase))
498+
.Select(p => new PersonInfo { Name = p.Name, Role = p.Role })
499+
.ToList() ?? (IReadOnlyList<PersonInfo>)Array.Empty<PersonInfo>(),
500+
Directors = item.People?
501+
.Where(p => string.Equals(p.Type, "Director", StringComparison.OrdinalIgnoreCase))
502+
.Select(p => p.Name)
503+
.ToList() ?? (IReadOnlyList<string>)Array.Empty<string>(),
504+
Writers = item.People?
505+
.Where(p => string.Equals(p.Type, "Writer", StringComparison.OrdinalIgnoreCase))
506+
.Select(p => p.Name)
507+
.ToList() ?? (IReadOnlyList<string>)Array.Empty<string>(),
508+
Tagline = item.Taglines?.FirstOrDefault(),
509+
TrailerUrl = item.RemoteTrailers?.FirstOrDefault()?.Url
492510
};
493511
}
494512

src/VirtualLib/Connectors/Internal/EmbyApiModels.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ internal sealed class EmbyItem
9393

9494
[JsonPropertyName("BackdropImageTags")]
9595
public List<string>? BackdropImageTags { get; init; }
96+
97+
[JsonPropertyName("People")]
98+
public List<EmbyPerson>? People { get; init; }
99+
100+
[JsonPropertyName("RemoteTrailers")]
101+
public List<EmbyRemoteTrailer>? RemoteTrailers { get; init; }
102+
103+
[JsonPropertyName("Taglines")]
104+
public List<string>? Taglines { get; init; }
96105
}
97106

98107
internal sealed class EmbyStudio
@@ -101,6 +110,24 @@ internal sealed class EmbyStudio
101110
public string Name { get; init; } = string.Empty;
102111
}
103112

113+
internal sealed class EmbyPerson
114+
{
115+
[JsonPropertyName("Name")]
116+
public string Name { get; init; } = string.Empty;
117+
118+
[JsonPropertyName("Role")]
119+
public string? Role { get; init; }
120+
121+
[JsonPropertyName("Type")]
122+
public string? Type { get; init; }
123+
}
124+
125+
internal sealed class EmbyRemoteTrailer
126+
{
127+
[JsonPropertyName("Url")]
128+
public string? Url { get; init; }
129+
}
130+
104131
internal sealed class EmbyUser
105132
{
106133
[JsonPropertyName("Id")]

0 commit comments

Comments
 (0)