From 3eddcfb9616645c03c2029020e7a9c4794a23522 Mon Sep 17 00:00:00 2001 From: Wojciech Kozlowski Date: Fri, 31 Mar 2023 14:24:54 +0200 Subject: [PATCH] Resolve "Local collection trait and beets implementation" (#9) Closes #4 Reviewed-on: https://git.wojciechkozlowski.eu/wojtek/musichoard/pulls/9 --- src/database/json.rs | 113 +++----- src/database/mod.rs | 4 + src/lib.rs | 51 ++-- src/library/beets.rs | 432 ++++++++++++++++++++++++++++ src/library/mod.rs | 133 +++++++++ tests/files/database_json_test.json | 2 +- 6 files changed, 626 insertions(+), 109 deletions(-) create mode 100644 src/library/beets.rs create mode 100644 src/library/mod.rs diff --git a/src/database/json.rs b/src/database/json.rs index 7807a90..c87d533 100644 --- a/src/database/json.rs +++ b/src/database/json.rs @@ -1,3 +1,5 @@ +//! Module for storing MusicHoard data in a JSON file database. + use std::fs::File; use std::io::{Read, Write}; use std::path::Path; @@ -5,7 +7,7 @@ use std::path::Path; use serde::de::DeserializeOwned; use serde::Serialize; -use crate::database::{DatabaseRead, DatabaseWrite}; +use super::{DatabaseRead, DatabaseWrite}; /// A JSON file database. pub struct DatabaseJson { @@ -59,84 +61,49 @@ impl DatabaseWrite for DatabaseJson { mod tests { use std::path::Path; use tempfile::NamedTempFile; - use uuid::uuid; use super::*; - use crate::{Artist, Release, ReleaseGroup, ReleaseGroupType, Track}; + use crate::{Album, AlbumId, Track}; const TEST_FILENAME: &str = "tests/files/database_json_test.json"; - fn test_data() -> Vec { + fn test_data() -> Vec { vec![ - ReleaseGroup { - r#type: ReleaseGroupType::Album, - title: String::from("Release group A"), - artist: vec![Artist { - name: String::from("Artist A"), - mbid: Some(uuid!("f7769831-746b-4a12-8124-0123d7fe17c9")), - }], - year: 1998, - mbid: Some(uuid!("89efbf43-3395-4f6e-ac11-32c1ce514bb0")), - releases: vec![Release { - tracks: vec![ - Track { - number: 1, - title: String::from("Track A.1"), - artist: vec![Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }], - mbid: None, - }, - Track { - number: 2, - title: String::from("Track A.2"), - artist: vec![Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }], - mbid: None, - }, - Track { - number: 3, - title: String::from("Track A.3"), - artist: vec![ - Artist { - name: String::from("Artist A.A"), - mbid: Some(uuid!("b7f7163d-61d5-4c96-b305-005df54fb999")), - }, - Artist { - name: String::from("Artist A.B"), - mbid: Some(uuid!("6f6b46f2-4bb5-47e7-a8c8-03ebde30164f")), - }, - ], - mbid: None, - }, - ], - mbid: None, - }], - }, - ReleaseGroup { - r#type: ReleaseGroupType::Single, - title: String::from("Release group B"), - artist: vec![Artist { - name: String::from("Artist B"), - mbid: None, - }], - year: 2008, - mbid: None, - releases: vec![Release { - tracks: vec![Track { + Album { + id: AlbumId { + artist: String::from("Artist A"), + year: 1998, + title: String::from("Release group A"), + }, + tracks: vec![ + Track { number: 1, - title: String::from("Track B.1"), - artist: vec![Artist { - name: String::from("Artist B.A"), - mbid: Some(uuid!("d927e216-2e63-415c-acec-bf9f1abd3e3c")), - }], - mbid: Some(uuid!("dacc9ce4-118c-4c92-aed7-1ebe4c7543b5")), - }], - mbid: Some(uuid!("ac7b642d-8b71-4588-a694-e5ae43fac873")), + title: String::from("Track A.1"), + artist: vec![String::from("Artist A.A")], + }, + Track { + number: 2, + title: String::from("Track A.2"), + artist: vec![String::from("Artist A.A")], + }, + Track { + number: 3, + title: String::from("Track A.3"), + artist: vec![String::from("Artist A.A"), String::from("Artist A.B")], + }, + ], + }, + Album { + id: AlbumId { + artist: String::from("Artist B"), + year: 2008, + title: String::from("Release group B"), + }, + tracks: vec![Track { + number: 1, + title: String::from("Track B.1"), + artist: vec![String::from("Artist B.A")], }], }, ] @@ -168,7 +135,7 @@ mod tests { #[test] fn read() { - let mut read_data: Vec = vec![]; + let mut read_data: Vec = vec![]; DatabaseJson::reader(Path::new(TEST_FILENAME)) .unwrap() .read(&mut read_data) @@ -179,7 +146,7 @@ mod tests { #[test] fn reverse() { let write_data = test_data(); - let mut read_data: Vec = vec![]; + let mut read_data: Vec = vec![]; let temp_file = NamedTempFile::new().unwrap(); diff --git a/src/database/mod.rs b/src/database/mod.rs index 5dff0b3..d5ab4c8 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,5 @@ +//! Module for storing MusicHoard data in a database. + use serde::de::DeserializeOwned; use serde::Serialize; @@ -5,6 +7,7 @@ pub mod json; /// Trait for database reads. pub trait DatabaseRead { + /// Read collection from the database. fn read(&mut self, collection: &mut D) -> Result<(), std::io::Error> where D: DeserializeOwned; @@ -12,6 +15,7 @@ pub trait DatabaseRead { /// Trait for database writes. pub trait DatabaseWrite { + /// Write collection to the database. fn write(&mut self, collection: &S) -> Result<(), std::io::Error> where S: Serialize; diff --git a/src/lib.rs b/src/lib.rs index 88c424c..1110f60 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,49 +4,30 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; pub mod database; +pub mod library; /// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID). pub type Mbid = Uuid; -/// [Artist](https://musicbrainz.org/doc/Artist). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct Artist { - pub name: String, - pub mbid: Option, -} - -/// [Track](https://musicbrainz.org/doc/Track). -#[derive(Debug, Deserialize, Serialize, PartialEq)] +/// A single track on an album. +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct Track { pub number: u32, pub title: String, - pub artist: Vec, - pub mbid: Option, + pub artist: Vec, } -/// [Release](https://musicbrainz.org/doc/Release). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct Release { - pub tracks: Vec, - pub mbid: Option, -} - -/// [Release group primary type](https://musicbrainz.org/doc/Release_Group/Type). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub enum ReleaseGroupType { - Album, - Ep, - Single, - Other, -} - -/// [Release group](https://musicbrainz.org/doc/Release_Group). -#[derive(Debug, Deserialize, Serialize, PartialEq)] -pub struct ReleaseGroup { - pub r#type: ReleaseGroupType, - pub title: String, - pub artist: Vec, +/// The album identifier. +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Hash)] +pub struct AlbumId { + pub artist: String, pub year: u32, - pub mbid: Option, - pub releases: Vec, + pub title: String, +} + +/// An album is a collection of tracks that were released together. +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct Album { + pub id: AlbumId, + pub tracks: Vec, } diff --git a/src/library/beets.rs b/src/library/beets.rs new file mode 100644 index 0000000..853f42a --- /dev/null +++ b/src/library/beets.rs @@ -0,0 +1,432 @@ +//! Module for interacting with the music library via +//! [beets](https://beets.readthedocs.io/en/stable/). + +use std::{collections::HashSet, fmt::Display, process::Command}; + +use crate::{Album, AlbumId, Track}; + +use super::{Error, Library, Query, QueryOption}; + +trait QueryOptionArgBeets { + fn to_arg(&self, option_name: &str) -> Option; +} + +trait QueryArgsBeets { + fn to_args(&self) -> Vec; +} + +trait SimpleOption {} +impl SimpleOption for String {} +impl SimpleOption for u32 {} + +impl QueryOptionArgBeets for QueryOption { + fn to_arg(&self, option_name: &str) -> Option { + let (negate, value) = match self { + Self::Include(value) => ("", value), + Self::Exclude(value) => ("^", value), + Self::None => return None, + }; + Some(format!("{}{}{}", negate, option_name, value)) + } +} + +impl QueryOptionArgBeets for QueryOption> { + fn to_arg(&self, option_name: &str) -> Option { + let (negate, vec) = match self { + Self::Include(value) => ("", value), + Self::Exclude(value) => ("^", value), + Self::None => return None, + }; + Some(format!("{}{}{}", negate, option_name, vec.join("; "))) + } +} + +impl QueryArgsBeets for Query { + fn to_args(&self) -> Vec { + let mut arguments: Vec = vec![]; + + if let Some(album_artist) = self.album_artist.to_arg("albumartist:") { + arguments.push(album_artist); + }; + + if let Some(album_year) = self.album_year.to_arg("year:") { + arguments.push(album_year); + }; + + if let Some(album_title) = self.album_title.to_arg("album:") { + arguments.push(album_title); + }; + + if let Some(track_number) = self.track_number.to_arg("track:") { + arguments.push(track_number); + }; + + if let Some(track_title) = self.track_title.to_arg("title:") { + arguments.push(track_title); + }; + + if let Some(track_artist) = self.track_artist.to_arg("artist:") { + arguments.push(track_artist); + }; + + if let Some(all) = self.all.to_arg("") { + arguments.push(all); + } + + arguments + } +} + +/// Trait for invoking beets commands. +pub trait BeetsExecutor { + /// Invoke beets with the provided arguments. + fn exec(&mut self, arguments: Vec) -> Result, Error>; +} + +/// Struct for interacting with the music library via beets. +pub struct Beets { + executor: Box, +} + +trait LibraryPrivate { + const CMD_LIST: &'static str; + const LIST_FORMAT_SEPARATOR: &'static str; + const LIST_FORMAT_ARG: &'static str; + + fn list_cmd_and_args(query: &Query) -> Vec; + fn list_to_albums(list_output: Vec) -> Result, Error>; +} + +impl Beets { + pub fn new(executor: Box) -> Beets { + Beets { executor } + } +} + +impl Library for Beets { + fn list(&mut self, query: &Query) -> Result, Error> { + let cmd = Self::list_cmd_and_args(query); + let output = self.executor.exec(cmd)?; + Self::list_to_albums(output) + } +} + +macro_rules! list_format_separator { + () => { + "-*^-" + }; +} + +impl LibraryPrivate for Beets { + const CMD_LIST: &'static str = "ls"; + const LIST_FORMAT_SEPARATOR: &'static str = list_format_separator!(); + const LIST_FORMAT_ARG: &'static str = concat!( + "--format=", + "$albumartist", + list_format_separator!(), + "$year", + list_format_separator!(), + "$album", + list_format_separator!(), + "$track", + list_format_separator!(), + "$title", + list_format_separator!(), + "$artist" + ); + + fn list_cmd_and_args(query: &Query) -> Vec { + let mut cmd: Vec = vec![String::from(Self::CMD_LIST)]; + cmd.push(Self::LIST_FORMAT_ARG.to_string()); + cmd.append(&mut query.to_args()); + cmd + } + + fn list_to_albums(list_output: Vec) -> Result, Error> { + let mut albums: Vec = vec![]; + let mut album_ids = HashSet::::new(); + + for line in list_output.iter() { + let split: Vec<&str> = line.split(Self::LIST_FORMAT_SEPARATOR).collect(); + + if split.len() != 6 { + return Err(Error::InvalidData(line.to_string())); + } + + let album_artist = split[0].to_string(); + let album_year = split[1].parse::()?; + let album_title = split[2].to_string(); + let track_number = split[3].parse::()?; + let track_title = split[4].to_string(); + let track_artist = split[5].to_string(); + + let aid = AlbumId { + artist: album_artist, + year: album_year, + title: album_title, + }; + + let track = Track { + number: track_number, + title: track_title, + artist: track_artist.split("; ").map(|s| s.to_owned()).collect(), + }; + + if album_ids.contains(&aid) { + // Beets returns results in order so we look from the back. + let album = albums.iter_mut().rev().find(|a| a.id == aid).unwrap(); + album.tracks.push(track); + } else { + album_ids.insert(aid.clone()); + albums.push(Album { + id: aid, + tracks: vec![track], + }); + } + } + + Ok(albums) + } +} + +/// Executor for executing beets commands on the local system. +pub struct SystemExecutor { + bin: String, +} + +impl SystemExecutor { + pub fn new(bin: &str) -> SystemExecutor { + SystemExecutor { + bin: bin.to_string(), + } + } +} + +impl Default for SystemExecutor { + fn default() -> Self { + SystemExecutor::new("beet") + } +} + +impl BeetsExecutor for SystemExecutor { + fn exec(&mut self, arguments: Vec) -> Result, Error> { + let output = Command::new(&self.bin).args(arguments).output()?; + let output = std::str::from_utf8(&output.stdout)?; + Ok(output.split('\n').map(|s| s.to_string()).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestExecutor { + arguments: Option>, + output: Option, Error>>, + } + + impl BeetsExecutor for TestExecutor { + fn exec(&mut self, arguments: Vec) -> Result, Error> { + if self.arguments.is_some() { + assert_eq!(self.arguments.take().unwrap(), arguments); + } + self.output.take().unwrap() + } + } + + fn test_data() -> Vec { + vec![ + Album { + id: AlbumId { + artist: "album_artist.a".to_string(), + year: 1998, + title: "album_title.a".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track.a.1".to_string(), + artist: vec!["artist.a.1".to_string()], + }, + Track { + number: 2, + title: "track.a.2".to_string(), + artist: vec!["artist.a.2.1".to_string(), "artist.a.2.2".to_string()], + }, + Track { + number: 3, + title: "track.a.3".to_string(), + artist: vec!["artist.a.3".to_string()], + }, + ], + }, + Album { + id: AlbumId { + artist: "album_artist.b".to_string(), + year: 2003, + title: "album_title.b".to_string(), + }, + tracks: vec![ + Track { + number: 1, + title: "track.b.1".to_string(), + artist: vec!["artist.b.1".to_string()], + }, + Track { + number: 2, + title: "track.b.2".to_string(), + artist: vec!["artist.b.2.1".to_string(), "artist.b.2.2".to_string()], + }, + ], + }, + ] + } + + fn album_to_beets_string(album: &Album) -> Vec { + let album_artist = &album.id.artist; + let album_year = &album.id.year; + let album_title = &album.id.title; + + let mut strings = vec![]; + for track in album.tracks.iter() { + let track_number = &track.number; + let track_title = &track.title; + let track_artist = &track.artist.join("; "); + + strings.push(format!( + "{album_artist}{0}{album_year}{0}{album_title}{0}\ + {track_number}{0}{track_title}{0}{track_artist}", + Beets::LIST_FORMAT_SEPARATOR, + )); + } + + strings + } + + #[test] + fn test_query() { + let query = Query::new() + .album_title(QueryOption::Exclude(String::from("some.album"))) + .track_number(QueryOption::Include(5)) + .track_artist(QueryOption::Include(vec![ + String::from("some.artist.1"), + String::from("some.artist.2"), + ])) + .all(QueryOption::Exclude(String::from("some.all"))); + + assert_eq!( + query.to_args(), + vec![ + String::from("^album:some.album"), + String::from("track:5"), + String::from("artist:some.artist.1; some.artist.2"), + String::from("^some.all"), + ] + ); + } + + #[test] + fn test_list_empty() { + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), + output: Some(Ok(vec![])), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + let expected: Vec = vec![]; + assert_eq!(output, expected); + } + + #[test] + fn test_list_ordered() { + let expected = test_data(); + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_unordered() { + let mut expected = test_data(); + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + let last = output.len() - 1; + output.swap(0, last); + + // Putting the last track first will make its entire album come first in the output. + expected.rotate_right(1); + + // Same applies to that album's tracks. + expected[0].tracks.rotate_right(1); + + // And the (now) second album's tracks first track comes last. + expected[1].tracks.rotate_left(1); + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_album_title_year_clash() { + let mut expected = test_data(); + expected[1].id.year = expected[0].id.year; + expected[1].id.title = expected[0].id.title.clone(); + + let mut output = vec![]; + for album in expected.iter() { + output.append(&mut album_to_beets_string(album)); + } + + let executor = TestExecutor { + arguments: Some(vec!["ls".to_string(), Beets::LIST_FORMAT_ARG.to_string()]), + output: Some(Ok(output)), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&Query::default()).unwrap(); + + assert_eq!(output, expected); + } + + #[test] + fn test_list_query() { + let query = Query::new() + .album_title(QueryOption::Exclude(String::from("some.album"))) + .track_number(QueryOption::Include(5)) + .track_artist(QueryOption::Include(vec![String::from("some.artist")])); + + let executor = TestExecutor { + arguments: Some(vec![ + "ls".to_string(), + Beets::LIST_FORMAT_ARG.to_string(), + String::from("^album:some.album"), + String::from("track:5"), + String::from("artist:some.artist"), + ]), + output: Some(Ok(vec![])), + }; + let mut beets = Beets::new(Box::new(executor)); + let output = beets.list(&query).unwrap(); + + let expected: Vec = vec![]; + assert_eq!(output, expected); + } +} diff --git a/src/library/mod.rs b/src/library/mod.rs new file mode 100644 index 0000000..4201d24 --- /dev/null +++ b/src/library/mod.rs @@ -0,0 +1,133 @@ +//! Module for interacting with the music library. + +use std::{num::ParseIntError, str::Utf8Error}; + +use crate::Album; + +pub mod beets; + +/// A single query option. +pub enum QueryOption { + /// Inclusive query. + Include(T), + /// Exclusive query. + Exclude(T), + /// No query. + None, +} + +impl QueryOption { + /// Return `true` if [QueryOption] is not [QueryOption::None]. + pub fn is_some(&self) -> bool { + !matches!(self, QueryOption::None) + } + + /// Return `true` if [QueryOption] is [QueryOption::None]. + pub fn is_none(&self) -> bool { + matches!(self, QueryOption::None) + } +} + +impl Default for QueryOption { + fn default() -> Self { + Self::None + } +} + +/// Options for refining library queries. +#[derive(Default)] +pub struct Query { + album_artist: QueryOption, + album_year: QueryOption, + album_title: QueryOption, + track_number: QueryOption, + track_title: QueryOption, + track_artist: QueryOption>, + all: QueryOption, +} + +impl Query { + /// Create an empty query. + pub fn new() -> Self { + Query::default() + } + + /// Refine the query to a specific album artist. + pub fn album_artist(mut self, album_artist: QueryOption) -> Self { + self.album_artist = album_artist; + self + } + + /// Refine the query to a specific album year. + pub fn album_year(mut self, album_year: QueryOption) -> Self { + self.album_year = album_year; + self + } + + /// Refine the query to a specific album title. + pub fn album_title(mut self, album_title: QueryOption) -> Self { + self.album_title = album_title; + self + } + + /// Refine the query to a specific track number. + pub fn track_number(mut self, track_number: QueryOption) -> Self { + self.track_number = track_number; + self + } + + /// Refine the query to a specific track title. + pub fn track_title(mut self, track_title: QueryOption) -> Self { + self.track_title = track_title; + self + } + + /// Refine the query to a specific set of track artists. + pub fn track_artist(mut self, track_artist: QueryOption>) -> Self { + self.track_artist = track_artist; + self + } + + /// Refine the query for all fields. + pub fn all(mut self, all: QueryOption) -> Self { + self.all = all; + self + } +} + +/// Error type for library calls. +#[derive(Debug)] +pub enum Error { + /// The underlying library returned invalid data. + InvalidData(String), + /// The underlying library experienced an I/O error. + IoError(String), + /// The underlying library failed to parse an integer. + ParseIntError(String), + /// The underlying library failed to parse a UTF-8 string. + Utf8Error(String), +} + +impl From for Error { + fn from(err: std::io::Error) -> Error { + Error::IoError(err.to_string()) + } +} + +impl From for Error { + fn from(err: ParseIntError) -> Error { + Error::ParseIntError(err.to_string()) + } +} + +impl From for Error { + fn from(err: Utf8Error) -> Error { + Error::Utf8Error(err.to_string()) + } +} + +/// Trait for interacting with the music library. +pub trait Library { + /// List lirbary items that match the a specific query. + fn list(&mut self, query: &Query) -> Result, Error>; +} diff --git a/tests/files/database_json_test.json b/tests/files/database_json_test.json index 4e3f7fc..afbc9fb 100644 --- a/tests/files/database_json_test.json +++ b/tests/files/database_json_test.json @@ -1 +1 @@ -[{"type":"Album","title":"Release group A","artist":[{"name":"Artist A","mbid":"f7769831-746b-4a12-8124-0123d7fe17c9"}],"year":1998,"mbid":"89efbf43-3395-4f6e-ac11-32c1ce514bb0","releases":[{"tracks":[{"number":1,"title":"Track A.1","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"}],"mbid":null},{"number":2,"title":"Track A.2","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"}],"mbid":null},{"number":3,"title":"Track A.3","artist":[{"name":"Artist A.A","mbid":"b7f7163d-61d5-4c96-b305-005df54fb999"},{"name":"Artist A.B","mbid":"6f6b46f2-4bb5-47e7-a8c8-03ebde30164f"}],"mbid":null}],"mbid":null}]},{"type":"Single","title":"Release group B","artist":[{"name":"Artist B","mbid":null}],"year":2008,"mbid":null,"releases":[{"tracks":[{"number":1,"title":"Track B.1","artist":[{"name":"Artist B.A","mbid":"d927e216-2e63-415c-acec-bf9f1abd3e3c"}],"mbid":"dacc9ce4-118c-4c92-aed7-1ebe4c7543b5"}],"mbid":"ac7b642d-8b71-4588-a694-e5ae43fac873"}]}] \ No newline at end of file +[{"id":{"artist":"Artist A","year":1998,"title":"Release group A"},"tracks":[{"number":1,"title":"Track A.1","artist":["Artist A.A"]},{"number":2,"title":"Track A.2","artist":["Artist A.A"]},{"number":3,"title":"Track A.3","artist":["Artist A.A","Artist A.B"]}]},{"id":{"artist":"Artist B","year":2008,"title":"Release group B"},"tracks":[{"number":1,"title":"Track B.1","artist":["Artist B.A"]}]}] \ No newline at end of file