Skip to content

Commit

Permalink
Add <playlist> placeholder in sources
Browse files Browse the repository at this point in the history
  • Loading branch information
mtkennerly committed Dec 12, 2024
1 parent 490af75 commit acff98d
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 19 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ Note:
## Usage
Detailed help documentation is available for several topics.

### General
* [Media sources](/docs/help/media-sources.md)

### Interfaces
* [Application folder](/docs/help/application-folder.md)
* [Command line](/docs/help/command-line.md)
Expand Down
26 changes: 26 additions & 0 deletions docs/help/media-sources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Media sources
To add media to a group,
you can specify the group's sources
using the gear icon in the group's header controls.

You can configure different kinds of sources:

* A `path` source is the path to a specific file or folder on your computer.
For folders, the application will look for media directly inside of that folder,
but not in any of its subfolders.
* A `glob` source lets you specify many files/folders at once using
[glob patterns](https://en.wikipedia.org/wiki/Glob_(programming)).
For example, `C:\media\**\*.mp4` would select all MP4 files in any subfolder of `C:\media`.

Tips:

* Relative paths are supported and resolve to the current working directory.
* Sources may begin with a `<playlist>` placeholder,
which resolves to the location of the active playlist.
If the playlist is not yet saved, then it resolves to the current working directory.
* Sources may begin with `~`,
which resolves to your user folder (e.g., `C:\Users\your-name` on Windows).
* For globs, if your file/folder name contains special glob characters,
you can escape them by wrapping them in brackets.
For example, to select all MP4 files starting with `[prefix]` (because `[` and `]` are special),
you can write `[[]prefix[]] *.mp4`.
55 changes: 44 additions & 11 deletions src/gui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ impl App {
let grids = match playlist_path.as_ref() {
Some(path) => match Playlist::load_from(path) {
Ok(playlist) => {
commands.push(Self::find_media(playlist.sources(), media::RefreshContext::Launch));
commands.push(Self::find_media(
playlist.sources(),
media::RefreshContext::Launch,
playlist_path.clone(),
));
Self::load_playlist(playlist)
}
Err(e) => {
Expand All @@ -178,7 +182,11 @@ impl App {
} else {
playlist_dirty = true;
}
commands.push(Self::find_media(sources, media::RefreshContext::Launch));
commands.push(Self::find_media(
sources,
media::RefreshContext::Launch,
playlist_path.clone(),
));
grids
}
};
Expand Down Expand Up @@ -310,15 +318,20 @@ impl App {
.collect()
}

fn find_media(sources: Vec<media::Source>, context: media::RefreshContext) -> Task<Message> {
fn find_media(
sources: Vec<media::Source>,
context: media::RefreshContext,
playlist: Option<StrictPath>,
) -> Task<Message> {
log::info!("Finding media ({context:?})");
Task::future(async move {
match tokio::task::spawn_blocking(move || media::Collection::find(&sources)).await {
match tokio::task::spawn_blocking(move || media::Collection::find(&sources, playlist)).await {
Ok(media) => {
log::debug!("Found media: {media:?}");
log::info!("Found media ({context:?}): {media:?}");
Message::MediaFound { context, media }
}
Err(e) => {
log::error!("Unable to find media: {e:?}");
log::error!("Unable to find media ({context:?}): {e:?}");
Message::Ignore
}
}
Expand Down Expand Up @@ -757,7 +770,11 @@ impl App {
self.playlist_dirty = true;
grid.set_settings(settings);
}
return Self::find_media(sources, media::RefreshContext::Edit);
return Self::find_media(
sources,
media::RefreshContext::Edit,
self.playlist_path.clone(),
);
}
modal::Update::Task(task) => {
return task;
Expand All @@ -771,7 +788,11 @@ impl App {
self.show_modal(Modal::Settings);
Task::none()
}
Message::FindMedia => Self::find_media(self.all_sources(), media::RefreshContext::Automatic),
Message::FindMedia => Self::find_media(
self.all_sources(),
media::RefreshContext::Automatic,
self.playlist_path.clone(),
),
Message::MediaFound { context, media } => {
self.media.update(media, context);
for (_grid_id, grid) in self.grids.iter_mut() {
Expand Down Expand Up @@ -962,6 +983,7 @@ impl App {
self.grids = grids;
self.playlist_dirty = false;
self.playlist_path = None;
self.media.clear();

Task::none()
}
Expand Down Expand Up @@ -996,7 +1018,11 @@ impl App {
match Playlist::load_from(&path) {
Ok(playlist) => {
self.grids = Self::load_playlist(playlist);
Self::find_media(self.all_sources(), media::RefreshContext::Playlist)
Self::find_media(
self.all_sources(),
media::RefreshContext::Playlist,
self.playlist_path.clone(),
)
}
Err(e) => {
self.show_error(e);
Expand Down Expand Up @@ -1041,13 +1067,20 @@ impl App {
match playlist.save_to(&path) {
Ok(_) => {
self.playlist_dirty = false;
Self::find_media(
self.all_sources()
.into_iter()
.filter(|x| x.has_playlist_placeholder())
.collect(),
media::RefreshContext::Edit,
self.playlist_path.clone(),
)
}
Err(e) => {
self.show_error(e);
Task::none()
}
}

Task::none()
}
}
}
Expand Down
92 changes: 84 additions & 8 deletions src/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use crate::{lang, path::StrictPath};

pub const MAX_INITIAL: usize = 1;

mod placeholder {
pub const PLAYLIST: &str = "<playlist>";
}

#[derive(Debug, Clone, Copy)]
pub enum RefreshContext {
Launch,
Expand Down Expand Up @@ -83,6 +87,24 @@ impl Source {
}
}
}

pub fn fill_placeholders(&self, playlist: &StrictPath) -> Self {
match self {
Self::Path { path } => Self::Path {
path: path.replace(&StrictPath::new(placeholder::PLAYLIST), playlist),
},
Self::Glob { pattern } => Self::Glob {
pattern: match pattern.strip_prefix(placeholder::PLAYLIST) {
Some(suffix) => format!("{}{}", playlist.render(), suffix),
None => pattern.clone(),
},
},
}
}

pub fn has_playlist_placeholder(&self) -> bool {
self.raw().contains(placeholder::PLAYLIST)
}
}

impl Default for Source {
Expand Down Expand Up @@ -158,7 +180,7 @@ impl Media {

match infer::get_from_path(inferrable) {
Ok(Some(info)) => {
log::info!("Inferred file type '{}': {path:?}", info.mime_type());
log::debug!("Inferred file type '{}': {path:?}", info.mime_type());

let extension = path.file_extension().map(|x| x.to_lowercase());

Expand Down Expand Up @@ -192,7 +214,7 @@ impl Media {
}
}
Ok(None) => {
log::info!("Did not infer any file type: {path:?}");
log::debug!("Did not infer any file type: {path:?}");
None
}
Err(e) => {
Expand All @@ -212,6 +234,10 @@ pub struct Collection {
}

impl Collection {
pub fn clear(&mut self) {
self.media.clear();
}

pub fn mark_error(&mut self, media: &Media) {
self.errored.insert(media.clone());
}
Expand All @@ -227,44 +253,58 @@ impl Collection {
.all(|known| !known.contains(media))
}

pub fn find(sources: &[Source]) -> SourceMap {
pub fn find(sources: &[Source], playlist: Option<StrictPath>) -> SourceMap {
let mut media = SourceMap::new();
let playlist = playlist.and_then(|x| x.parent()).unwrap_or_else(StrictPath::cwd);

for source in sources {
media.insert(source.clone(), Self::find_in_source(source));
media.insert(source.clone(), Self::find_in_source(source, Some(&playlist)));
}

media
}

fn find_in_source(source: &Source) -> HashSet<Media> {
match source {
fn find_in_source(source: &Source, playlist: Option<&StrictPath>) -> HashSet<Media> {
log::debug!("Finding media in source: {source:?}, playlist: {playlist:?}");

let source = match playlist {
Some(playlist) => source.fill_placeholders(playlist),
None => source.clone(),
};
log::debug!("Source with placeholders filled: {source:?}");

match &source {
Source::Path { path } => {
if path.is_file() {
log::debug!("Source is file");
match Media::identify(path) {
Some(source) => HashSet::from_iter([source]),
None => HashSet::new(),
}
} else if path.is_dir() {
log::debug!("Source is directory");
path.joined("*")
.glob()
.into_iter()
.filter(|x| x.is_file())
.filter_map(|path| Media::identify(&path))
.collect()
} else if path.is_symlink() {
log::debug!("Source is symlink");
match path.interpreted() {
Ok(path) => Self::find_in_source(&Source::new_path(path)),
Ok(path) => Self::find_in_source(&Source::new_path(path), None),
Err(_) => HashSet::new(),
}
} else {
log::debug!("Source is unknown path");
HashSet::new()
}
}
Source::Glob { pattern } => {
log::debug!("Source is glob");
let mut media = HashSet::new();
for path in StrictPath::new(pattern).glob() {
media.extend(Self::find_in_source(&Source::new_path(path)));
media.extend(Self::find_in_source(&Source::new_path(path), None));
}
media
}
Expand Down Expand Up @@ -326,3 +366,39 @@ impl Collection {
.cloned()
}
}

#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;

#[test]
fn can_fill_placeholders_in_path_with_match() {
let source = Source::new_path(StrictPath::new(format!("{}/foo", placeholder::PLAYLIST)));
let playlist = StrictPath::new("/tmp");
let filled = Source::new_path(StrictPath::new("/tmp/foo"));
assert_eq!(filled, source.fill_placeholders(&playlist))
}

#[test]
fn can_fill_placeholders_in_path_without_match() {
let source = Source::new_path(StrictPath::new(format!("/{}/foo", placeholder::PLAYLIST)));
let playlist = StrictPath::new("/tmp");
assert_eq!(source, source.fill_placeholders(&playlist))
}

#[test]
fn can_fill_placeholders_in_glob_with_match() {
let source = Source::new_glob(format!("{}/foo", placeholder::PLAYLIST));
let playlist = StrictPath::new("/tmp");
let filled = Source::new_glob("/tmp/foo".to_string());
assert_eq!(filled, source.fill_placeholders(&playlist))
}

#[test]
fn can_fill_placeholders_in_glob_without_match() {
let source = Source::new_glob(format!("/{}/foo", placeholder::PLAYLIST));
let playlist = StrictPath::new("/tmp");
assert_eq!(source, source.fill_placeholders(&playlist))
}
}

0 comments on commit acff98d

Please sign in to comment.