Initial commit
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user