diff options
author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-08-20 18:30:22 +0200 |
---|---|---|
committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2024-08-20 18:30:22 +0200 |
commit | 664315ead49e94a6be4402f97aef86b27477a5b2 (patch) | |
tree | adbafbd7f33b12092e8ce03120af333856d6e7dc /pkgs | |
parent | build(pkgs/yt): Update (diff) | |
download | nixos-config-664315ead49e94a6be4402f97aef86b27477a5b2.tar.gz nixos-config-664315ead49e94a6be4402f97aef86b27477a5b2.zip |
fix(pkgs/yt): Fix some more usage problems
Diffstat (limited to 'pkgs')
-rw-r--r-- | pkgs/by-name/yt/yt/src/cache/mod.rs | 11 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/cli.rs | 11 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/main.rs | 2 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/select/mod.rs | 36 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/select/selection_file/display.rs | 10 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/select/selection_file/help.str | 10 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/status/mod.rs | 73 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/storage/video_database/getters.rs | 16 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/storage/video_database/mod.rs | 2 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/storage/video_database/schema.sql | 18 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/storage/video_database/setters.rs | 33 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/watch/events.rs | 46 | ||||
-rw-r--r-- | pkgs/by-name/yt/yt/src/watch/mod.rs | 3 |
13 files changed, 220 insertions, 51 deletions
diff --git a/pkgs/by-name/yt/yt/src/cache/mod.rs b/pkgs/by-name/yt/yt/src/cache/mod.rs index 87f42604..6aa3a161 100644 --- a/pkgs/by-name/yt/yt/src/cache/mod.rs +++ b/pkgs/by-name/yt/yt/src/cache/mod.rs @@ -5,7 +5,8 @@ use tokio::fs; use crate::{ app::App, storage::video_database::{ - downloader::set_video_cache_path, getters::get_videos, Video, VideoStatus, + downloader::set_video_cache_path, getters::get_videos, setters::set_state_change, Video, + VideoStatus, }, }; @@ -25,7 +26,7 @@ async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> { } pub async fn invalidate(app: &App, hard: bool) -> Result<()> { - let all_cached_things = get_videos(app, &[VideoStatus::Cached]).await?; + let all_cached_things = get_videos(app, &[VideoStatus::Cached], None).await?; info!("Got videos to invalidate: '{}'", all_cached_things.len()); @@ -52,7 +53,7 @@ pub async fn maintain(app: &App, all: bool) -> Result<()> { vec![VideoStatus::Watch, VideoStatus::Cached] }; - let cached_videos = get_videos(app, domain.as_slice()).await?; + let cached_videos = get_videos(app, domain.as_slice(), None).await?; for vid in cached_videos { if let Some(path) = vid.cache_path.as_ref() { @@ -61,6 +62,10 @@ pub async fn maintain(app: &App, all: bool) -> Result<()> { invalidate_video(app, &vid, false).await?; } } + if vid.status_change { + info!("Video '{}' has it's changing bit set. This is probably the result of an unexpectet exit. Clearing it", vid.title); + set_state_change(app, &vid.extractor_hash, false).await?; + } } Ok(()) diff --git a/pkgs/by-name/yt/yt/src/cli.rs b/pkgs/by-name/yt/yt/src/cli.rs index 932712cc..45590d56 100644 --- a/pkgs/by-name/yt/yt/src/cli.rs +++ b/pkgs/by-name/yt/yt/src/cli.rs @@ -53,7 +53,8 @@ pub enum Command { Description {}, /// Manipulate the video cache in the database - Cache { + #[command(visible_alias = "db")] + Database { #[command(subcommand)] command: CacheCommand, }, @@ -137,6 +138,7 @@ pub struct SharedSelectionCommandArgs { #[derive(Subcommand, Clone, Debug)] #[command(infer_subcommands = true)] +// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20> pub enum SelectCommand { /// Open a `git rebase` like file to select the videos to watch (the default) File { @@ -201,7 +203,12 @@ pub enum CacheCommand { hard: bool, }, - /// Check every path for validity (removing all invalid cache entries) + /// Perform basic maintenance operations on the database. + /// This helps recovering from invalid db states after a crash (or force exit via CTRL+C). + /// + /// 1. Check every path for validity (removing all invalid cache entries) + /// 2. Reset all `status_change` bits of videos to false. + #[command(verbatim_doc_comment)] Maintain { /// Check every video (otherwise only the videos to be watched are checked) #[arg(short, long)] diff --git a/pkgs/by-name/yt/yt/src/main.rs b/pkgs/by-name/yt/yt/src/main.rs index 26af85b2..302fba1e 100644 --- a/pkgs/by-name/yt/yt/src/main.rs +++ b/pkgs/by-name/yt/yt/src/main.rs @@ -102,7 +102,7 @@ async fn main() -> Result<()> { Command::Status {} => status::show(&app).await?, - Command::Cache { command } => match command { + Command::Database { command } => match command { CacheCommand::Invalidate { hard } => cache::invalidate(&app, hard).await?, CacheCommand::Maintain { all } => cache::maintain(&app, all).await?, }, diff --git a/pkgs/by-name/yt/yt/src/select/mod.rs b/pkgs/by-name/yt/yt/src/select/mod.rs index 9c250909..2dfefef9 100644 --- a/pkgs/by-name/yt/yt/src/select/mod.rs +++ b/pkgs/by-name/yt/yt/src/select/mod.rs @@ -1,7 +1,8 @@ use std::{ - env, fs, - io::{BufRead, BufReader, BufWriter, Write}, - process::Command, + env::{self}, + fs, + io::{BufRead, Write}, + io::{BufReader, BufWriter}, }; use crate::{ @@ -17,6 +18,7 @@ use cmds::handle_select_cmd; use futures::future::join_all; use selection_file::process_line; use tempfile::Builder; +use tokio::process::Command; pub mod cmds; pub mod selection_file; @@ -35,6 +37,7 @@ pub async fn select(app: &App, done: bool) -> Result<()> { VideoStatus::Drop, VideoStatus::Dropped, ], + None, ) .await? } else { @@ -46,6 +49,7 @@ pub async fn select(app: &App, done: bool) -> Result<()> { VideoStatus::Watch, VideoStatus::Cached, ], + None, ) .await? }; @@ -82,6 +86,7 @@ pub async fn select(app: &App, done: bool) -> Result<()> { .expect("This write should not fail"); }); + // edit_file.write_all(get_help().await?.as_bytes())?; edit_file.write_all(HELP_STR.as_bytes())?; edit_file.flush().context("Failed to flush edit file")?; @@ -89,7 +94,7 @@ pub async fn select(app: &App, done: bool) -> Result<()> { let mut nvim = Command::new(editor); nvim.arg(temp_file.path()); - let status = nvim.status().context("Falied to run nvim")?; + let status = nvim.status().await.context("Falied to run nvim")?; if !status.success() { bail!("nvim exited with error status: {}", status) } @@ -144,3 +149,26 @@ pub async fn select(app: &App, done: bool) -> Result<()> { Ok(()) } + +// // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've +// // jet to find a way to do it with out the extra exec <2024-08-20> +// async fn get_help() -> Result<String> { +// let binary_name = current_exe()?; +// let cmd = Command::new(binary_name) +// .args(&["select", "--help"]) +// .output() +// .await?; +// +// assert_eq!(cmd.status.code(), Some(0)); +// +// let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?"); +// +// let out = output +// .lines() +// .map(|line| format!("# {}\n", line)) +// .collect::<String>(); +// +// debug!("Returning help: '{}'", &out); +// +// Ok(out) +// } diff --git a/pkgs/by-name/yt/yt/src/select/selection_file/display.rs b/pkgs/by-name/yt/yt/src/select/selection_file/display.rs index 5ab90316..9f5ae3c2 100644 --- a/pkgs/by-name/yt/yt/src/select/selection_file/display.rs +++ b/pkgs/by-name/yt/yt/src/select/selection_file/display.rs @@ -20,7 +20,10 @@ impl Video { pub async fn to_select_file_display(&self, app: &App) -> Result<String> { let mut f = String::new(); - let opts = get_video_opts(app, &self.extractor_hash).await?; + let opts = get_video_opts(app, &self.extractor_hash) + .await? + .to_cli_flags(); + let opts_white = if !opts.is_empty() { " " } else { "" }; let publish_date = if let Some(date) = self.publish_date { DateTime::from_timestamp(date, 0) @@ -40,9 +43,10 @@ impl Video { debug!("Formatting video for selection file: {}", self.title); write!( f, - r#"{} {} {} "{}" "{}" "{}" "{}" "{}"{}"#, + r#"{}{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, self.status.as_command(), - opts.to_cli_flags(), + opts_white, + opts, self.extractor_hash.into_short_hash(app).await?, self.title.replace(['"', '„', '”'], "'"), publish_date, diff --git a/pkgs/by-name/yt/yt/src/select/selection_file/help.str b/pkgs/by-name/yt/yt/src/select/selection_file/help.str index 130fe42a..6e296f6e 100644 --- a/pkgs/by-name/yt/yt/src/select/selection_file/help.str +++ b/pkgs/by-name/yt/yt/src/select/selection_file/help.str @@ -1,8 +1,10 @@ # Commands: -# w, watch = watch id -# d, drop = mark id as watched -# u, url = open the associated URL in the `timesinks.youtube` Firefox profile -# p, pick = leave id as is; This is a noop +# w, watch [-p,-s,-l] Mark the video given by the hash to be watched +# d, drop Mark the video given by the hash to be dropped +# u, url Open the video URL in Firefox's `timesinks.youtube` profile +# p, pick Reset the videos status to 'Pick' +# +# 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= diff --git a/pkgs/by-name/yt/yt/src/status/mod.rs b/pkgs/by-name/yt/yt/src/status/mod.rs index 82f75c8d..43632048 100644 --- a/pkgs/by-name/yt/yt/src/status/mod.rs +++ b/pkgs/by-name/yt/yt/src/status/mod.rs @@ -4,43 +4,74 @@ use crate::{ app::App, storage::{ subscriptions::get_subscriptions, - video_database::{getters::get_videos, VideoStatus}, + video_database::{getters::get_videos, Video, VideoStatus}, }, }; +macro_rules! get { + ($videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status == VideoStatus::$status) + .collect::<Vec<&Video>>() + }; + (@changing $videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status == VideoStatus::$status && vid.status_change) + .collect::<Vec<&Video>>() + }; +} + pub async fn show(app: &App) -> Result<()> { - let pick_videos = get_videos(app, &[VideoStatus::Pick]).await?; + let all_videos = get_videos( + app, + &[ + VideoStatus::Pick, + // + VideoStatus::Watch, + VideoStatus::Cached, + VideoStatus::Watched, + // + VideoStatus::Drop, + VideoStatus::Dropped, + ], + None, + ) + .await?; + + // lengths + let picked_videos_len = (get!(all_videos, Pick)).len(); - let watch_videos = get_videos(app, &[VideoStatus::Watch]).await?; - let cached_videos = get_videos(app, &[VideoStatus::Cached]).await?; - let watched_videos = get_videos(app, &[VideoStatus::Watched]).await?; + let watch_videos_len = (get!(all_videos, Watch)).len(); + let cached_videos_len = (get!(all_videos, Cached)).len(); + let watched_videos_len = (get!(all_videos, Watched)).len(); - let drop_videos = get_videos(app, &[VideoStatus::Drop]).await?; - let dropped_videos = get_videos(app, &[VideoStatus::Dropped]).await?; + let drop_videos_len = (get!(all_videos, Drop)).len(); + let dropped_videos_len = (get!(all_videos, Dropped)).len(); - // lengths - let picked_videos_len = pick_videos.len(); + // changing + let picked_videos_changing = (get!(@changing all_videos, Pick)).len(); - let watch_videos_len = watch_videos.len(); - let cached_videos_len = cached_videos.len(); - let watched_videos_len = watched_videos.len(); + let watch_videos_changing = (get!(@changing all_videos, Watch)).len(); + let cached_videos_changing = (get!(@changing all_videos, Cached)).len(); + let watched_videos_changing = (get!(@changing all_videos, Watched)).len(); - let drop_videos_len = drop_videos.len(); - let dropped_videos_len = dropped_videos.len(); + let drop_videos_changing = (get!(@changing all_videos, Drop)).len(); + let dropped_videos_changing = (get!(@changing all_videos, Dropped)).len(); let subscriptions = get_subscriptions()?; let subscriptions_len = subscriptions.0.len(); - println!( "\ -Picked Videos: {picked_videos_len} +Picked Videos: {picked_videos_len} ({picked_videos_changing} changing) -Watch Videos: {watch_videos_len} -Cached Videos: {cached_videos_len} -Watched Videos: {watched_videos_len} +Watch Videos: {watch_videos_len} ({watch_videos_changing} changing) +Cached Videos: {cached_videos_len} ({cached_videos_changing} changing) +Watched Videos: {watched_videos_len} ({watched_videos_changing} changing) -Drop Videos: {drop_videos_len} -Dropped Videos: {dropped_videos_len} +Drop Videos: {drop_videos_len} ({drop_videos_changing} changing) +Dropped Videos: {dropped_videos_len} ({dropped_videos_changing} changing) Subscriptions: {subscriptions_len}" diff --git a/pkgs/by-name/yt/yt/src/storage/video_database/getters.rs b/pkgs/by-name/yt/yt/src/storage/video_database/getters.rs index 57c023e6..b61a5c86 100644 --- a/pkgs/by-name/yt/yt/src/storage/video_database/getters.rs +++ b/pkgs/by-name/yt/yt/src/storage/video_database/getters.rs @@ -57,7 +57,13 @@ macro_rules! video_from_record { } /// Get the lines to display at the selection file -pub async fn get_videos(app: &App, allowed_states: &[VideoStatus]) -> Result<Vec<Video>> { +/// [`changing` = true]: Means that we include *only* videos, that have the `status_changing` flag set +/// [`changing` = None]: Means that we include *both* videos, that have the `status_changing` flag set and not set +pub async fn get_videos( + app: &App, + allowed_states: &[VideoStatus], + changing: Option<bool>, +) -> Result<Vec<Video>> { let mut qb: QueryBuilder<Sqlite> = QueryBuilder::new( "\ SELECT * @@ -80,6 +86,14 @@ pub async fn get_videos(app: &App, allowed_states: &[VideoStatus]) -> Result<Vec }); qb.push(")"); + if let Some(val) = changing { + if val { + qb.push(" AND status_change = 1"); + } else { + qb.push(" AND status_change = 0"); + } + } + qb.push("\n ORDER BY priority DESC;"); debug!("Will run: \"{}\"", qb.sql()); diff --git a/pkgs/by-name/yt/yt/src/storage/video_database/mod.rs b/pkgs/by-name/yt/yt/src/storage/video_database/mod.rs index 203cf651..1bb1376d 100644 --- a/pkgs/by-name/yt/yt/src/storage/video_database/mod.rs +++ b/pkgs/by-name/yt/yt/src/storage/video_database/mod.rs @@ -81,7 +81,7 @@ pub struct YtDlpOptions { /// Cache // yt cache /// | /// Watched // yt watch -#[derive(Default, Debug)] +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum VideoStatus { #[default] Pick, diff --git a/pkgs/by-name/yt/yt/src/storage/video_database/schema.sql b/pkgs/by-name/yt/yt/src/storage/video_database/schema.sql index d25b7015..2e9e18af 100644 --- a/pkgs/by-name/yt/yt/src/storage/video_database/schema.sql +++ b/pkgs/by-name/yt/yt/src/storage/video_database/schema.sql @@ -2,7 +2,11 @@ -- Keep this table in sync with the `Video` structure CREATE TABLE IF NOT EXISTS videos ( - cache_path TEXT UNIQUE CHECK (CASE WHEN cache_path IS NOT NULL THEN status == 2 ELSE 1 END), + cache_path TEXT UNIQUE CHECK (CASE WHEN cache_path IS NOT NULL THEN + status == 2 + ELSE + 1 + END), description TEXT, duration FLOAT, extractor_hash TEXT UNIQUE NOT NULL PRIMARY KEY, @@ -10,7 +14,17 @@ CREATE TABLE IF NOT EXISTS videos ( parent_subscription_name TEXT, priority INTEGER NOT NULL DEFAULT 0, publish_date INTEGER, - status INTEGER NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3, 4, 5) AND CASE WHEN status == 2 THEN cache_path IS NOT NULL ELSE 1 END AND CASE WHEN status != 2 THEN cache_path IS NULL ELSE 1 END), + status INTEGER NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3, 4, 5) AND + CASE WHEN status == 2 THEN + cache_path IS NOT NULL + ELSE + 1 + END AND + CASE WHEN status != 2 THEN + cache_path IS NULL + ELSE + 1 + END), status_change INTEGER NOT NULL DEFAULT 0 CHECK (status_change IN (0, 1)), thumbnail_url TEXT, title TEXT NOT NULL, diff --git a/pkgs/by-name/yt/yt/src/storage/video_database/setters.rs b/pkgs/by-name/yt/yt/src/storage/video_database/setters.rs index 242cf67a..dbcb897e 100644 --- a/pkgs/by-name/yt/yt/src/storage/video_database/setters.rs +++ b/pkgs/by-name/yt/yt/src/storage/video_database/setters.rs @@ -2,6 +2,7 @@ use anyhow::Result; use chrono::Utc; +use log::debug; use sqlx::query; use tokio::fs; @@ -18,11 +19,10 @@ pub async fn set_video_status( new_priority: Option<i64>, ) -> Result<()> { let video_hash = video_hash.hash().to_string(); - let new_status = new_status.as_db_integer(); let old = query!( r#" - SELECT status, priority + SELECT status, priority, cache_path FROM videos WHERE extractor_hash = ? "#, @@ -31,6 +31,16 @@ pub async fn set_video_status( .fetch_one(&app.database) .await?; + let cache_path = if (VideoStatus::from_db_integer(old.status) == VideoStatus::Cached) + && (new_status != VideoStatus::Cached) + { + None + } else { + old.cache_path.as_deref() + }; + + let new_status = new_status.as_db_integer(); + if let Some(new_priority) = new_priority { if old.status == new_status && old.priority == new_priority { return Ok(()); @@ -38,15 +48,22 @@ pub async fn set_video_status( let now = Utc::now().timestamp(); + debug!( + "Running status change: {:#?} -> {:#?}...", + VideoStatus::from_db_integer(old.status), + VideoStatus::from_db_integer(new_status), + ); + query!( r#" UPDATE videos - SET status = ?, last_status_change = ?, priority = ? + SET status = ?, last_status_change = ?, priority = ?, cache_path = ? WHERE extractor_hash = ?; "#, new_status, now, new_priority, + cache_path, video_hash ) .execute(&app.database) @@ -58,20 +75,28 @@ pub async fn set_video_status( let now = Utc::now().timestamp(); + debug!( + "Running status change: {:#?} -> {:#?}...", + VideoStatus::from_db_integer(old.status), + VideoStatus::from_db_integer(new_status), + ); + query!( r#" UPDATE videos - SET status = ?, last_status_change = ? + SET status = ?, last_status_change = ?, cache_path = ? WHERE extractor_hash = ?; "#, new_status, now, + cache_path, video_hash ) .execute(&app.database) .await?; } + debug!("Finished status change."); Ok(()) } diff --git a/pkgs/by-name/yt/yt/src/watch/events.rs b/pkgs/by-name/yt/yt/src/watch/events.rs index d693fdce..09495c3e 100644 --- a/pkgs/by-name/yt/yt/src/watch/events.rs +++ b/pkgs/by-name/yt/yt/src/watch/events.rs @@ -11,8 +11,9 @@ use crate::{ constants::LOCAL_COMMENTS_LENGTH, storage::video_database::{ extractor_hash::ExtractorHash, - getters::{get_video_by_hash, get_video_mpv_opts}, + getters::{get_video_by_hash, get_video_mpv_opts, get_videos}, setters::{set_state_change, set_video_watched}, + VideoStatus, }, }; @@ -31,6 +32,43 @@ impl MpvEventHandler { } } + /// Checks, whether new videos are ready to be played + pub async fn possibly_add_new_videos(&mut self, app: &App, mpv: &Mpv) -> Result<()> { + let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; + + // There is nothing to watch + if play_things.len() == 0 { + return Ok(()); + } + + let play_things = play_things + .into_iter() + .filter(|val| !self.current_playlist.contains(&val.extractor_hash)) + .collect::<Vec<_>>(); + + info!( + "{} videos are cached and will be added to the list to be played", + play_things.len() + ); + + self.current_playlist.reserve(play_things.len()); + + for play_thing in play_things { + debug!("Adding '{}' to playlist.", play_thing.title); + + let orig_cache_path = play_thing.cache_path.expect("Is cached and thus some"); + let cache_path = orig_cache_path.to_str().expect("Should be vaild utf8"); + let cache_path = format!("\"{}\"", cache_path); + + let args = &[&cache_path, "append-play"]; + + mpv.execute("loadfile", args)?; + self.current_playlist.push(play_thing.extractor_hash); + } + + Ok(()) + } + async fn mark_video_watched(&mut self, app: &App, hash: &ExtractorHash) -> Result<()> { let video = get_video_by_hash(app, hash).await?; set_video_watched(&app, &video).await?; @@ -59,7 +97,7 @@ impl MpvEventHandler { Ok(()) } - /// Apply the options set with e.g. `watch --speed` + /// Apply the options set with e.g. `watch --speed=<speed>` async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> { let options = get_video_mpv_opts(app, hash).await?; @@ -87,11 +125,11 @@ impl MpvEventHandler { EndFileReason::Quit => { info!("Mpv quit. Exiting playback"); - self.mark_cvideo_inactive(app).await?; // draining the playlist is okay, as mpv is done playing let videos = mem::take(&mut self.current_playlist); for video in videos { self.mark_video_watched(app, &video).await?; + set_state_change(&app, &video, false).await?; } return Ok(true); } @@ -103,6 +141,8 @@ impl MpvEventHandler { } }, Event::StartFile(playlist_index) => { + self.possibly_add_new_videos(app, &mpv).await?; + self.mark_video_active(app, (playlist_index - 1) as usize) .await?; self.current_playlist_position = (playlist_index - 1) as usize; diff --git a/pkgs/by-name/yt/yt/src/watch/mod.rs b/pkgs/by-name/yt/yt/src/watch/mod.rs index 4b558968..83996c1a 100644 --- a/pkgs/by-name/yt/yt/src/watch/mod.rs +++ b/pkgs/by-name/yt/yt/src/watch/mod.rs @@ -62,8 +62,7 @@ pub async fn watch(app: &App) -> Result<()> { let mut ev_ctx = EventContext::new(mpv.ctx); ev_ctx.disable_deprecated_events()?; - let play_things = get_videos(app, &[VideoStatus::Cached]).await?; - + let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; info!( "{} videos are cached and ready to be played", play_things.len() |