Skip to content

Commit df5715e

Browse files
committed
fix: improve media release quality ranking
1 parent 9179306 commit df5715e

7 files changed

Lines changed: 636 additions & 80 deletions

File tree

doc/auto-media-design.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,8 @@ struct ReleaseInfo {
320320
resolution: Option<String>,
321321
codec: Option<String>,
322322
source: Option<String>,
323+
hdr_formats: Vec<String>,
324+
bit_depth: Option<u8>,
323325
revision: Option<String>,
324326
release_group: Option<String>,
325327
matched_rule: String,
@@ -333,7 +335,7 @@ struct ReleaseInfo {
333335
3. 动画字幕组 + 绝对集、绝对集范围。
334336
4. 季包/全集。
335337
5. 电影标题 + 年份。
336-
6. 独立质量、编码、来源和 revision 解析。
338+
6. 独立质量、编码、来源、HDR / Dolby Vision、位深和 revision 解析。
337339

338340
范围展开必须限制最大长度,反向范围直接拒绝。数字标题如 `The 100``3x3 Eyes``Blade Runner 2049` 必须有专门回归测试。
339341

@@ -372,15 +374,22 @@ struct ReleaseInfo {
372374

373375
```text
374376
accepted DESC,
375-
score DESC,
376-
quality_rank DESC,
377+
resolution_rank DESC,
378+
source_rank DESC,
379+
size_fitness DESC,
380+
video_feature_rank DESC,
381+
codec_rank DESC,
377382
seeders DESC,
383+
score DESC,
378384
publish_time DESC,
379385
site_priority ASC,
380386
stable_release_key ASC
381387
```
382388

383-
最后的稳定键保证不同网络返回顺序不会改变最佳资源。
389+
`size_fitness` 按媒体类型、分辨率、集数和编码效率计算 0..1000 的体积充足度,达到参考体积后封顶,
390+
避免把文件大小当成无限增益。只有 `size_fitness = 1000` 时才启用 `video_feature_rank`,依次区分
391+
Dolby Vision(带 HDR 回退优先)、HDR10+、HDR10、HLG / HDR 和 10bit。片长不可用时体积充足度只是软排序信号,
392+
不作为拒绝条件。最后的稳定键保证不同网络返回顺序不会改变最佳资源。
384393

385394
## 7. 搜索查询生成
386395

doc/openapi.yaml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,7 +1307,7 @@ components:
13071307

13081308
ReleaseInfo:
13091309
type: object
1310-
required: [raw_title, title, alternate_titles, year, season, episodes, absolute_episodes, full_season, resolution, codec, source, revision, release_group, matched_rule]
1310+
required: [raw_title, title, alternate_titles, year, season, episodes, absolute_episodes, full_season, resolution, codec, source, hdr_formats, bit_depth, revision, release_group, matched_rule]
13111311
properties:
13121312
raw_title: {type: string}
13131313
title: {type: string}
@@ -1320,6 +1320,10 @@ components:
13201320
resolution: {type: string, nullable: true}
13211321
codec: {type: string, nullable: true}
13221322
source: {type: string, nullable: true}
1323+
hdr_formats:
1324+
type: array
1325+
items: {type: string, enum: [Dolby Vision, HDR10+, HDR10, HLG, HDR]}
1326+
bit_depth: {type: integer, format: int32, nullable: true, enum: [8, 10, 12]}
13231327
revision: {type: string, nullable: true}
13241328
release_group: {type: string, nullable: true}
13251329
matched_rule: {type: string}
@@ -1358,11 +1362,22 @@ components:
13581362

13591363
SortKey:
13601364
type: object
1361-
required: [accepted, score, quality_rank, seeders, publish_time, site_priority, stable_release_key]
1365+
description: >-
1366+
Deterministic best-first key. Ordering is accepted, resolution, source, size
1367+
fitness, eligible video features, codec, seeders, score, publish time, site
1368+
priority, stable key. Video features rank as zero until size fitness reaches 1000.
1369+
required: [accepted, score, quality_rank, resolution_rank, video_feature_rank, source_rank, size_fitness, size_per_item, size_target, codec_rank, seeders, publish_time, site_priority, stable_release_key]
13621370
properties:
13631371
accepted: {type: boolean}
13641372
score: {type: integer, format: int32, minimum: 0}
13651373
quality_rank: {type: integer, format: int64, minimum: 0}
1374+
resolution_rank: {type: integer, format: int64, minimum: 0}
1375+
video_feature_rank: {type: integer, format: int32, minimum: 0}
1376+
source_rank: {type: integer, format: int64, minimum: 0}
1377+
size_fitness: {type: integer, format: int32, minimum: 0, maximum: 1000}
1378+
size_per_item: {type: integer, format: int64, minimum: 0}
1379+
size_target: {type: integer, format: int64, minimum: 1}
1380+
codec_rank: {type: integer, format: int64, minimum: 0}
13661381
seeders: {type: integer, format: int32, minimum: 0}
13671382
publish_time: {type: string, format: date-time, nullable: true}
13681383
site_priority: {type: integer, format: int32, minimum: 0}

frontend/src/pages/media-page.tsx

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ type ReleaseInfo = {
248248
resolution: string | null;
249249
codec: string | null;
250250
source: string | null;
251+
hdr_formats: string[];
252+
bit_depth: number | null;
251253
revision: string | null;
252254
release_group: string | null;
253255
matched_rule: string;
@@ -268,13 +270,24 @@ type MatchDecision = {
268270
explanations: string[];
269271
};
270272

273+
type CandidateSortKey = {
274+
resolution_rank: number;
275+
video_feature_rank: number;
276+
source_rank: number;
277+
size_fitness: number;
278+
size_per_item: number;
279+
size_target: number;
280+
codec_rank: number;
281+
};
282+
271283
type ResourceCandidate = {
272284
candidateId: string;
273285
key: string;
274286
result: IndexerResult;
275287
release: ReleaseInfo | null;
276288
parseError: string | null;
277289
decision: MatchDecision | null;
290+
sortKey: CandidateSortKey | null;
278291
rank: number;
279292
raw: Record<string, unknown>;
280293
};
@@ -769,7 +782,15 @@ function compactEpisodeList(episodes: number[], prefix = ""): string {
769782

770783
function releaseQualityFields(release: ReleaseInfo | null | undefined): string[] {
771784
if (!release) return [];
772-
return [release.resolution, release.source, release.codec, release.revision, releaseScopeLabel(release)]
785+
return [
786+
release.resolution,
787+
...(release.hdr_formats ?? []),
788+
release.bit_depth ? `${release.bit_depth}bit` : null,
789+
release.source,
790+
release.codec,
791+
release.revision,
792+
releaseScopeLabel(release),
793+
]
773794
.filter((field): field is string => Boolean(field));
774795
}
775796

@@ -875,6 +896,8 @@ function normalizeCandidate(value: unknown, index: number): ResourceCandidate |
875896
resolution: optionalString(value.release.resolution),
876897
codec: optionalString(value.release.codec),
877898
source: optionalString(value.release.source),
899+
hdr_formats: stringList(value.release.hdr_formats),
900+
bit_depth: value.release.bit_depth == null ? null : numberValue(value.release.bit_depth),
878901
revision: optionalString(value.release.revision),
879902
release_group: optionalString(value.release.release_group),
880903
matched_rule: optionalString(value.release.matched_rule) ?? "unknown",
@@ -906,13 +929,27 @@ function normalizeCandidate(value: unknown, index: number): ResourceCandidate |
906929
};
907930
}
908931

932+
const sortKeyRaw = isRecord(value.sort_key) ? value.sort_key : null;
933+
const sortKey: CandidateSortKey | null = sortKeyRaw
934+
? {
935+
resolution_rank: numberValue(sortKeyRaw.resolution_rank),
936+
video_feature_rank: numberValue(sortKeyRaw.video_feature_rank),
937+
source_rank: numberValue(sortKeyRaw.source_rank),
938+
size_fitness: numberValue(sortKeyRaw.size_fitness),
939+
size_per_item: numberValue(sortKeyRaw.size_per_item),
940+
size_target: numberValue(sortKeyRaw.size_target),
941+
codec_rank: numberValue(sortKeyRaw.codec_rank),
942+
}
943+
: null;
944+
909945
return {
910946
candidateId,
911947
key: candidateId,
912948
result,
913949
release,
914950
parseError: value.parse_error == null ? null : describeUnknown(value.parse_error),
915951
decision,
952+
sortKey,
916953
rank: numberValue(value.rank, index + 1),
917954
raw: value,
918955
};
@@ -2151,7 +2188,7 @@ export function MediaPage() {
21512188
<div className="min-w-0">
21522189
<div className="text-sm font-semibold">当前会优先寻找</div>
21532190
<div className="mt-2 flex flex-wrap gap-2"><TokenList values={[...splitValues(qualityForm.resolutionOrder).slice(0, 2), ...splitValues(qualityForm.sourceOrder).slice(0, 2), ...splitValues(qualityForm.codecOrder).slice(0, 1)]} /></div>
2154-
<p className="mt-2 text-xs leading-5 text-muted">原盘 / REMUX、DIY、HDR、杜比视界、杜比全景声、10bit 等特性仍会保留在资源标题中供你确认;当前自动筛选以分辨率、片源和视频编码为准</p>
2191+
<p className="mt-2 text-xs leading-5 text-muted">自动排序依次比较分辨率、片源、体积充足度、杜比视界 / HDR / 位深、视频编码和做种数;只有体积达到参考线后才启用视频特性加成</p>
21552192
</div>
21562193
</div>
21572194
</div>
@@ -3374,7 +3411,7 @@ function candidateProcessingOutcome(
33743411
return { label: "未下载", detail: `标题解析失败:${candidate.parseError}`, tone: "negative" };
33753412
}
33763413
if (candidate.decision?.accepted) {
3377-
return { label: "符合但未选", detail: "通过全部规则,但综合排序低于最终选择。", tone: "neutral" };
3414+
return { label: "符合但未选", detail: "通过全部规则,但在分辨率、片源、体积充足度、视频特性、编码或做种数的逐级排序中低于最终选择。", tone: "neutral" };
33783415
}
33793416
if (candidate.decision) {
33803417
return {
@@ -3746,8 +3783,7 @@ function ReleaseSummary({ candidate }: { candidate: ResourceCandidate }) {
37463783
return <div className="max-w-56 text-xs text-destructive">解析失败:{candidate.parseError}</div>;
37473784
}
37483785
if (!candidate.release) return <span className="text-xs text-muted">无解析数据</span>;
3749-
const episode = releaseScopeLabel(candidate.release);
3750-
const fields = [candidate.release.resolution, candidate.release.source, candidate.release.codec, episode].filter(Boolean);
3786+
const fields = releaseQualityFields(candidate.release);
37513787
return (
37523788
<div className="max-w-56 text-xs">
37533789
<div className="flex flex-wrap gap-1.5">
@@ -3791,6 +3827,7 @@ function DecisionSummary({ candidate }: { candidate: ResourceCandidate }) {
37913827
<div className="max-w-64 text-xs">
37923828
<DecisionBadge candidate={candidate} />
37933829
{breakdown ? <div className="mt-1.5 text-muted">{breakdown}</div> : null}
3830+
<CandidateRankingSummary candidate={candidate} />
37943831
</div>
37953832
);
37963833
}
@@ -3804,6 +3841,26 @@ function DecisionSummary({ candidate }: { candidate: ResourceCandidate }) {
38043841
);
38053842
}
38063843

3844+
function CandidateRankingSummary({ candidate }: { candidate: ResourceCandidate }) {
3845+
const sortKey = candidate.sortKey;
3846+
if (!sortKey || sortKey.size_target <= 0) return null;
3847+
const fitness = Math.max(0, Math.min(100, Math.floor(sortKey.size_fitness / 10)));
3848+
const normalized = sortKey.size_per_item === candidate.result.size ? "体积" : "单集体积";
3849+
const hasVideoFeatures = Boolean(candidate.release?.hdr_formats.length)
3850+
|| (candidate.release?.bit_depth ?? 0) >= 10;
3851+
const featureStatus = hasVideoFeatures
3852+
? ` · 视频特性加成${sortKey.video_feature_rank > 0 ? "已启用" : "未启用"}`
3853+
: "";
3854+
return (
3855+
<div
3856+
className="mt-1.5 text-muted"
3857+
title={`${normalized} ${formatBytes(sortKey.size_per_item)},充足参考 ${formatBytes(sortKey.size_target)}`}
3858+
>
3859+
{normalized}充足度 {fitness}%{featureStatus} · {candidate.result.seeders} 做种
3860+
</div>
3861+
);
3862+
}
3863+
38073864
function decisionBreakdownLabel(name: string): string {
38083865
const labels: Record<string, string> = {
38093866
title: "标题",

src/media/domain/quality.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,23 @@ impl QualityProfile {
163163

164164
#[allow(dead_code)]
165165
pub fn quality_rank(&self, release: &ReleaseInfo) -> u64 {
166-
let resolution = preference_rank(&self.resolution_order, release.resolution.as_deref());
167-
let source = preference_rank(&self.source_order, release.source.as_deref());
168-
let codec = preference_rank(&self.codec_order, release.codec.as_deref());
166+
combined_rank(
167+
self.resolution_rank(release),
168+
self.source_rank(release),
169+
self.codec_rank(release),
170+
)
171+
}
172+
173+
pub fn resolution_rank(&self, release: &ReleaseInfo) -> u64 {
174+
preference_rank(&self.resolution_order, release.resolution.as_deref())
175+
}
176+
177+
pub fn source_rank(&self, release: &ReleaseInfo) -> u64 {
178+
preference_rank(&self.source_order, release.source.as_deref())
179+
}
169180

170-
combined_rank(resolution, source, codec)
181+
pub fn codec_rank(&self, release: &ReleaseInfo) -> u64 {
182+
preference_rank(&self.codec_order, release.codec.as_deref())
171183
}
172184

173185
fn unknown_or_allowed(&self, release: &ReleaseInfo) -> QualityAssessment {
@@ -328,6 +340,8 @@ mod tests {
328340
resolution: resolution.map(str::to_owned),
329341
codec: codec.map(str::to_owned),
330342
source: source.map(str::to_owned),
343+
hdr_formats: Vec::new(),
344+
bit_depth: None,
331345
revision: None,
332346
release_group: None,
333347
matched_rule: "quality_only".into(),

0 commit comments

Comments
 (0)