Skip to main content

suno_core/
extras.rs

1//! Pure "media extras" generators: M3U8 playlists.
2//!
3//! Every function here is pure. It takes clip data plus relative paths and
4//! returns the text the CLI writes to disk later, with no IO, no clock, and no
5//! network, so the logic stays deterministic and is unit-tested in isolation.
6
7use std::fmt::Write as _;
8
9/// One ordered entry in an extended-M3U8 playlist.
10///
11/// Order is significant: the liked and playlist ordering is preserved exactly
12/// as given.
13///
14/// An **empty `relative_path` marks a member absent from the local library**
15/// (Liked from another creator, or filtered out by `--limit`/`--since`). Such an
16/// entry renders as a `# (not in library) <title>` comment line rather than an
17/// `#EXTINF` + path pair, so the playlist never carries a dangling path
18/// (HARDENING L1). A present member always has a non-empty relative path.
19#[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
26/// Render an extended-M3U8 playlist named `name` from `entries`, preserving
27/// their order.
28///
29/// The output opens with the `#EXTM3U` header and a `#PLAYLIST:<name>` line,
30/// then per entry emits either an `#EXTINF:<seconds>,<title>` line followed by
31/// the relative path line (a member present in the library), or a
32/// `# (not in library) <title>` comment line (an [`M3u8Entry`] with an empty
33/// relative path — HARDENING L1). Seconds are rounded to the nearest whole
34/// number. Carriage returns and line feeds in the name, title, and path are
35/// folded to spaces so a single field can never break the line structure.
36pub 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            // L1: a member absent from the local library — a comment, never a
43            // dangling path line.
44            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
54/// Round a duration in seconds to the nearest whole second for `#EXTINF`.
55///
56/// Non-finite inputs fold to `0` so the playlist line stays well-formed.
57fn 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
65/// Fold carriage returns and line feeds to spaces, keeping the value on one line
66/// so it cannot break the surrounding text format.
67fn 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        // Header, playlist name, one EXTINF line, one path line, trailing newline.
124        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        // L1: an empty relative path means the member is not in the local
141        // library, so it is a comment line with no #EXTINF and no path.
142        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        // The absent member never contributes a bare path line.
171        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}