1use crate::{
3 model::Catalog,
4 user::{ClientPreference, UserData},
5};
6use serde_json::{Value, json};
7use std::collections::{BTreeMap, BTreeSet};
8const KEY: &str = "aede.achievements.v1";
9struct Definition {
10 id: String,
11 fr: String,
12 en: String,
13 detail_fr: String,
14 detail_en: String,
15 met: bool,
16 icon: &'static str,
17}
18fn definitions(catalog: &Catalog, data: &UserData, owner: &str, search: bool) -> Vec<Definition> {
19 let annotations: Vec<_> = data
20 .annotations
21 .iter()
22 .filter(|r| r.owner == owner)
23 .collect();
24 let plays: Vec<_> = data.plays.iter().filter(|r| r.owner == owner).collect();
25 let completed = crate::listening::completed_tracks(data, owner);
26 let albums = crate::listening::completed_albums(catalog, data, owner);
27 let mut discographies: BTreeMap<_, Vec<_>> = BTreeMap::new();
28 for r in &catalog.releases {
29 if let Some(artist) = r.album_artist_id {
30 discographies.entry(artist).or_default().push(r.id);
31 }
32 }
33 let tags: BTreeSet<_> = annotations.iter().flat_map(|r| r.tags.iter()).collect();
34 let families = [
35 (
36 "loved",
37 "Favoris",
38 "Favourites",
39 "Ajouter des favoris",
40 "Add favourites",
41 annotations.iter().filter(|r| r.loved).count() as u64,
42 "heart",
43 [1, 5, 10, 25, 50, 100],
44 ),
45 (
46 "rated",
47 "Étoiles",
48 "Stars",
49 "Noter des éléments",
50 "Rate items",
51 annotations.iter().filter(|r| r.rating.is_some()).count() as u64,
52 "star",
53 [1, 5, 10, 25, 50, 100],
54 ),
55 (
56 "notes",
57 "Carnet",
58 "Notebook",
59 "Écrire des notes personnelles",
60 "Write personal notes",
61 annotations
62 .iter()
63 .filter(|r| r.note.as_ref().is_some_and(|n| !n.trim().is_empty()))
64 .count() as u64,
65 "edit",
66 [1, 3, 10, 25, 50, 100],
67 ),
68 (
69 "tags",
70 "Repères",
71 "Landmarks",
72 "Utiliser des tags distincts",
73 "Use distinct tags",
74 tags.len() as u64,
75 "tags",
76 [1, 3, 10, 25, 50, 100],
77 ),
78 (
79 "tracks",
80 "À l'écoute",
81 "Listening",
82 "Écouter des pistes distinctes jusqu'au bout",
83 "Finish distinct tracks",
84 completed.len() as u64,
85 "volume",
86 [1, 10, 25, 100, 250, 1000],
87 ),
88 (
89 "albums",
90 "Du début à la fin",
91 "From beginning to end",
92 "Terminer des albums du catalogue",
93 "Complete catalogue albums",
94 albums.len() as u64,
95 "disc",
96 [1, 5, 10, 25, 50, 100],
97 ),
98 (
99 "artists",
100 "En profondeur",
101 "In depth",
102 "Terminer les discographies présentes dans le catalogue",
103 "Complete discographies present in the catalogue",
104 discographies
105 .values()
106 .filter(|rs| rs.iter().all(|r| albums.contains(r)))
107 .count() as u64,
108 "users",
109 [1, 2, 5, 10, 25, 50],
110 ),
111 (
112 "hours",
113 "Le temps de la musique",
114 "Time for music",
115 "Cumuler des heures d'écoute dans l'historique conservé",
116 "Accumulate listening hours in retained history",
117 plays
118 .iter()
119 .fold(0_u64, |sum, r| sum.saturating_add(r.ms_played))
120 / 3600000,
121 "history",
122 [1, 5, 10, 25, 50, 100],
123 ),
124 ];
125 let mut result = Vec::new();
126 for (key, fr, en, df, de, count, icon, limits) in families {
127 for threshold in limits {
128 result.push(Definition {
129 id: format!("{key}-{threshold}"),
130 fr: format!("{fr} · {threshold}"),
131 en: format!("{en} · {threshold}"),
132 detail_fr: format!("{df} : {threshold}."),
133 detail_en: format!("{de}: {threshold}."),
134 met: count >= threshold,
135 icon,
136 });
137 }
138 }
139 let count = data.collections.iter().filter(|r| r.owner == owner).count();
140 for threshold in [1, 3, 10] {
141 result.push(Definition {
142 id: format!("collections-{threshold}"),
143 fr: format!("Votre sélection · {threshold}"),
144 en: format!("Your selection · {threshold}"),
145 detail_fr: format!("Créer {threshold} collection(s)."),
146 detail_en: format!("Create {threshold} collection(s)."),
147 met: count >= threshold,
148 icon: "library",
149 });
150 }
151 result.push(Definition {
152 id: "search-1".into(),
153 fr: "Au-delà du titre".into(),
154 en: "Beyond the title".into(),
155 detail_fr: "Effectuer une recherche avancée dans Phémios.".into(),
156 detail_en: "Perform an advanced search in Phémios.".into(),
157 met: search,
158 icon: "search",
159 });
160 result
161}
162pub fn refresh(
165 catalog: &Catalog,
166 data: &mut UserData,
167 owner: &str,
168 advanced: bool,
169 at: u64,
170) -> Result<Value, String> {
171 let row = data
172 .client_preferences
173 .iter()
174 .position(|r| r.owner == owner && r.key == KEY);
175 let previous = match row {
176 Some(i) => serde_json::from_str::<Value>(&data.client_preferences[i].value)
177 .map_err(|_| "achievement store is unreadable")?,
178 None => json!({"version":1,"unlocked":{},"search":false}),
179 };
180 if previous["version"] != 1 || !previous["search"].is_boolean() {
181 return Err("achievement store is unreadable".into());
182 }
183 let object = previous["unlocked"]
184 .as_object()
185 .filter(|r| r.len() <= 256)
186 .ok_or("achievement store is unreadable")?;
187 let mut unlocked = BTreeMap::new();
188 for (key, value) in object {
189 if key.len() > 64 || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
190 return Err("achievement store is unreadable".into());
191 }
192 unlocked.insert(
193 key.clone(),
194 value.as_u64().ok_or("achievement store is unreadable")?,
195 );
196 }
197 let search = advanced || previous["search"].as_bool().unwrap_or(false);
198 let definitions = definitions(catalog, data, owner, search);
199 for d in &definitions {
200 if d.met {
201 unlocked.entry(d.id.clone()).or_insert(at);
202 }
203 }
204 let value = json!({"version":1,"unlocked":unlocked,"search":search}).to_string();
205 if !crate::user::valid_preference(KEY, &value) {
206 return Err("achievement store exceeds its bound".into());
207 }
208 match row {
209 Some(i) => data.client_preferences[i].value = value,
210 None => {
211 if data.client_preferences.len() >= 8192
212 || data
213 .client_preferences
214 .iter()
215 .filter(|r| r.owner == owner)
216 .count()
217 >= 64
218 {
219 return Err("personal store is full".into());
220 }
221 data.client_preferences.push(ClientPreference {
222 owner: owner.into(),
223 key: KEY.into(),
224 value,
225 });
226 }
227 }
228 let items:Vec<_>=definitions.iter().enumerate().map(|(index,d)| {
229 if let Some(at)=unlocked.get(&d.id) {json!({"slot":index,"unlocked_at":at,"title":{"fr":d.fr,"en":d.en},"description":{"fr":d.detail_fr,"en":d.detail_en},"icon":d.icon})}
230 else {json!({"slot":index,"unlocked_at":null})}
231 }).collect();
232 Ok(
233 json!({"items":items,"unlocked":definitions.iter().filter(|d|unlocked.contains_key(&d.id)).count(),"total":definitions.len()}),
234 )
235}
236#[cfg(test)]
237#[path = "achievements_tests.rs"]
238mod tests;