273 lines
6.9 KiB
Dart
273 lines
6.9 KiB
Dart
|
|
|
|
library;
|
|
|
|
import '../../core/contracts/library.dart';
|
|
import '../../shared/utils/time_bucket.dart';
|
|
|
|
|
|
enum GroupAxis { watchedTime, addedTime, mediaType }
|
|
|
|
|
|
class AggregatedEntrySource {
|
|
final EmbyRawItem item;
|
|
final GlobalSearchServerResult source;
|
|
|
|
const AggregatedEntrySource({required this.item, required this.source});
|
|
|
|
|
|
DateTime? dateFor(GroupAxis axis) {
|
|
switch (axis) {
|
|
case GroupAxis.watchedTime:
|
|
final userData = item.extra['UserData'];
|
|
return parseEmbyDate(
|
|
userData is Map ? userData['LastPlayedDate'] : null,
|
|
);
|
|
case GroupAxis.addedTime:
|
|
return parseEmbyDate(item.extra['DateCreated']);
|
|
case GroupAxis.mediaType:
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class AggregatedEntry {
|
|
final EmbyRawItem item;
|
|
final GlobalSearchServerResult source;
|
|
final List<AggregatedEntrySource> sources;
|
|
|
|
AggregatedEntry({
|
|
required this.item,
|
|
required this.source,
|
|
List<AggregatedEntrySource>? sources,
|
|
}) : assert(sources == null || sources.isNotEmpty),
|
|
sources = List.unmodifiable(
|
|
sources ?? [AggregatedEntrySource(item: item, source: source)],
|
|
);
|
|
|
|
|
|
DateTime? dateFor(GroupAxis axis) {
|
|
return sources.first.dateFor(axis);
|
|
}
|
|
}
|
|
|
|
|
|
class AggregatedGroup {
|
|
final String key;
|
|
final String label;
|
|
final int order;
|
|
final List<AggregatedEntry> entries;
|
|
|
|
const AggregatedGroup({
|
|
required this.key,
|
|
required this.label,
|
|
required this.order,
|
|
required this.entries,
|
|
});
|
|
}
|
|
|
|
|
|
List<AggregatedEntry> flatten(
|
|
List<GlobalSearchServerResult> results, {
|
|
String? serverFilter,
|
|
bool deduplicateAcrossServers = false,
|
|
}) {
|
|
final out = <AggregatedEntry>[];
|
|
for (final sr in results) {
|
|
if (serverFilter != null && sr.serverId != serverFilter) continue;
|
|
for (final item in sr.items) {
|
|
out.add(AggregatedEntry(item: item, source: sr));
|
|
}
|
|
}
|
|
if (deduplicateAcrossServers && serverFilter == null && out.length > 1) {
|
|
return _deduplicateEntries(out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const _typeOrder = <String, ({String label, int order})>{
|
|
'Movie': (label: '电影', order: 0),
|
|
'Series': (label: '剧集', order: 1),
|
|
'Episode': (label: '单集', order: 2),
|
|
};
|
|
const _typeOther = (label: '其它', order: 3);
|
|
|
|
|
|
List<AggregatedGroup> groupEntries(
|
|
List<AggregatedEntry> entries,
|
|
GroupAxis axis,
|
|
DateTime now,
|
|
) {
|
|
if (axis == GroupAxis.mediaType) {
|
|
return _groupByType(entries);
|
|
}
|
|
return _groupByTime(entries, axis, now);
|
|
}
|
|
|
|
List<AggregatedGroup> _groupByTime(
|
|
List<AggregatedEntry> entries,
|
|
GroupAxis axis,
|
|
DateTime now,
|
|
) {
|
|
final scheme = axis == GroupAxis.watchedTime
|
|
? TimeBucketScheme.watched
|
|
: TimeBucketScheme.added;
|
|
final buckets =
|
|
<String, ({TimeBucket bucket, List<AggregatedEntry> items})>{};
|
|
|
|
for (final e in entries) {
|
|
final b = bucketFor(e.dateFor(axis), now, scheme);
|
|
(buckets[b.key] ??= (bucket: b, items: [])).items.add(e);
|
|
}
|
|
|
|
final groups = buckets.values.map((v) {
|
|
final items = [...v.items]
|
|
..sort((a, b) {
|
|
final da = a.dateFor(axis);
|
|
final db = b.dateFor(axis);
|
|
if (da == null && db == null) return 0;
|
|
if (da == null) return 1;
|
|
if (db == null) return -1;
|
|
return db.compareTo(da);
|
|
});
|
|
return AggregatedGroup(
|
|
key: v.bucket.key,
|
|
label: v.bucket.label,
|
|
order: v.bucket.order,
|
|
entries: items,
|
|
);
|
|
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
|
return groups;
|
|
}
|
|
|
|
List<AggregatedGroup> _groupByType(List<AggregatedEntry> entries) {
|
|
final groups =
|
|
<String, ({String label, int order, List<AggregatedEntry> items})>{};
|
|
|
|
for (final e in entries) {
|
|
final meta = _typeOrder[e.item.Type] ?? _typeOther;
|
|
final key = e.item.Type ?? 'Other';
|
|
(groups[key] ??= (
|
|
label: meta.label,
|
|
order: meta.order,
|
|
items: [],
|
|
)).items.add(e);
|
|
}
|
|
|
|
final out = groups.entries.map((kv) {
|
|
final items = [...kv.value.items]
|
|
..sort((a, b) => (a.item.Name).compareTo(b.item.Name));
|
|
return AggregatedGroup(
|
|
key: kv.key,
|
|
label: kv.value.label,
|
|
order: kv.value.order,
|
|
entries: items,
|
|
);
|
|
}).toList()..sort((a, b) => a.order.compareTo(b.order));
|
|
return out;
|
|
}
|
|
|
|
Set<String> _candidateKeys(EmbyRawItem item) {
|
|
final type = item.Type;
|
|
if (type == null || type.isEmpty) return const {};
|
|
final ids = normalizedProviderIdsOfItem(item);
|
|
if (ids.isEmpty) return const {};
|
|
return {for (final e in ids.entries) '$type:${e.key}:${e.value}'};
|
|
}
|
|
|
|
List<AggregatedEntry> _deduplicateEntries(List<AggregatedEntry> entries) {
|
|
final uf = _UnionFind(entries.length);
|
|
final keyToIndex = <String, int>{};
|
|
|
|
for (var i = 0; i < entries.length; i++) {
|
|
final keys = _candidateKeys(entries[i].item);
|
|
for (final k in keys) {
|
|
final prev = keyToIndex[k];
|
|
if (prev != null) {
|
|
uf.union(prev, i);
|
|
} else {
|
|
keyToIndex[k] = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
final groups = uf.groups();
|
|
final survivors = <AggregatedEntry>[];
|
|
for (final group in groups) {
|
|
final orderedIndices = [...group]
|
|
..sort((leftIndex, rightIndex) {
|
|
final leftDate = _lastPlayedDate(entries[leftIndex]);
|
|
final rightDate = _lastPlayedDate(entries[rightIndex]);
|
|
if (leftDate == null && rightDate == null) {
|
|
return leftIndex.compareTo(rightIndex);
|
|
}
|
|
if (leftDate == null) return 1;
|
|
if (rightDate == null) return -1;
|
|
|
|
final dateComparison = rightDate.compareTo(leftDate);
|
|
return dateComparison != 0
|
|
? dateComparison
|
|
: leftIndex.compareTo(rightIndex);
|
|
});
|
|
|
|
final sourceServerIds = <String>{};
|
|
final sources = <AggregatedEntrySource>[];
|
|
for (final entryIndex in orderedIndices) {
|
|
final entry = entries[entryIndex];
|
|
if (!sourceServerIds.add(entry.source.serverId)) continue;
|
|
sources.add(
|
|
AggregatedEntrySource(item: entry.item, source: entry.source),
|
|
);
|
|
}
|
|
|
|
final primarySource = sources.first;
|
|
survivors.add(
|
|
AggregatedEntry(
|
|
item: primarySource.item,
|
|
source: primarySource.source,
|
|
sources: sources,
|
|
),
|
|
);
|
|
}
|
|
return survivors;
|
|
}
|
|
|
|
DateTime? _lastPlayedDate(AggregatedEntry e) =>
|
|
e.dateFor(GroupAxis.watchedTime);
|
|
|
|
class _UnionFind {
|
|
final List<int> _parent;
|
|
final List<int> _rank;
|
|
|
|
_UnionFind(int n)
|
|
: _parent = List.generate(n, (i) => i),
|
|
_rank = List.filled(n, 0);
|
|
|
|
int find(int x) {
|
|
if (_parent[x] != x) _parent[x] = find(_parent[x]);
|
|
return _parent[x];
|
|
}
|
|
|
|
void union(int a, int b) {
|
|
final ra = find(a), rb = find(b);
|
|
if (ra == rb) return;
|
|
if (_rank[ra] < _rank[rb]) {
|
|
_parent[ra] = rb;
|
|
} else if (_rank[ra] > _rank[rb]) {
|
|
_parent[rb] = ra;
|
|
} else {
|
|
_parent[rb] = ra;
|
|
_rank[ra]++;
|
|
}
|
|
}
|
|
|
|
List<List<int>> groups() {
|
|
final map = <int, List<int>>{};
|
|
for (var i = 0; i < _parent.length; i++) {
|
|
(map[find(i)] ??= []).add(i);
|
|
}
|
|
return map.values.toList();
|
|
}
|
|
}
|