about summary refs log tree commit diff stats
path: root/crates/yt/src/commands/select/implm
diff options
context:
space:
mode:
Diffstat (limited to 'crates/yt/src/commands/select/implm')
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/help.str12
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/help.str.license10
-rw-r--r--crates/yt/src/commands/select/implm/fs_generators/mod.rs345
-rw-r--r--crates/yt/src/commands/select/implm/mod.rs42
-rw-r--r--crates/yt/src/commands/select/implm/standalone/add.rs186
-rw-r--r--crates/yt/src/commands/select/implm/standalone/mod.rs122
6 files changed, 717 insertions, 0 deletions
diff --git a/crates/yt/src/commands/select/implm/fs_generators/help.str b/crates/yt/src/commands/select/implm/fs_generators/help.str
new file mode 100644
index 0000000..e3cc347
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/fs_generators/help.str
@@ -0,0 +1,12 @@
+# Commands:
+#   w,  watch    [-p,-s,-l]   Mark the video given by the hash to be watched
+#   wd, watched  [-p,-s,-l]   Mark the video given by the hash as already watched
+#   d,  drop     [-p,-s,-l]   Mark the video given by the hash to be dropped
+#   u,  url      [-p,-s,-l]   Open the video URL in Firefox's `timesinks.youtube` profile
+#   p,  pick     [-p,-s,-l]   Reset the videos status to 'Pick'
+#   a,  add      URL          Add a video, defined by the URL
+#
+# See `yt select <cmd_name> --help` for more help.
+#
+# These lines can be re-ordered; they are executed from top to bottom.
+# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn= nowrap
diff --git a/crates/yt/src/commands/select/implm/fs_generators/help.str.license b/crates/yt/src/commands/select/implm/fs_generators/help.str.license
new file mode 100644
index 0000000..a0e196c
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/fs_generators/help.str.license
@@ -0,0 +1,10 @@
+yt - A fully featured command line YouTube client
+
+Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+SPDX-License-Identifier: GPL-3.0-or-later
+
+This file is part of Yt.
+
+You should have received a copy of the License along with this program.
+If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
diff --git a/crates/yt/src/commands/select/implm/fs_generators/mod.rs b/crates/yt/src/commands/select/implm/fs_generators/mod.rs
new file mode 100644
index 0000000..8ccda3c
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/fs_generators/mod.rs
@@ -0,0 +1,345 @@
+use std::{
+    collections::HashMap,
+    env,
+    fs::{self, File, OpenOptions},
+    io::{BufRead, BufReader, BufWriter, Read, Write},
+    iter,
+    os::fd::{AsFd, AsRawFd},
+    path::Path,
+};
+
+use crate::{
+    app::App,
+    cli::CliArgs,
+    commands::{
+        Command,
+        select::{
+            SelectCommand, SelectSplitSortKey, SelectSplitSortMode,
+            implm::standalone::{self, handle_select_cmd},
+        },
+    },
+    storage::db::{
+        extractor_hash::ExtractorHash,
+        insert::Operations,
+        video::{Video, VideoStatusMarker},
+    },
+};
+
+use anyhow::{Context, Result, bail};
+use clap::Parser;
+use futures::{TryStreamExt, stream::FuturesOrdered};
+use log::info;
+use shlex::Shlex;
+use tokio::process;
+
+const HELP_STR: &str = include_str!("./help.str");
+
+pub(crate) async fn select_split(
+    app: &App,
+    done: bool,
+    sort_key: SelectSplitSortKey,
+    sort_mode: SelectSplitSortMode,
+) -> Result<()> {
+    let temp_dir = tempfile::Builder::new()
+        .prefix("yt_video_select-")
+        .rand_bytes(6)
+        .tempdir()
+        .context("Failed to get tempdir")?;
+
+    let matching_videos = get_videos(app, done).await?;
+
+    let mut no_author = vec![];
+    let mut author_map = HashMap::new();
+    for video in matching_videos {
+        if let Some(sub) = &video.parent_subscription_name {
+            if author_map.contains_key(sub) {
+                let vec: &mut Vec<_> = author_map
+                    .get_mut(sub)
+                    .expect("This key is set, we checked in the if above");
+
+                vec.push(video);
+            } else {
+                author_map.insert(sub.to_owned(), vec![video]);
+            }
+        } else {
+            no_author.push(video);
+        }
+    }
+
+    let author_map = {
+        let mut temp_vec: Vec<_> = author_map.into_iter().collect();
+
+        match sort_key {
+            SelectSplitSortKey::Publisher => {
+                // PERFORMANCE: The clone here should not be neeed.  <2025-06-15>
+                temp_vec.sort_by_key(|(name, _): &(String, Vec<Video>)| name.to_owned());
+            }
+            SelectSplitSortKey::Videos => {
+                temp_vec.sort_by_key(|(_, videos): &(String, Vec<Video>)| videos.len());
+            }
+        }
+
+        match sort_mode {
+            SelectSplitSortMode::Asc => {
+                // Std's default mode is ascending.
+            }
+            SelectSplitSortMode::Desc => {
+                temp_vec.reverse();
+            }
+        }
+
+        temp_vec
+    };
+
+    for (index, (name, videos)) in author_map
+        .into_iter()
+        .chain(iter::once((
+            "<No parent subscription>".to_owned(),
+            no_author,
+        )))
+        .enumerate()
+    {
+        let mut file_path = temp_dir.path().join(format!("{index:02}_{name}"));
+        file_path.set_extension("yts");
+
+        let tmp_file = File::create(&file_path)
+            .with_context(|| format!("Falied to create file at: {}", file_path.display()))?;
+
+        write_videos_to_file(app, &tmp_file, &videos)
+            .await
+            .with_context(|| format!("Falied to populate file at: {}", file_path.display()))?;
+    }
+
+    open_editor_at(temp_dir.path()).await?;
+
+    let mut paths = vec![];
+    for maybe_entry in temp_dir
+        .path()
+        .read_dir()
+        .context("Failed to open temp dir for reading")?
+    {
+        let entry = maybe_entry.context("Failed to read entry in temp dir")?;
+
+        if !entry.file_type()?.is_file() {
+            bail!("Found non-file entry: {}", entry.path().display());
+        }
+
+        paths.push(entry.path());
+    }
+
+    paths.sort();
+
+    let mut persistent_file = OpenOptions::new()
+        .read(false)
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .open(&app.config.paths.last_selection_path)
+        .context("Failed to open persistent selection file")?;
+
+    for path in paths {
+        let mut read_file = File::open(path)?;
+
+        let mut buffer = vec![];
+        read_file.read_to_end(&mut buffer)?;
+        persistent_file.write_all(&buffer)?;
+    }
+
+    persistent_file.flush()?;
+    let persistent_file = OpenOptions::new()
+        .read(true)
+        .open(format!(
+            "/proc/self/fd/{}",
+            persistent_file.as_fd().as_raw_fd()
+        ))
+        .context("Failed to re-open persistent file")?;
+
+    let processed = process_file(app, &persistent_file).await?;
+
+    info!("Processed {processed} records.");
+    temp_dir.close().context("Failed to close the temp dir")?;
+    Ok(())
+}
+
+pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> {
+    let temp_file = tempfile::Builder::new()
+        .prefix("yt_video_select-")
+        .suffix(".yts")
+        .rand_bytes(6)
+        .tempfile()
+        .context("Failed to get tempfile")?;
+
+    if use_last_selection {
+        fs::copy(&app.config.paths.last_selection_path, &temp_file)?;
+    } else {
+        let matching_videos = get_videos(app, done).await?;
+
+        write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?;
+    }
+
+    open_editor_at(temp_file.path()).await?;
+
+    let read_file = OpenOptions::new().read(true).open(temp_file.path())?;
+    fs::copy(temp_file.path(), &app.config.paths.last_selection_path)
+        .context("Failed to persist selection file")?;
+
+    let processed = process_file(app, &read_file).await?;
+    info!("Processed {processed} records.");
+
+    Ok(())
+}
+
+async fn get_videos(app: &App, include_done: bool) -> Result<Vec<Video>> {
+    if include_done {
+        Video::in_states(app, VideoStatusMarker::ALL).await
+    } else {
+        Video::in_states(
+            app,
+            &[
+                VideoStatusMarker::Pick,
+                //
+                VideoStatusMarker::Watch,
+                VideoStatusMarker::Cached,
+            ],
+        )
+        .await
+    }
+}
+
+async fn write_videos_to_file(app: &App, file: &File, videos: &[Video]) -> Result<()> {
+    // Warm-up the cache for the display rendering of the videos.
+    // Otherwise the futures would all try to warm it up at the same time.
+    if let Some(vid) = videos.first() {
+        drop(vid.to_line_display(app, None).await?);
+    }
+
+    let mut edit_file = BufWriter::new(file);
+
+    videos
+        .iter()
+        .map(|vid| vid.to_select_file_display(app))
+        .collect::<FuturesOrdered<_>>()
+        .try_collect::<Vec<String>>()
+        .await?
+        .into_iter()
+        .try_for_each(|line| -> Result<()> {
+            edit_file
+                .write_all(line.as_bytes())
+                .context("Failed to write to `edit_file`")?;
+
+            Ok(())
+        })?;
+
+    edit_file.write_all(HELP_STR.as_bytes())?;
+    edit_file.flush().context("Failed to flush edit file")?;
+
+    Ok(())
+}
+
+async fn process_file(app: &App, file: &File) -> Result<i64> {
+    let mut line_number = 0;
+
+    let mut ops = Operations::new("Select: process file");
+
+    // Fetch all the hashes once, instead of every time we need to process a line.
+    let all_hashes = ExtractorHash::get_all(app).await?;
+
+    let reader = BufReader::new(file);
+    for line in reader.lines() {
+        let line = line.context("Failed to read a line")?;
+
+        if let Some(line) = process_line(&line)? {
+            line_number -= 1;
+
+            // debug!(
+            //     "Parsed command: `{}`",
+            //     line.iter()
+            //         .map(|val| format!("\"{}\"", val))
+            //         .collect::<Vec<String>>()
+            //         .join(" ")
+            // );
+
+            let arg_line = ["yt", "select"]
+                .into_iter()
+                .chain(line.iter().map(String::as_str));
+
+            let args = CliArgs::parse_from(arg_line);
+
+            let Command::Select { cmd } = args
+                .command
+                .expect("This will be some, as we constructed it above.")
+            else {
+                unreachable!("This is checked in the `filter_line` function")
+            };
+
+            match cmd.expect(
+                "This value should always be some \
+                    here, as it would otherwise thrown an error above.",
+            ) {
+                SelectCommand::File { .. } | SelectCommand::Split { .. } => {
+                    bail!("You cannot use `select file` or `select split` recursively.")
+                }
+                SelectCommand::Add { urls, start, stop } => {
+                    Box::pin(standalone::add::add(app, urls, start, stop)).await?;
+                }
+                other => {
+                    let shared = other
+                        .clone()
+                        .into_shared()
+                        .expect("The ones without shared should have been filtered out.");
+
+                    let hash = shared.hash.realize(app, Some(&all_hashes)).await?;
+                    let mut video = hash
+                        .get_with_app(app)
+                        .await
+                        .expect("The hash was already realized, it should therefore exist");
+
+                    handle_select_cmd(app, other, &mut video, Some(line_number), &mut ops).await?;
+                }
+            }
+        }
+    }
+
+    ops.commit(app).await?;
+    Ok(-line_number)
+}
+
+async fn open_editor_at(path: &Path) -> Result<()> {
+    let editor = env::var("EDITOR").unwrap_or("nvim".to_owned());
+
+    let mut nvim = process::Command::new(&editor);
+    nvim.arg(path);
+    let status = nvim
+        .status()
+        .await
+        .with_context(|| format!("Falied to run editor: {editor}"))?;
+
+    if status.success() {
+        Ok(())
+    } else {
+        bail!("Editor ({editor}) exited with error status: {}", status)
+    }
+}
+
+fn process_line(line: &str) -> Result<Option<Vec<String>>> {
+    // Filter out comments and empty lines
+    if line.starts_with('#') || line.trim().is_empty() {
+        Ok(None)
+    } else {
+        let split: Vec<_> = {
+            let mut shl = Shlex::new(line);
+            let res = shl.by_ref().collect();
+
+            if shl.had_error {
+                bail!("Failed to parse line '{line}'")
+            }
+
+            assert_eq!(shl.line_no, 1, "A unexpected newline appeared");
+            res
+        };
+
+        assert!(!split.is_empty());
+
+        Ok(Some(split))
+    }
+}
diff --git a/crates/yt/src/commands/select/implm/mod.rs b/crates/yt/src/commands/select/implm/mod.rs
new file mode 100644
index 0000000..755076c
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/mod.rs
@@ -0,0 +1,42 @@
+use crate::{app::App, commands::select::SelectCommand, storage::db::insert::Operations};
+
+use anyhow::Result;
+
+mod fs_generators;
+mod standalone;
+
+impl SelectCommand {
+    pub(crate) async fn implm(self, app: &App) -> Result<()> {
+        match self {
+            SelectCommand::File {
+                done,
+                use_last_selection,
+            } => Box::pin(fs_generators::select_file(&app, done, use_last_selection)).await?,
+            SelectCommand::Split {
+                done,
+                sort_key,
+                sort_mode,
+            } => Box::pin(fs_generators::select_split(&app, done, sort_key, sort_mode)).await?,
+            SelectCommand::Add { urls, start, stop } => {
+                Box::pin(standalone::add::add(&app, urls, start, stop)).await?;
+            }
+            other => {
+                let shared = other
+                    .clone()
+                    .into_shared()
+                    .expect("The ones without shared should have been filtered out.");
+                let hash = shared.hash.realize(&app, None).await?;
+                let mut video = hash
+                    .get_with_app(&app)
+                    .await
+                    .expect("The hash was already realized, it should therefore exist");
+
+                let mut ops = Operations::new("Main: handle select cmd");
+                standalone::handle_select_cmd(&app, other, &mut video, None, &mut ops).await?;
+                ops.commit(&app).await?;
+            }
+        }
+
+        Ok(())
+    }
+}
diff --git a/crates/yt/src/commands/select/implm/standalone/add.rs b/crates/yt/src/commands/select/implm/standalone/add.rs
new file mode 100644
index 0000000..ec32039
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/standalone/add.rs
@@ -0,0 +1,186 @@
+// yt - A fully featured command line YouTube client
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Yt.
+//
+// You should have received a copy of the License along with this program.
+// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+use crate::{
+    app::App,
+    storage::db::{extractor_hash::ExtractorHash, insert::Operations, video::Video},
+    yt_dlp::yt_dlp_opts_updating,
+};
+
+use anyhow::{Context, Result, bail};
+use log::{error, warn};
+use url::Url;
+use yt_dlp::{YoutubeDL, info_json::InfoJson, json_cast, json_get};
+
+#[allow(clippy::too_many_lines)]
+pub(crate) async fn add(
+    app: &App,
+    urls: Vec<Url>,
+    start: Option<usize>,
+    stop: Option<usize>,
+) -> Result<()> {
+    for url in urls {
+        async fn process_and_add(app: &App, entry: InfoJson, yt_dlp: &YoutubeDL) -> Result<()> {
+            let url = json_get!(entry, "url", as_str).parse()?;
+
+            let entry = yt_dlp
+                .extract_info(&url, false, true)
+                .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?;
+
+            add_entry(app, entry).await?;
+
+            Ok(())
+        }
+
+        async fn add_entry(app: &App, entry: InfoJson) -> Result<()> {
+            // We have to re-fetch all hashes every time, because a user could try to add the same
+            // URL twice (for whatever reason.)
+            let hashes = ExtractorHash::get_all(app)
+                .await
+                .context("Failed to fetch all video hashes")?;
+
+            let extractor_hash = ExtractorHash::from_info_json(&entry);
+            if hashes.contains(&extractor_hash) {
+                error!(
+                    "Video '{}'{} is already in the database. Skipped adding it",
+                    extractor_hash
+                        .as_short_hash(app)
+                        .await
+                        .with_context(|| format!(
+                            "Failed to format hash of video '{}' as short hash",
+                            entry
+                                .get("url")
+                                .map_or("<Unknown video Url>".to_owned(), ToString::to_string)
+                        ))?,
+                    entry.get("title").map_or(String::new(), |title| format!(
+                        " (\"{}\")",
+                        json_cast!(title, as_str)
+                    ))
+                );
+                return Ok(());
+            }
+
+            let mut ops = Operations::new("SelectAdd: Video entry to video");
+            let video = Video::from_info_json(&entry, None)?.add(&mut ops)?;
+            ops.commit(app).await?;
+
+            println!("{}", &video.to_line_display(app, None).await?);
+
+            Ok(())
+        }
+
+        let yt_dlp = yt_dlp_opts_updating(start.unwrap_or(0) + stop.unwrap_or(0))?;
+
+        let entry = yt_dlp
+            .extract_info(&url, false, true)
+            .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?;
+
+        match entry.get("_type").map(|val| json_cast!(val, as_str)) {
+            Some("video") => {
+                add_entry(app, entry).await?;
+                if start.is_some() || stop.is_some() {
+                    warn!(
+                        "You added `start` and/or `stop` markers for a single *video*! These will be ignored."
+                    );
+                }
+            }
+            Some("playlist") => {
+                if let Some(entries) = entry.get("entries") {
+                    let entries = json_cast!(entries, as_array);
+                    let start = start.unwrap_or(0);
+                    let stop = stop.unwrap_or(entries.len() - 1);
+
+                    let respected_entries =
+                        take_vector(entries, start, stop).with_context(|| {
+                            format!(
+                                "Failed to take entries starting at: {start} and ending with {stop}"
+                            )
+                        })?;
+
+                    if respected_entries.is_empty() {
+                        warn!("No entries found, after applying your start/stop limits.");
+                    } else {
+                        // Pre-warm the cache
+                        process_and_add(
+                            app,
+                            json_cast!(respected_entries[0], as_object).to_owned(),
+                            &yt_dlp,
+                        )
+                        .await?;
+                        let respected_entries = &respected_entries[1..];
+
+                        let futures: Vec<_> = respected_entries
+                            .iter()
+                            .map(|entry| {
+                                process_and_add(
+                                    app,
+                                    json_cast!(entry, as_object).to_owned(),
+                                    &yt_dlp,
+                                )
+                            })
+                            .collect();
+
+                        for fut in futures {
+                            fut.await?;
+                        }
+                    }
+                } else {
+                    bail!("Your playlist does not seem to have any entries!")
+                }
+            }
+            other => bail!(
+                "Your URL should point to a video or a playlist, but points to a '{:#?}'",
+                other
+            ),
+        }
+    }
+
+    Ok(())
+}
+
+fn take_vector<T>(vector: &[T], start: usize, stop: usize) -> Result<&[T]> {
+    let length = vector.len();
+
+    if stop >= length {
+        bail!(
+            "Your stop marker ({stop}) exceeds the possible entries ({length})! Remember that it is zero indexed."
+        );
+    }
+
+    Ok(&vector[start..=stop])
+}
+
+#[cfg(test)]
+mod test {
+    use super::take_vector;
+
+    #[test]
+    fn test_vector_take() {
+        let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+        let new_vec = take_vector(&vec, 2, 8).unwrap();
+
+        assert_eq!(new_vec, vec![2, 3, 4, 5, 6, 7, 8]);
+    }
+
+    #[test]
+    fn test_vector_take_overflow() {
+        let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+        assert!(take_vector(&vec, 0, 12).is_err());
+    }
+
+    #[test]
+    fn test_vector_take_equal() {
+        let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
+
+        assert!(take_vector(&vec, 0, 11).is_err());
+    }
+}
diff --git a/crates/yt/src/commands/select/implm/standalone/mod.rs b/crates/yt/src/commands/select/implm/standalone/mod.rs
new file mode 100644
index 0000000..dd6de45
--- /dev/null
+++ b/crates/yt/src/commands/select/implm/standalone/mod.rs
@@ -0,0 +1,122 @@
+use std::io::{Write, stderr};
+
+use crate::{
+    ansi_escape_codes,
+    app::App,
+    commands::select::{SelectCommand, SharedSelectionCommandArgs},
+    storage::db::{
+        insert::{Operations, video::Operation},
+        video::{Priority, Video, VideoStatus},
+    },
+};
+
+use anyhow::{Context, Result, bail};
+
+pub(super) mod add;
+
+pub(crate) async fn handle_select_cmd(
+    app: &App,
+    cmd: SelectCommand,
+    video: &mut Video,
+    line_number: Option<i64>,
+    ops: &mut Operations<Operation>,
+) -> Result<()> {
+    let status = match cmd {
+        SelectCommand::Pick { shared } => Some((VideoStatus::Pick, shared)),
+        SelectCommand::Drop { shared } => Some((VideoStatus::Drop, shared)),
+        SelectCommand::Watched { shared } => Some((VideoStatus::Watched, shared)),
+        SelectCommand::Watch { shared } => {
+            if let VideoStatus::Cached {
+                cache_path,
+                is_focused,
+            } = &video.status
+            {
+                Some((
+                    VideoStatus::Cached {
+                        cache_path: cache_path.to_owned(),
+                        is_focused: *is_focused,
+                    },
+                    shared,
+                ))
+            } else {
+                Some((VideoStatus::Watch, shared))
+            }
+        }
+        SelectCommand::Url { shared } => {
+            let Some(url) = shared.url else {
+                bail!("You need to provide a url to `select url ..`")
+            };
+
+            let mut firefox = std::process::Command::new("firefox");
+            firefox.args(["-P", "timesinks.youtube"]);
+            firefox.arg(url.as_str());
+            let _handle = firefox.spawn().context("Failed to run firefox")?;
+            None
+        }
+        SelectCommand::File { .. } | SelectCommand::Split { .. } | SelectCommand::Add { .. } => {
+            unreachable!("These should have been filtered out")
+        }
+    };
+
+    if let Some((status, shared)) = status {
+        handle_status_change(
+            app,
+            video,
+            shared,
+            line_number,
+            status,
+            line_number.is_none(),
+            ops,
+        )
+        .await?;
+    }
+
+    Ok(())
+}
+
+async fn handle_status_change(
+    app: &App,
+    video: &mut Video,
+    shared: SharedSelectionCommandArgs,
+    line_number: Option<i64>,
+    new_status: VideoStatus,
+    is_single: bool,
+    ops: &mut Operations<Operation>,
+) -> Result<()> {
+    let priority = compute_priority(line_number, shared.priority);
+
+    video.set_status(new_status, ops);
+    if let Some(priority) = priority {
+        video.set_priority(priority, ops);
+    }
+
+    if let Some(subtitle_langs) = shared.subtitle_langs {
+        video.set_subtitle_langs(subtitle_langs, ops);
+    }
+    if let Some(playback_speed) = shared.playback_speed {
+        video.set_playback_speed(playback_speed, ops);
+    }
+
+    if !is_single {
+        ansi_escape_codes::clear_whole_line();
+        ansi_escape_codes::move_to_col(1);
+    }
+
+    eprint!("{}", &video.to_line_display(app, None).await?);
+
+    if is_single {
+        eprintln!();
+    } else {
+        stderr().flush()?;
+    }
+
+    Ok(())
+}
+
+fn compute_priority(line_number: Option<i64>, priority: Option<i64>) -> Option<Priority> {
+    if let Some(pri) = priority {
+        Some(Priority::from(pri))
+    } else {
+        line_number.map(Priority::from)
+    }
+}