pulsedeck 0.1.8

A cyber-synthwave internet radio player and smart tape recorder for your terminal
use super::*;
use crate::audio::AudioCommand;
use crate::system_trash;
use std::time::SystemTime;

impl App {
    pub(super) fn toggle_recording(&mut self) {
        if self.local_playback_path.is_some() && self.playing_url.is_none() {
            self.set_info_notice("Recording is only available for live streams");
            return;
        }

        if self.playing_url.is_none() {
            self.set_info_notice("Start playback before recording");
            return;
        }

        match self.recording_state {
            RecordingState::Off => {
                let station = self.now_playing().cloned();
                let category = station
                    .as_ref()
                    .map(|s| s.genre.clone())
                    .unwrap_or_else(|| "Unknown".to_string());
                let rec_dir = self.library.settings.recording_dir.clone();
                let keep_snippets = self.library.settings.keep_snippets;
                let min_secs = self.library.settings.min_song_duration_secs;

                self.recording_started_at = Some(SystemTime::now());
                self.recording_station_name = station.as_ref().map(|s| s.name.clone());
                self.recording_station_url = station.as_ref().map(|s| s.url.clone());
                self.recording_category = Some(category.clone());
                self.recording_recovery = None;
                self.recording_recovery_notice = None;
                self.write_recording_journal("pending");

                self.audio.send(AudioCommand::StartRecording {
                    recording_dir: rec_dir,
                    category,
                    keep_snippets,
                    min_song_duration_secs: min_secs,
                });
                self.recording_state = RecordingState::Pending;
                self.set_info_notice("Recording will start at next track boundary");
            }
            RecordingState::Pending | RecordingState::Active => {
                self.audio.send(AudioCommand::StopRecording);
                self.clear_recording_session();
                self.set_info_notice("Recording stopped");
            }
        }
    }
    pub(super) fn sync_recording_status(&mut self, state: u8, filepath: Option<String>) {
        self.recording_state = match state {
            1 => RecordingState::Pending,
            2 => RecordingState::Active,
            _ => RecordingState::Off,
        };
        self.active_record_filepath = filepath;

        match self.recording_state {
            RecordingState::Off => self.clear_recording_session(),
            RecordingState::Pending => {
                if self.recording_started_at.is_none() {
                    self.recording_started_at = Some(SystemTime::now());
                }
                self.write_recording_journal("pending");
            }
            RecordingState::Active => {
                if self.recording_started_at.is_none() {
                    self.recording_started_at = Some(SystemTime::now());
                }
                self.write_recording_journal("active");
            }
        }
    }

    pub(super) fn clear_recording_session(&mut self) {
        let recording_dir = self.library.settings.recording_dir.clone();
        let _ = crate::recording_journal::remove_session_journal(&recording_dir);

        self.recording_state = RecordingState::Off;
        self.active_record_filepath = None;
        self.recording_started_at = None;
        self.recording_station_name = None;
        self.recording_station_url = None;
        self.recording_category = None;
        self.recording_recovery = None;
        self.recording_recovery_notice = None;
    }

    fn write_recording_journal(&mut self, state: &str) {
        let Some(started_at) = self.recording_started_at else {
            return;
        };

        let recording_dir = self.library.settings.recording_dir.clone();
        let result = crate::recording_journal::write_session_journal(
            &recording_dir,
            self.recording_station_name.as_deref(),
            self.recording_station_url.as_deref(),
            self.recording_category.as_deref(),
            state,
            started_at,
            self.active_record_filepath.as_deref(),
            self.current_track.as_deref(),
        );

        if let Err(err) = result {
            self.set_error_notice(err);
        }
    }
    pub(super) fn keep_recording_recovery(&mut self) {
        let Some(recovery) = self.recording_recovery.clone() else {
            self.set_info_notice("No recording recovery journal is pending");
            return;
        };

        match crate::recording_journal::remove_journal_file(&recovery.journal_path) {
            Ok(()) => {
                self.clear_recording_recovery_state();
                self.set_info_notice("Recovered recording kept on disk");
            }
            Err(err) => self.set_error_notice(err),
        }
    }

    pub(super) fn dismiss_recording_recovery(&mut self) {
        let Some(recovery) = self.recording_recovery.clone() else {
            self.set_info_notice("No recording recovery journal is pending");
            return;
        };

        match crate::recording_journal::remove_journal_file(&recovery.journal_path) {
            Ok(()) => {
                self.clear_recording_recovery_state();
                self.set_info_notice("Recording recovery dismissed");
            }
            Err(err) => self.set_error_notice(err),
        }
    }

    pub(super) fn trash_recording_recovery(&mut self) {
        let Some(recovery) = self.recording_recovery.clone() else {
            self.set_info_notice("No recording recovery journal is pending");
            return;
        };

        let Some(active_file) = recovery.active_file_path() else {
            match crate::recording_journal::remove_journal_file(&recovery.journal_path) {
                Ok(()) => {
                    self.clear_recording_recovery_state();
                    self.set_info_notice(
                        "Recording recovery dismissed; no partial file was listed",
                    );
                }
                Err(err) => self.set_error_notice(err),
            }
            return;
        };

        if !active_file.exists() {
            match crate::recording_journal::remove_journal_file(&recovery.journal_path) {
                Ok(()) => {
                    self.clear_recording_recovery_state();
                    self.set_info_notice("Recovery journal cleared; partial file was not found");
                }
                Err(err) => self.set_error_notice(err),
            }
            return;
        }

        match system_trash::move_to_trash(&active_file) {
            Ok(()) => match crate::recording_journal::remove_journal_file(&recovery.journal_path) {
                Ok(()) => {
                    self.clear_recording_recovery_state();
                    self.tape_archive_scan_requested = true;
                    self.set_info_notice("Recovered partial recording moved to trash");
                }
                Err(err) => self.set_error_notice(err),
            },
            Err(err) => self.set_error_notice(format!(
                "Could not move recovered recording to trash: {err}"
            )),
        }
    }

    fn clear_recording_recovery_state(&mut self) {
        self.recording_recovery = None;
        self.recording_recovery_notice = None;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::favorites::Library;
    use crate::radio::Station;

    fn station(name: &str, url: &str) -> Station {
        Station {
            name: name.to_string(),
            url: url.to_string(),
            genre: "Synthwave".to_string(),
            country: "US".to_string(),
            bitrate: 128,
        }
    }

    fn test_app() -> App {
        let mut library = Library::in_memory(vec![station("A", "http://a")]);
        let unique = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        library.settings.recording_dir = std::env::temp_dir()
            .join(format!("pulsedeck-recording-test-{unique}"))
            .display()
            .to_string();
        App::new(library)
    }

    fn notice_text(app: &App) -> Option<&str> {
        match app.notice.as_ref() {
            Some(AppNotice::Info(message)) | Some(AppNotice::Error(message)) => Some(message),
            None => None,
        }
    }

    #[test]
    fn toggle_recording_without_playing_shows_notice() {
        let mut app = test_app();

        app.toggle_recording();

        assert_eq!(app.recording_state, RecordingState::Off);
        assert_eq!(notice_text(&app), Some("Start playback before recording"));
    }

    #[test]
    fn toggle_recording_while_local_tape_is_playing_shows_notice() {
        let mut app = test_app();
        app.local_playback_path = Some(std::path::PathBuf::from("recordings/tape.mp3"));

        app.toggle_recording();

        assert_eq!(app.recording_state, RecordingState::Off);
        assert_eq!(
            notice_text(&app),
            Some("Recording is only available for live streams")
        );
    }

    #[test]
    fn toggle_recording_when_playing_sets_pending_and_shows_notice() {
        let mut app = test_app();
        app.playing_url = Some("http://a".to_string());

        app.toggle_recording();

        assert_eq!(app.recording_state, RecordingState::Pending);
        assert_eq!(
            notice_text(&app),
            Some("Recording will start at next track boundary")
        );
    }

    #[test]
    fn toggle_recording_when_pending_turns_off_and_shows_notice() {
        let mut app = test_app();
        app.playing_url = Some("http://a".to_string());
        app.recording_state = RecordingState::Pending;
        app.active_record_filepath = Some("capture.mp3".to_string());

        app.toggle_recording();

        assert_eq!(app.recording_state, RecordingState::Off);
        assert_eq!(app.active_record_filepath, None);
        assert_eq!(notice_text(&app), Some("Recording stopped"));
    }

    #[test]
    fn recording_status_changed_active_updates_dashboard_fields() {
        let mut app = test_app();

        app.sync_recording_status(2, Some("recordings/Synthwave/capture.mp3".to_string()));

        assert_eq!(app.recording_state, RecordingState::Active);
        assert_eq!(
            app.active_record_filepath.as_deref(),
            Some("recordings/Synthwave/capture.mp3")
        );
        assert!(app.recording_started_at.is_some());
    }

    #[test]
    fn clearing_recording_session_resets_dashboard_fields() {
        let mut app = test_app();
        app.recording_state = RecordingState::Active;
        app.active_record_filepath = Some("capture.mp3".to_string());
        app.recording_started_at = Some(std::time::SystemTime::now());
        app.recording_station_name = Some("A".to_string());

        app.clear_recording_session();

        assert_eq!(app.recording_state, RecordingState::Off);
        assert!(app.active_record_filepath.is_none());
        assert!(app.recording_started_at.is_none());
        assert!(app.recording_station_name.is_none());
    }
}