1use std::collections::HashMap;
7use std::path::{Component, Path, PathBuf};
8
9use serde::Deserialize;
10
11use crate::error::{Error, Result};
12use crate::naming::CharacterSet;
13use crate::pathkey::canonical_path_key;
14use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
15
16use super::label_to_env;
17
18#[derive(Debug, Clone, Default, Deserialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25pub struct Settings {
26 pub format: Option<AudioFormat>,
27 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
28 pub concurrency: Option<u32>,
29 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
30 pub retries: Option<u32>,
31 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
32 pub min_newest: Option<u32>,
33 pub token_command: Option<String>,
38 pub animated_covers: Option<bool>,
39 pub video_cover_retention: Option<VideoCoverRetention>,
40 #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 100)))]
41 pub animated_cover_quality: Option<u8>,
42 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
43 pub animated_cover_max_fps: Option<u32>,
44 #[cfg_attr(feature = "schema", schemars(range(max = u32::MAX)))]
45 pub animated_cover_max_width: Option<u32>,
46 #[cfg_attr(feature = "schema", schemars(range(min = 0, max = 4)))]
47 pub animated_cover_compression_level: Option<u8>,
48 pub animated_cover_lossless: Option<bool>,
49 pub details_sidecar: Option<bool>,
50 pub lyrics_sidecar: Option<bool>,
51 pub lrc_sidecar: Option<bool>,
52 pub video_mp4: Option<bool>,
53 pub download_stems: Option<bool>,
54 pub stem_format: Option<StemFormat>,
55 pub naming_template: Option<String>,
56 pub character_set: Option<CharacterSet>,
57 pub number_singletons: Option<bool>,
61}
62
63const SETTINGS_KEYS: &[&str] = &[
72 "format",
73 "concurrency",
74 "retries",
75 "min_newest",
76 "token_command",
77 "animated_covers",
78 "video_cover_retention",
79 "animated_cover_quality",
80 "animated_cover_max_fps",
81 "animated_cover_max_width",
82 "animated_cover_compression_level",
83 "animated_cover_lossless",
84 "details_sidecar",
85 "lyrics_sidecar",
86 "lrc_sidecar",
87 "video_mp4",
88 "download_stems",
89 "stem_format",
90 "naming_template",
91 "character_set",
92 "number_singletons",
93];
94
95const ACCOUNT_STRUCTURAL_KEYS: &[&str] = &[
97 "token",
98 "root",
99 "account_id",
100 "sources",
101 "areas",
102 "albums",
103 "lead_tracks",
104];
105
106const TOP_LEVEL_KEYS: &[&str] = &["defaults", "accounts"];
108
109#[derive(Debug, Clone, Default, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct Defaults {
113 #[serde(flatten)]
114 pub settings: Settings,
115}
116
117#[derive(Debug, Clone, Default, Deserialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct SourceConfig {
121 #[serde(flatten)]
122 pub settings: Settings,
123}
124
125#[derive(Debug, Clone, Default, Deserialize)]
127#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128pub struct AccountConfig {
129 pub token: Option<String>,
130 pub root: Option<String>,
131 pub account_id: Option<String>,
135 #[serde(flatten)]
136 pub settings: Settings,
137 #[serde(default)]
138 pub sources: HashMap<String, SourceConfig>,
139 pub areas: Option<AreasConfig>,
142 #[serde(default)]
147 pub albums: HashMap<String, String>,
148 #[serde(default)]
153 pub lead_tracks: Vec<String>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum AreaMode {
164 Off,
166 Mode(SourceMode),
168}
169
170impl<'de> Deserialize<'de> for AreaMode {
171 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
172 where
173 D: serde::Deserializer<'de>,
174 {
175 let raw = String::deserialize(deserializer)?;
176 match raw.as_str() {
177 "off" => Ok(AreaMode::Off),
178 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
179 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
180 other => Err(serde::de::Error::custom(format!(
181 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
182 ))),
183 }
184 }
185}
186
187#[cfg(feature = "schema")]
188impl schemars::JsonSchema for AreaMode {
189 fn schema_name() -> std::borrow::Cow<'static, str> {
190 "AreaMode".into()
191 }
192
193 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
194 schemars::json_schema!({
195 "type": "string",
196 "enum": ["off", "copy", "mirror"],
197 "description": "Deletion mode for the library area: 'off' arms deletion of \
198 library-exclusive files, 'copy' is additive, 'mirror' deletes.",
199 })
200 }
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212#[serde(deny_unknown_fields)]
213pub struct AreasConfig {
214 pub library: Option<AreaMode>,
215 pub liked: Option<SourceMode>,
216 pub playlists: Option<SourceMode>,
217 #[serde(default)]
218 pub playlist: HashMap<String, SourceMode>,
219}
220
221#[derive(Debug, Clone, Default, Deserialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct Config {
225 #[serde(default)]
226 pub defaults: Defaults,
227 #[serde(default)]
228 pub accounts: HashMap<String, AccountConfig>,
229}
230
231impl Config {
232 pub fn from_toml(toml_str: &str) -> Result<Self> {
238 let config: Self = toml::from_str(toml_str).map_err(redact_toml_error)?;
239 reject_unknown_keys(toml_str)?;
240 config.validate()?;
241 Ok(config)
242 }
243
244 fn validate(&self) -> Result<()> {
245 let roots: Vec<(&str, &str)> = self
246 .accounts
247 .iter()
248 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
249 .collect();
250
251 for (i, (label_a, root_a)) in roots.iter().enumerate() {
252 for (label_b, root_b) in roots.iter().skip(i + 1) {
253 let a = normalise_root(root_a);
257 let b = normalise_root(root_b);
258 if a.starts_with(&b) || b.starts_with(&a) {
259 return Err(Error::Config(format!(
260 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
261 )));
262 }
263 }
264 }
265
266 let templates = std::iter::once(&self.defaults.settings).chain(
272 self.accounts.values().flat_map(|acc| {
273 std::iter::once(&acc.settings).chain(acc.sources.values().map(|s| &s.settings))
274 }),
275 );
276 for settings in templates {
277 if let Some(template) = settings.naming_template.as_deref()
278 && template.trim().is_empty()
279 {
280 return Err(Error::Config(
281 "naming_template must not be empty or whitespace-only".into(),
282 ));
283 }
284 }
285
286 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
287 for label in self.accounts.keys() {
288 let prefix = label_to_env(label);
289 if let Some(other) = prefix_seen.get(&prefix) {
290 return Err(Error::Config(format!(
291 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
292 )));
293 }
294 prefix_seen.insert(prefix, label.as_str());
295 }
296
297 Ok(())
298 }
299}
300
301fn redact_toml_error(e: toml::de::Error) -> Error {
305 let msg = e
306 .to_string()
307 .lines()
308 .filter(|l| !l.contains(" | "))
309 .collect::<Vec<_>>()
310 .join("\n")
311 .trim()
312 .to_owned();
313 Error::Config(if msg.is_empty() {
314 "parse error".into()
315 } else {
316 msg
317 })
318}
319
320fn reject_unknown_keys(toml_str: &str) -> Result<()> {
331 let doc: toml::Table = toml::from_str(toml_str).map_err(redact_toml_error)?;
332
333 check_known_keys(&doc, "the top-level table", |k| TOP_LEVEL_KEYS.contains(&k))?;
334
335 if let Some(defaults) = doc.get("defaults").and_then(toml::Value::as_table) {
336 check_known_keys(defaults, "[defaults]", |k| SETTINGS_KEYS.contains(&k))?;
337 }
338
339 if let Some(accounts) = doc.get("accounts").and_then(toml::Value::as_table) {
340 for (label, account) in accounts {
341 let Some(account) = account.as_table() else {
342 continue;
343 };
344 let section = format!("[accounts.{label}]");
345 check_known_keys(account, §ion, |k| {
346 ACCOUNT_STRUCTURAL_KEYS.contains(&k) || SETTINGS_KEYS.contains(&k)
347 })?;
348
349 if let Some(sources) = account.get("sources").and_then(toml::Value::as_table) {
350 for (name, source) in sources {
351 let Some(source) = source.as_table() else {
352 continue;
353 };
354 let section = format!("[accounts.{label}.sources.{name}]");
355 check_known_keys(source, §ion, |k| SETTINGS_KEYS.contains(&k))?;
356 }
357 }
358 }
359 }
360
361 Ok(())
362}
363
364fn check_known_keys(
367 table: &toml::Table,
368 section: &str,
369 allowed: impl Fn(&str) -> bool,
370) -> Result<()> {
371 for key in table.keys() {
372 if !allowed(key.as_str()) {
373 return Err(Error::Config(format!("unknown key '{key}' in {section}")));
374 }
375 }
376 Ok(())
377}
378
379fn normalise_root(root: &str) -> Vec<String> {
395 let mut out = PathBuf::new();
396 for component in Path::new(root).components() {
397 match component {
398 Component::CurDir => {}
399 Component::ParentDir => match out.components().next_back() {
400 Some(Component::Normal(_)) => {
401 out.pop();
402 }
403 Some(Component::RootDir | Component::Prefix(_)) => {}
405 _ => out.push(component.as_os_str()),
407 },
408 other => out.push(other.as_os_str()),
409 }
410 }
411 out.components()
412 .map(|c| canonical_path_key(&c.as_os_str().to_string_lossy()))
413 .collect()
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn parse_empty_toml() {
422 let cfg = Config::from_toml("").unwrap();
423 assert!(cfg.accounts.is_empty());
424 }
425
426 #[test]
427 fn parse_basic_account() {
428 let toml = r#"
429 [accounts.alice]
430 token = "tok"
431 root = "/music"
432 "#;
433 let cfg = Config::from_toml(toml).unwrap();
434 let acc = &cfg.accounts["alice"];
435 assert_eq!(acc.token.as_deref(), Some("tok"));
436 assert_eq!(acc.root.as_deref(), Some("/music"));
437 }
438
439 #[test]
440 fn parse_defaults_section() {
441 let toml = r#"
442 [defaults]
443 format = "mp3"
444 concurrency = 8
445 retries = 5
446 min_newest = 2
447 animated_covers = true
448 video_cover_retention = "both"
449 animated_cover_quality = 85
450 animated_cover_max_fps = 18
451 animated_cover_max_width = 720
452 animated_cover_compression_level = 4
453 "#;
454 let cfg = Config::from_toml(toml).unwrap();
455 assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
456 assert_eq!(cfg.defaults.settings.concurrency, Some(8));
457 assert_eq!(cfg.defaults.settings.retries, Some(5));
458 assert_eq!(cfg.defaults.settings.min_newest, Some(2));
459 assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
460 assert_eq!(
461 cfg.defaults.settings.video_cover_retention,
462 Some(VideoCoverRetention::Both)
463 );
464 assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
465 assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
466 assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
467 assert_eq!(
468 cfg.defaults.settings.animated_cover_compression_level,
469 Some(4)
470 );
471 }
472
473 #[test]
474 fn validation_nested_roots() {
475 let toml = r#"
476 [accounts.alice]
477 root = "/music"
478
479 [accounts.bob]
480 root = "/music/bob"
481 "#;
482 assert!(Config::from_toml(toml).is_err());
483 }
484
485 #[test]
486 fn validation_non_nested_roots_ok() {
487 let toml = r#"
488 [accounts.alice]
489 root = "/music/alice"
490
491 [accounts.bob]
492 root = "/music/bob"
493 "#;
494 assert!(Config::from_toml(toml).is_ok());
495 }
496
497 #[test]
498 fn invalid_toml_errors() {
499 assert!(Config::from_toml("not valid toml ][").is_err());
500 }
501
502 #[test]
503 fn duplicate_account_label_errors() {
504 let toml = "
506 [accounts.alice]
507 token = \"tok1\"
508
509 [accounts.alice]
510 token = \"tok2\"
511 ";
512 assert!(Config::from_toml(toml).is_err());
513 }
514
515 #[test]
516 fn parse_error_does_not_echo_token() {
517 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
519 let err = Config::from_toml(toml).unwrap_err().to_string();
520 assert!(!err.contains("unterminated"), "error leaked token: {err}");
521 }
522
523 #[test]
524 fn validation_env_prefix_collision_errors() {
525 let toml = "
527 [accounts.my-lib]
528 [accounts.my_lib]
529 ";
530 assert!(Config::from_toml(toml).is_err());
531 }
532
533 #[test]
534 fn areas_parse_full_table() {
535 let toml = r#"
536 [accounts.alice]
537 token = "t"
538 [accounts.alice.areas]
539 library = "off"
540 liked = "copy"
541 playlists = "mirror"
542 [accounts.alice.areas.playlist]
543 "pl_abc123" = "mirror"
544 "pl_def456" = "copy"
545 "#;
546 let cfg = Config::from_toml(toml).unwrap();
547 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
548 assert_eq!(areas.library, Some(AreaMode::Off));
549 assert_eq!(areas.liked, Some(SourceMode::Copy));
550 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
551 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
552 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
553 }
554
555 #[test]
556 fn areas_library_accepts_copy_and_mirror() {
557 for (raw, expect) in [
558 ("copy", AreaMode::Mode(SourceMode::Copy)),
559 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
560 ] {
561 let toml =
562 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
563 let cfg = Config::from_toml(&toml).unwrap();
564 assert_eq!(
565 cfg.accounts["a"].areas.as_ref().unwrap().library,
566 Some(expect)
567 );
568 }
569 }
570
571 #[test]
572 fn areas_bad_mode_errors() {
573 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
574 assert!(Config::from_toml(toml).is_err());
575 }
576
577 #[test]
578 fn areas_bad_playlist_mode_errors() {
579 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
580 assert!(Config::from_toml(toml).is_err());
582 }
583
584 #[test]
585 fn areas_unknown_field_errors() {
586 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
588 assert!(Config::from_toml(toml).is_err());
589 }
590
591 #[test]
592 fn areas_absent_is_none() {
593 let toml = "[accounts.a]\ntoken = \"t\"\n";
594 assert!(
595 Config::from_toml(toml).unwrap().accounts["a"]
596 .areas
597 .is_none()
598 );
599 }
600
601 #[test]
604 fn misspelled_min_newest_key_is_rejected() {
605 let err = Config::from_toml("[defaults]\nmin_newst = 50\n")
609 .unwrap_err()
610 .to_string();
611 assert!(
612 err.contains("min_newst"),
613 "error should name the key: {err}"
614 );
615 assert!(
616 err.contains("[defaults]"),
617 "error should name the tier: {err}"
618 );
619 }
620
621 #[test]
622 fn unknown_defaults_key_is_rejected() {
623 assert!(Config::from_toml("[defaults]\nbogus = true\n").is_err());
624 }
625
626 #[test]
627 fn unknown_account_key_is_rejected() {
628 let err = Config::from_toml("[accounts.alice]\nroout = \"/music\"\n")
629 .unwrap_err()
630 .to_string();
631 assert!(err.contains("roout"), "error should name the key: {err}");
632 assert!(
633 err.contains("[accounts.alice]"),
634 "error names the tier: {err}"
635 );
636 }
637
638 #[test]
639 fn unknown_source_key_is_rejected() {
640 let toml = "[accounts.alice.sources.liked]\nformta = \"mp3\"\n";
641 let err = Config::from_toml(toml).unwrap_err().to_string();
642 assert!(err.contains("formta"), "error should name the key: {err}");
643 assert!(
644 err.contains("[accounts.alice.sources.liked]"),
645 "error should name the source tier: {err}"
646 );
647 }
648
649 #[test]
650 fn unknown_top_level_table_is_rejected() {
651 let err = Config::from_toml("[defalts]\nmin_newest = 50\n")
654 .unwrap_err()
655 .to_string();
656 assert!(err.contains("defalts"), "error should name the key: {err}");
657 }
658
659 #[test]
660 fn every_settings_key_is_accepted() {
661 let toml = r#"
663 [defaults]
664 format = "flac"
665 concurrency = 4
666 retries = 3
667 min_newest = 1
668 token_command = "echo tok"
669 animated_covers = true
670 video_cover_retention = "both"
671 animated_cover_quality = 90
672 animated_cover_max_fps = 24
673 animated_cover_max_width = 640
674 animated_cover_compression_level = 4
675 animated_cover_lossless = false
676 details_sidecar = true
677 lyrics_sidecar = true
678 lrc_sidecar = true
679 video_mp4 = true
680 download_stems = true
681 stem_format = "wav"
682 naming_template = "{title}"
683 character_set = "unicode"
684 number_singletons = false
685 "#;
686 assert!(Config::from_toml(toml).is_ok());
687 }
688
689 #[test]
690 fn known_structural_account_keys_are_accepted() {
691 let toml = r#"
692 [accounts.alice]
693 token = "t"
694 root = "/music/alice"
695 account_id = "user_abc"
696 format = "mp3"
697 lead_tracks = ["abc123"]
698 [accounts.alice.areas]
699 library = "off"
700 [accounts.alice.albums]
701 root_xyz = "Greatest Hits"
702 [accounts.alice.sources.liked]
703 format = "flac"
704 "#;
705 assert!(Config::from_toml(toml).is_ok());
706 }
707
708 #[cfg(feature = "schema")]
709 #[test]
710 fn settings_keys_mirror_the_struct() {
711 let schema = serde_json::to_value(schemars::schema_for!(Settings)).unwrap();
717 let schema_keys: std::collections::BTreeSet<&str> = schema["properties"]
718 .as_object()
719 .expect("Settings schema exposes properties")
720 .keys()
721 .map(String::as_str)
722 .collect();
723 let known: std::collections::BTreeSet<&str> = SETTINGS_KEYS.iter().copied().collect();
724 assert_eq!(
725 schema_keys, known,
726 "SETTINGS_KEYS is out of sync with Settings"
727 );
728 }
729
730 #[test]
733 fn nested_roots_with_dot_prefix_and_bare_are_rejected() {
734 let toml = "[accounts.alice]\nroot = \"./music\"\n\n[accounts.bob]\nroot = \"music\"\n";
737 assert!(Config::from_toml(toml).is_err());
738 }
739
740 #[test]
741 fn nested_roots_with_trailing_slash_are_rejected() {
742 let toml =
743 "[accounts.alice]\nroot = \"/music/\"\n\n[accounts.bob]\nroot = \"/music/alice\"\n";
744 assert!(Config::from_toml(toml).is_err());
745 }
746
747 #[test]
748 fn nested_roots_via_parent_segments_are_rejected() {
749 let toml =
751 "[accounts.alice]\nroot = \"sub/../shared\"\n\n[accounts.bob]\nroot = \"shared\"\n";
752 assert!(Config::from_toml(toml).is_err());
753 }
754
755 #[test]
756 fn disjoint_relative_roots_are_accepted() {
757 let toml = "[accounts.alice]\nroot = \"./alice\"\n\n[accounts.bob]\nroot = \"bob\"\n";
758 assert!(Config::from_toml(toml).is_ok());
759 }
760
761 #[test]
762 fn case_only_differing_roots_are_rejected() {
763 let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/music\"\n";
767 assert!(Config::from_toml(toml).is_err());
768 }
769
770 #[test]
771 fn case_only_nested_roots_are_rejected() {
772 let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/music/bob\"\n";
774 assert!(Config::from_toml(toml).is_err());
775 }
776
777 #[test]
778 fn nfc_nfd_differing_roots_are_rejected() {
779 let toml = "[accounts.alice]\nroot = \"/\u{00e9}toile\"\n\n[accounts.bob]\nroot = \"/e\u{0301}toile\"\n";
782 assert!(Config::from_toml(toml).is_err());
783 }
784
785 #[test]
786 fn genuinely_distinct_roots_are_still_accepted() {
787 let toml = "[accounts.alice]\nroot = \"/Music\"\n\n[accounts.bob]\nroot = \"/Videos\"\n";
790 assert!(Config::from_toml(toml).is_ok());
791 }
792
793 #[test]
794 fn normalise_root_folds_case_and_normalisation() {
795 assert_eq!(normalise_root("Foo/BAR"), normalise_root("foo/bar"));
799 assert_eq!(normalise_root("/\u{00e9}"), normalise_root("/e\u{0301}"));
800 assert_ne!(normalise_root("/music"), normalise_root("/musicfoo"));
801 assert!(!normalise_root("/musicfoo").starts_with(&normalise_root("/music")));
802 }
803
804 #[test]
807 fn empty_naming_template_is_rejected() {
808 assert!(Config::from_toml("[defaults]\nnaming_template = \"\"\n").is_err());
809 }
810
811 #[test]
812 fn whitespace_only_naming_template_is_rejected() {
813 assert!(Config::from_toml("[defaults]\nnaming_template = \" \"\n").is_err());
814 }
815
816 #[test]
817 fn empty_naming_template_in_account_is_rejected() {
818 assert!(Config::from_toml("[accounts.alice]\nnaming_template = \"\"\n").is_err());
819 }
820
821 #[test]
822 fn empty_naming_template_in_source_is_rejected() {
823 let toml = "[accounts.alice.sources.liked]\nnaming_template = \"\"\n";
824 assert!(Config::from_toml(toml).is_err());
825 }
826
827 #[test]
828 fn non_empty_naming_template_is_accepted() {
829 let toml = "[defaults]\nnaming_template = \"{creator}/{title}\"\n";
830 assert!(Config::from_toml(toml).is_ok());
831 }
832
833 #[test]
834 fn min_newest_zero_stays_legal() {
835 let cfg = Config::from_toml("[defaults]\nmin_newest = 0\n").unwrap();
838 assert_eq!(cfg.defaults.settings.min_newest, Some(0));
839 }
840}