Skip to main content

zantetsu_core/scoring/
profile.rs

1use serde::{Deserialize, Serialize};
2
3use crate::types::{AudioCodec, MediaSource, Resolution, VideoCodec};
4
5/// Default quality profile weights.
6pub const WEIGHT_RESOLUTION: f32 = 0.35;
7pub const WEIGHT_VIDEO_CODEC: f32 = 0.25;
8pub const WEIGHT_AUDIO_CODEC: f32 = 0.15;
9pub const WEIGHT_SOURCE: f32 = 0.15;
10pub const WEIGHT_GROUP_TRUST: f32 = 0.10;
11
12/// Quality profile defining the relative importance of each dimension.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct QualityProfile {
15    pub resolution_weight: f32,
16    pub video_codec_weight: f32,
17    pub audio_codec_weight: f32,
18    pub source_weight: f32,
19    pub group_trust_weight: f32,
20}
21
22impl Default for QualityProfile {
23    fn default() -> Self {
24        Self {
25            resolution_weight: WEIGHT_RESOLUTION,
26            video_codec_weight: WEIGHT_VIDEO_CODEC,
27            audio_codec_weight: WEIGHT_AUDIO_CODEC,
28            source_weight: WEIGHT_SOURCE,
29            group_trust_weight: WEIGHT_GROUP_TRUST,
30        }
31    }
32}
33
34impl QualityProfile {
35    /// Validates that all weights sum to approximately 1.0.
36    #[must_use]
37    pub fn is_valid(&self) -> bool {
38        let sum = self.resolution_weight
39            + self.video_codec_weight
40            + self.audio_codec_weight
41            + self.source_weight
42            + self.group_trust_weight;
43        (sum - 1.0).abs() < 0.01
44    }
45}
46
47/// Scores for individual quality dimensions of a parsed file.
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct QualityScores {
50    /// Resolution score `[0.0, 1.0]`.
51    pub resolution: Option<f32>,
52    /// Video codec score `[0.0, 1.0]`.
53    pub video_codec: Option<f32>,
54    /// Audio codec score `[0.0, 1.0]`.
55    pub audio_codec: Option<f32>,
56    /// Source score `[0.0, 1.0]`.
57    pub source: Option<f32>,
58    /// Group trust score `[0.0, 1.0]`.
59    pub group_trust: f32,
60}
61
62impl QualityScores {
63    /// Builds scores from parsed metadata.
64    #[must_use]
65    pub fn from_metadata(
66        resolution: Option<Resolution>,
67        video_codec: Option<VideoCodec>,
68        audio_codec: Option<AudioCodec>,
69        source: Option<MediaSource>,
70        group_trust: f32,
71    ) -> Self {
72        Self {
73            resolution: resolution.map(|r| r.score()),
74            video_codec: video_codec.map(|v| v.score()),
75            audio_codec: audio_codec.map(|a| a.score()),
76            source: source.map(|s| s.score()),
77            group_trust,
78        }
79    }
80
81    /// Computes the weighted quality score using the given profile.
82    /// Missing dimensions contribute 0.5 (neutral) to avoid penalizing
83    /// files where metadata is simply absent.
84    #[must_use]
85    pub fn compute(&self, profile: &QualityProfile) -> f32 {
86        let res = self.resolution.unwrap_or(0.5);
87        let vc = self.video_codec.unwrap_or(0.5);
88        let ac = self.audio_codec.unwrap_or(0.5);
89        let src = self.source.unwrap_or(0.5);
90
91        profile.resolution_weight * res
92            + profile.video_codec_weight * vc
93            + profile.audio_codec_weight * ac
94            + profile.source_weight * src
95            + profile.group_trust_weight * self.group_trust
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn default_profile_is_valid() {
105        let profile = QualityProfile::default();
106        assert!(profile.is_valid());
107    }
108
109    #[test]
110    fn invalid_profile_detected() {
111        let profile = QualityProfile {
112            resolution_weight: 0.5,
113            video_codec_weight: 0.5,
114            audio_codec_weight: 0.5,
115            source_weight: 0.5,
116            group_trust_weight: 0.5,
117        };
118        assert!(!profile.is_valid());
119    }
120
121    #[test]
122    fn quality_scores_full_metadata() {
123        let scores = QualityScores::from_metadata(
124            Some(Resolution::FHD1080),
125            Some(VideoCodec::HEVC),
126            Some(AudioCodec::FLAC),
127            Some(MediaSource::BluRay),
128            0.8,
129        );
130        let profile = QualityProfile::default();
131        let score = scores.compute(&profile);
132
133        // Expected:
134        // 0.35 * 0.85 (1080p) + 0.25 * 0.85 (HEVC) + 0.15 * 0.95 (FLAC) + 0.15 * 0.90 (BluRay) + 0.10 * 0.8
135        let expected = 0.35 * 0.85 + 0.25 * 0.85 + 0.15 * 0.95 + 0.15 * 0.90 + 0.10 * 0.8;
136        assert!(
137            (score - expected).abs() < 0.001,
138            "score={score}, expected={expected}"
139        );
140    }
141
142    #[test]
143    fn quality_scores_missing_metadata_uses_neutral() {
144        let scores = QualityScores::from_metadata(None, None, None, None, 0.5);
145        let profile = QualityProfile::default();
146        let score = scores.compute(&profile);
147        // All dimensions use 0.5 neutral
148        assert!((score - 0.5).abs() < 0.001);
149    }
150
151    #[test]
152    fn quality_scores_partial_metadata() {
153        let scores = QualityScores::from_metadata(
154            Some(Resolution::UHD2160),
155            None,
156            None,
157            Some(MediaSource::BluRayRemux),
158            0.9,
159        );
160        let profile = QualityProfile::default();
161        let score = scores.compute(&profile);
162
163        let expected = 0.35 * 1.0 + 0.25 * 0.5 + 0.15 * 0.5 + 0.15 * 1.0 + 0.10 * 0.9;
164        assert!(
165            (score - expected).abs() < 0.001,
166            "score={score}, expected={expected}"
167        );
168    }
169}