1use std::fmt::Write as _;
8
9#[derive(Debug, Clone, Copy)]
20pub struct M3u8Entry<'a> {
21 pub title: &'a str,
22 pub duration_secs: f64,
23 pub relative_path: &'a str,
24}
25
26pub fn render_m3u8(name: &str, entries: &[M3u8Entry<'_>]) -> String {
37 let mut out = String::from("#EXTM3U\n");
38 let _ = writeln!(out, "#PLAYLIST:{}", to_single_line(name));
39 for entry in entries {
40 let title = to_single_line(entry.title);
41 if entry.relative_path.is_empty() {
42 let _ = writeln!(out, "# (not in library) {title}");
45 continue;
46 }
47 let path = to_single_line(entry.relative_path);
48 let seconds = extinf_seconds(entry.duration_secs);
49 let _ = write!(out, "#EXTINF:{seconds},{title}\n{path}\n");
50 }
51 out
52}
53
54fn extinf_seconds(duration_secs: f64) -> i64 {
58 if duration_secs.is_finite() {
59 duration_secs.round() as i64
60 } else {
61 0
62 }
63}
64
65fn to_single_line(text: &str) -> String {
68 text.replace('\r', "").replace('\n', " ")
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn m3u8_preserves_order_and_rounds_extinf() {
77 let entries = [
78 M3u8Entry {
79 title: "First",
80 duration_secs: 211.6,
81 relative_path: "Artist/Album/First.flac",
82 },
83 M3u8Entry {
84 title: "Second, Take",
85 duration_secs: 90.5,
86 relative_path: "Artist/Album/Second.flac",
87 },
88 M3u8Entry {
89 title: "Third\nLine",
90 duration_secs: 30.2,
91 relative_path: "Artist/Album/Third.flac",
92 },
93 ];
94
95 let rendered = render_m3u8("Road Trip", &entries);
96
97 let expected = "#EXTM3U\n\
98 #PLAYLIST:Road Trip\n\
99 #EXTINF:212,First\n\
100 Artist/Album/First.flac\n\
101 #EXTINF:91,Second, Take\n\
102 Artist/Album/Second.flac\n\
103 #EXTINF:30,Third Line\n\
104 Artist/Album/Third.flac\n";
105 assert_eq!(rendered, expected);
106 }
107
108 #[test]
109 fn m3u8_strips_newlines_but_keeps_commas() {
110 let entries = [M3u8Entry {
111 title: "Hello, World\r\nSecond, Line",
112 duration_secs: 12.0,
113 relative_path: "Artist/Track.flac",
114 }];
115
116 let rendered = render_m3u8("Mix", &entries);
117
118 assert_eq!(
119 rendered,
120 "#EXTM3U\n#PLAYLIST:Mix\n#EXTINF:12,Hello, World Second, Line\nArtist/Track.flac\n"
121 );
122 assert!(!rendered.contains('\r'));
123 assert_eq!(rendered.lines().count(), 4);
125 }
126
127 #[test]
128 fn m3u8_folds_newlines_in_the_playlist_name() {
129 let rendered = render_m3u8("Road\r\nTrip", &[]);
130 assert_eq!(rendered, "#EXTM3U\n#PLAYLIST:Road Trip\n");
131 }
132
133 #[test]
134 fn m3u8_empty_list_is_header_and_name_only() {
135 assert_eq!(render_m3u8("Empty", &[]), "#EXTM3U\n#PLAYLIST:Empty\n");
136 }
137
138 #[test]
139 fn m3u8_absent_member_renders_a_comment_not_a_path() {
140 let entries = [
143 M3u8Entry {
144 title: "In Library",
145 duration_secs: 60.0,
146 relative_path: "Artist/In.flac",
147 },
148 M3u8Entry {
149 title: "Missing, Song",
150 duration_secs: 42.0,
151 relative_path: "",
152 },
153 M3u8Entry {
154 title: "Also Present",
155 duration_secs: 30.0,
156 relative_path: "Artist/Also.flac",
157 },
158 ];
159
160 let rendered = render_m3u8("Liked Songs", &entries);
161
162 let expected = "#EXTM3U\n\
163 #PLAYLIST:Liked Songs\n\
164 #EXTINF:60,In Library\n\
165 Artist/In.flac\n\
166 # (not in library) Missing, Song\n\
167 #EXTINF:30,Also Present\n\
168 Artist/Also.flac\n";
169 assert_eq!(rendered, expected);
170 assert!(!rendered.contains("#EXTINF:42"));
172 }
173
174 #[test]
175 fn m3u8_non_finite_duration_is_zero() {
176 let entries = [M3u8Entry {
177 title: "Unknown",
178 duration_secs: f64::NAN,
179 relative_path: "Artist/Unknown.flac",
180 }];
181
182 assert_eq!(
183 render_m3u8("Odd", &entries),
184 "#EXTM3U\n#PLAYLIST:Odd\n#EXTINF:0,Unknown\nArtist/Unknown.flac\n"
185 );
186 }
187}