Embedded artwork support (#101)
* Embedded artwork support for mp4 and id3 tags * Embedded artwork support for flac tags. * small fixes * use first embedded artwork for directory * added artwork tests * updated Cargo.lock * use first embedded artwork for missing artworks
This commit is contained in:
parent
4534a84c05
commit
bff49c22ec
17 changed files with 1529 additions and 1229 deletions
2547
Cargo.lock
generated
2547
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -29,7 +29,7 @@ lewton = "0.10.1"
|
||||||
log = "0.4.5"
|
log = "0.4.5"
|
||||||
metaflac = "0.2.3"
|
metaflac = "0.2.3"
|
||||||
mp3-duration = "0.1.9"
|
mp3-duration = "0.1.9"
|
||||||
mp4ameta = "0.7.0"
|
mp4ameta = "0.7.1"
|
||||||
opus_headers = "0.1.2"
|
opus_headers = "0.1.2"
|
||||||
pbkdf2 = "0.4"
|
pbkdf2 = "0.4"
|
||||||
rand = "0.7"
|
rand = "0.7"
|
||||||
|
|
118
src/artwork.rs
Normal file
118
src/artwork.rs
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
use anyhow::*;
|
||||||
|
use image::DynamicImage;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::utils;
|
||||||
|
use crate::utils::AudioFormat;
|
||||||
|
|
||||||
|
pub fn read(image_path: &Path) -> Result<DynamicImage> {
|
||||||
|
match utils::get_audio_format(image_path) {
|
||||||
|
Some(AudioFormat::APE) => read_ape(image_path),
|
||||||
|
Some(AudioFormat::FLAC) => read_flac(image_path),
|
||||||
|
Some(AudioFormat::MP3) => read_id3(image_path),
|
||||||
|
Some(AudioFormat::MP4) => read_mp4(image_path),
|
||||||
|
Some(AudioFormat::MPC) => read_ape(image_path),
|
||||||
|
Some(AudioFormat::OGG) => read_vorbis(image_path),
|
||||||
|
Some(AudioFormat::OPUS) => read_opus(image_path),
|
||||||
|
None => Ok(image::open(image_path)?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_ape(_: &Path) -> Result<DynamicImage> {
|
||||||
|
Err(crate::Error::msg("Embedded ape artworks not yet supported"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_flac(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let tag = metaflac::Tag::read_from_path(path)?;
|
||||||
|
|
||||||
|
if let Some(p) = tag.pictures().next() {
|
||||||
|
return Ok(image::load_from_memory(&p.data)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::Error::msg(format!(
|
||||||
|
"Embedded flac artwork not found for file: {}",
|
||||||
|
path.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_id3(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let tag = id3::Tag::read_from_path(path)?;
|
||||||
|
|
||||||
|
if let Some(p) = tag.pictures().next() {
|
||||||
|
return Ok(image::load_from_memory(&p.data)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(crate::Error::msg(format!(
|
||||||
|
"Embedded id3 artwork not found for file: {}",
|
||||||
|
path.display()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_mp4(path: &Path) -> Result<DynamicImage> {
|
||||||
|
let tag = mp4ameta::Tag::read_from_path(path)?;
|
||||||
|
|
||||||
|
match tag.artwork().and_then(|d| d.image_data()) {
|
||||||
|
Some(v) => Ok(image::load_from_memory(v)?),
|
||||||
|
_ => Err(crate::Error::msg(format!(
|
||||||
|
"Embedded mp4 artwork not found for file: {}",
|
||||||
|
path.display()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_vorbis(_: &Path) -> Result<DynamicImage> {
|
||||||
|
Err(crate::Error::msg(
|
||||||
|
"Embedded vorbis artworks are not yet supported",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_opus(_: &Path) -> Result<DynamicImage> {
|
||||||
|
Err(crate::Error::msg(
|
||||||
|
"Embedded opus artworks are not yet supported",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_artowork() {
|
||||||
|
let ext_img = image::open("test-data/artwork/Folder.png")
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
let embedded_img = image::open("test-data/artwork/Embedded.png")
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
|
||||||
|
let folder_img = read(Path::new("test-data/artwork/Folder.png"))
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
assert_eq!(folder_img, ext_img);
|
||||||
|
|
||||||
|
let ape_img = read(Path::new("test-data/artwork/sample.ape"))
|
||||||
|
.map(|d| d.to_rgb8())
|
||||||
|
.ok();
|
||||||
|
assert_eq!(ape_img, None);
|
||||||
|
|
||||||
|
let flac_img = read(Path::new("test-data/artwork/sample.flac"))
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
assert_eq!(flac_img, embedded_img);
|
||||||
|
|
||||||
|
let mp3_img = read(Path::new("test-data/artwork/sample.mp3"))
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
assert_eq!(mp3_img, embedded_img);
|
||||||
|
|
||||||
|
let m4a_img = read(Path::new("test-data/artwork/sample.m4a"))
|
||||||
|
.unwrap()
|
||||||
|
.to_rgb8();
|
||||||
|
assert_eq!(m4a_img, embedded_img);
|
||||||
|
|
||||||
|
let ogg_img = read(Path::new("test-data/artwork/sample.ogg"))
|
||||||
|
.map(|d| d.to_rgb8())
|
||||||
|
.ok();
|
||||||
|
assert_eq!(ogg_img, None);
|
||||||
|
|
||||||
|
let opus_img = read(Path::new("test-data/artwork/sample.opus"))
|
||||||
|
.map(|d| d.to_rgb8())
|
||||||
|
.ok();
|
||||||
|
assert_eq!(opus_img, None);
|
||||||
|
}
|
|
@ -24,6 +24,7 @@ pub struct SongTags {
|
||||||
pub album_artist: Option<String>,
|
pub album_artist: Option<String>,
|
||||||
pub album: Option<String>,
|
pub album: Option<String>,
|
||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
|
pub has_artwork: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "profile-index", flame)]
|
#[cfg_attr(feature = "profile-index", flame)]
|
||||||
|
@ -83,6 +84,7 @@ fn read_id3(path: &Path) -> Result<SongTags> {
|
||||||
.map(|y| y as i32)
|
.map(|y| y as i32)
|
||||||
.or_else(|| tag.date_released().and_then(|d| Some(d.year)))
|
.or_else(|| tag.date_released().and_then(|d| Some(d.year)))
|
||||||
.or_else(|| tag.date_recorded().and_then(|d| Some(d.year)));
|
.or_else(|| tag.date_recorded().and_then(|d| Some(d.year)));
|
||||||
|
let has_artwork = tag.pictures().count() > 0;
|
||||||
|
|
||||||
Ok(SongTags {
|
Ok(SongTags {
|
||||||
artist,
|
artist,
|
||||||
|
@ -93,6 +95,7 @@ fn read_id3(path: &Path) -> Result<SongTags> {
|
||||||
disc_number,
|
disc_number,
|
||||||
track_number,
|
track_number,
|
||||||
year,
|
year,
|
||||||
|
has_artwork,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -143,6 +146,7 @@ fn read_ape(path: &Path) -> Result<SongTags> {
|
||||||
disc_number,
|
disc_number,
|
||||||
track_number,
|
track_number,
|
||||||
year,
|
year,
|
||||||
|
has_artwork: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,6 +164,7 @@ fn read_vorbis(path: &Path) -> Result<SongTags> {
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
track_number: None,
|
track_number: None,
|
||||||
year: None,
|
year: None,
|
||||||
|
has_artwork: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (key, value) in source.comment_hdr.comment_list {
|
for (key, value) in source.comment_hdr.comment_list {
|
||||||
|
@ -193,6 +198,7 @@ fn read_opus(path: &Path) -> Result<SongTags> {
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
track_number: None,
|
track_number: None,
|
||||||
year: None,
|
year: None,
|
||||||
|
has_artwork: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (key, value) in headers.comments.user_comments {
|
for (key, value) in headers.comments.user_comments {
|
||||||
|
@ -230,6 +236,7 @@ fn read_flac(path: &Path) -> Result<SongTags> {
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
let has_artwork = tag.pictures().count() > 0;
|
||||||
|
|
||||||
Ok(SongTags {
|
Ok(SongTags {
|
||||||
artist: vorbis.artist().map(|v| v[0].clone()),
|
artist: vorbis.artist().map(|v| v[0].clone()),
|
||||||
|
@ -240,6 +247,7 @@ fn read_flac(path: &Path) -> Result<SongTags> {
|
||||||
disc_number,
|
disc_number,
|
||||||
track_number: vorbis.track(),
|
track_number: vorbis.track(),
|
||||||
year,
|
year,
|
||||||
|
has_artwork,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,6 +264,7 @@ fn read_mp4(path: &Path) -> Result<SongTags> {
|
||||||
disc_number: tag.disc_number().map(|d| d as u32),
|
disc_number: tag.disc_number().map(|d| d as u32),
|
||||||
track_number: tag.track_number().map(|d| d as u32),
|
track_number: tag.track_number().map(|d| d as u32),
|
||||||
year: tag.year().and_then(|v| v.parse::<i32>().ok()),
|
year: tag.year().and_then(|v| v.parse::<i32>().ok()),
|
||||||
|
has_artwork: tag.artwork().is_some(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,6 +279,7 @@ fn test_read_metadata() {
|
||||||
album: Some("TEST ALBUM".into()),
|
album: Some("TEST ALBUM".into()),
|
||||||
duration: None,
|
duration: None,
|
||||||
year: Some(2016),
|
year: Some(2016),
|
||||||
|
has_artwork: false,
|
||||||
};
|
};
|
||||||
let flac_sample_tag = SongTags {
|
let flac_sample_tag = SongTags {
|
||||||
duration: Some(0),
|
duration: Some(0),
|
||||||
|
@ -307,4 +317,29 @@ fn test_read_metadata() {
|
||||||
read(Path::new("test-data/formats/sample.ape")).unwrap(),
|
read(Path::new("test-data/formats/sample.ape")).unwrap(),
|
||||||
sample_tags
|
sample_tags
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let flac_artwork_tag = SongTags {
|
||||||
|
has_artwork: true,
|
||||||
|
..flac_sample_tag
|
||||||
|
};
|
||||||
|
let mp3_artwork_tag = SongTags {
|
||||||
|
has_artwork: true,
|
||||||
|
..mp3_sample_tag
|
||||||
|
};
|
||||||
|
let m4a_artwork_tag = SongTags {
|
||||||
|
has_artwork: true,
|
||||||
|
..m4a_sample_tag
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
read(Path::new("test-data/artwork/sample.mp3")).unwrap(),
|
||||||
|
mp3_artwork_tag
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(Path::new("test-data/artwork/sample.flac")).unwrap(),
|
||||||
|
flac_artwork_tag
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read(Path::new("test-data/artwork/sample.m4a")).unwrap(),
|
||||||
|
m4a_artwork_tag
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,37 @@ fn test_metadata() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_embedded_artwork() {
|
||||||
|
let mut song_path = PathBuf::new();
|
||||||
|
song_path.push("test-data");
|
||||||
|
song_path.push("small-collection");
|
||||||
|
song_path.push("Tobokegao");
|
||||||
|
song_path.push("Picnic");
|
||||||
|
song_path.push("07 - なぜ (Why).mp3");
|
||||||
|
|
||||||
|
let db = db::get_test_db("artwork.sqlite");
|
||||||
|
update(&db).unwrap();
|
||||||
|
|
||||||
|
let connection = db.connect().unwrap();
|
||||||
|
let songs: Vec<Song> = songs::table
|
||||||
|
.filter(songs::title.eq("なぜ (Why?)"))
|
||||||
|
.load(&connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(songs.len(), 1);
|
||||||
|
let song = &songs[0];
|
||||||
|
assert_eq!(song.path, song_path.to_string_lossy().as_ref());
|
||||||
|
assert_eq!(song.track_number, Some(7));
|
||||||
|
assert_eq!(song.disc_number, None);
|
||||||
|
assert_eq!(song.title, Some("なぜ (Why?)".to_owned()));
|
||||||
|
assert_eq!(song.artist, Some("Tobokegao".to_owned()));
|
||||||
|
assert_eq!(song.album_artist, None);
|
||||||
|
assert_eq!(song.album, Some("Picnic".to_owned()));
|
||||||
|
assert_eq!(song.year, Some(2016));
|
||||||
|
assert_eq!(song.artwork, Some(song_path.to_string_lossy().into_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_browse_top_level() {
|
fn test_browse_top_level() {
|
||||||
let mut root_path = PathBuf::new();
|
let mut root_path = PathBuf::new();
|
||||||
|
|
|
@ -114,7 +114,7 @@ impl IndexUpdater {
|
||||||
));
|
));
|
||||||
|
|
||||||
// Find artwork
|
// Find artwork
|
||||||
let artwork = {
|
let mut directory_artwork = {
|
||||||
#[cfg(feature = "profile-index")]
|
#[cfg(feature = "profile-index")]
|
||||||
let _guard = flame::start_guard("artwork");
|
let _guard = flame::start_guard("artwork");
|
||||||
self.get_artwork(path).unwrap_or(None)
|
self.get_artwork(path).unwrap_or(None)
|
||||||
|
@ -200,6 +200,13 @@ impl IndexUpdater {
|
||||||
.filter_map(song_metadata)
|
.filter_map(song_metadata)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if directory_artwork.is_none() {
|
||||||
|
directory_artwork = song_tags
|
||||||
|
.iter()
|
||||||
|
.find(|(_, t)| t.has_artwork)
|
||||||
|
.map(|(p, _)| p.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
for (file_path_string, tags) in song_tags {
|
for (file_path_string, tags) in song_tags {
|
||||||
if tags.year.is_some() {
|
if tags.year.is_some() {
|
||||||
inconsistent_directory_year |=
|
inconsistent_directory_year |=
|
||||||
|
@ -223,6 +230,12 @@ impl IndexUpdater {
|
||||||
directory_artist = tags.artist.as_ref().cloned();
|
directory_artist = tags.artist.as_ref().cloned();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let artwork_path = if tags.has_artwork {
|
||||||
|
Some(file_path_string.to_owned())
|
||||||
|
} else {
|
||||||
|
directory_artwork.as_ref().cloned()
|
||||||
|
};
|
||||||
|
|
||||||
let song = NewSong {
|
let song = NewSong {
|
||||||
path: file_path_string.to_owned(),
|
path: file_path_string.to_owned(),
|
||||||
parent: path_string.to_owned(),
|
parent: path_string.to_owned(),
|
||||||
|
@ -234,7 +247,7 @@ impl IndexUpdater {
|
||||||
album_artist: tags.album_artist,
|
album_artist: tags.album_artist,
|
||||||
album: tags.album,
|
album: tags.album,
|
||||||
year: tags.year,
|
year: tags.year,
|
||||||
artwork: artwork.as_ref().cloned(),
|
artwork: artwork_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.push_song(song)?;
|
self.push_song(song)?;
|
||||||
|
@ -255,7 +268,7 @@ impl IndexUpdater {
|
||||||
NewDirectory {
|
NewDirectory {
|
||||||
path: path_string.to_owned(),
|
path: path_string.to_owned(),
|
||||||
parent: parent_string,
|
parent: parent_string,
|
||||||
artwork,
|
artwork: directory_artwork,
|
||||||
album: directory_album,
|
album: directory_album,
|
||||||
artist: directory_artist,
|
artist: directory_artist,
|
||||||
year: directory_year,
|
year: directory_year,
|
||||||
|
|
|
@ -34,6 +34,7 @@ mod lastfm;
|
||||||
mod playlist;
|
mod playlist;
|
||||||
mod service;
|
mod service;
|
||||||
|
|
||||||
|
mod artwork;
|
||||||
mod thumbnails;
|
mod thumbnails;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
use anyhow::*;
|
use anyhow::*;
|
||||||
use image;
|
|
||||||
use image::imageops::FilterType;
|
use image::imageops::FilterType;
|
||||||
use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageOutputFormat};
|
use image::{DynamicImage, GenericImage, GenericImageView, ImageBuffer, ImageOutputFormat};
|
||||||
use std::cmp;
|
use std::cmp;
|
||||||
|
@ -8,6 +7,8 @@ use std::fs::{DirBuilder, File};
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::path::*;
|
use std::path::*;
|
||||||
|
|
||||||
|
use crate::artwork;
|
||||||
|
|
||||||
pub struct ThumbnailsManager {
|
pub struct ThumbnailsManager {
|
||||||
thumbnails_path: PathBuf,
|
thumbnails_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
@ -88,7 +89,7 @@ fn generate_thumbnail(
|
||||||
image_path: &Path,
|
image_path: &Path,
|
||||||
thumbnailoptions: &ThumbnailOptions,
|
thumbnailoptions: &ThumbnailOptions,
|
||||||
) -> Result<DynamicImage> {
|
) -> Result<DynamicImage> {
|
||||||
let source_image = image::open(image_path)?;
|
let source_image = artwork::read(image_path)?;
|
||||||
let (source_width, source_height) = source_image.dimensions();
|
let (source_width, source_height) = source_image.dimensions();
|
||||||
let largest_dimension = cmp::max(source_width, source_height);
|
let largest_dimension = cmp::max(source_width, source_height);
|
||||||
let out_dimension = cmp::min(thumbnailoptions.max_dimension, largest_dimension);
|
let out_dimension = cmp::min(thumbnailoptions.max_dimension, largest_dimension);
|
||||||
|
|
BIN
test-data/artwork/Embedded.png
Normal file
BIN
test-data/artwork/Embedded.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 141 B |
BIN
test-data/artwork/Folder.png
Normal file
BIN
test-data/artwork/Folder.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 103 B |
BIN
test-data/artwork/sample.ape
Normal file
BIN
test-data/artwork/sample.ape
Normal file
Binary file not shown.
BIN
test-data/artwork/sample.flac
Normal file
BIN
test-data/artwork/sample.flac
Normal file
Binary file not shown.
BIN
test-data/artwork/sample.m4a
Normal file
BIN
test-data/artwork/sample.m4a
Normal file
Binary file not shown.
BIN
test-data/artwork/sample.mp3
Normal file
BIN
test-data/artwork/sample.mp3
Normal file
Binary file not shown.
BIN
test-data/artwork/sample.ogg
Normal file
BIN
test-data/artwork/sample.ogg
Normal file
Binary file not shown.
BIN
test-data/artwork/sample.opus
Normal file
BIN
test-data/artwork/sample.opus
Normal file
Binary file not shown.
Binary file not shown.
Loading…
Add table
Reference in a new issue