class ID3_INFO

(source code)

Description

Id3 info

note
	description: "Id3 info"

	author: "Finnian Reilly"
	copyright: "Copyright (c) 2001-2022 Finnian Reilly"
	contact: "finnian at eiffel hyphen loop dot com"

	license: "MIT license (See: en.wikipedia.org/wiki/MIT_License)"
	date: "2023-03-10 10:10:55 GMT (Friday 10th March 2023)"
	revision: "13"

class
	ID3_INFO

inherit
	ID3_SHARED_ENCODING_ENUM
		export
			{NONE} all
		redefine
			default_create
		end

	MEMORY
		export
			{NONE} all
		undefine
			default_create
		end

	ID3_MODULE_TAG

create
	make, make_version, make_version_23

feature {NONE} -- Initialization

	default_create
		do
			create unique_id_list.make
			create comment_table.make_equal (5)
			create user_text_table.make_equal (3)
			create basic_fields.make (10); basic_fields.compare_objects
			create internal_album_picture.make (0)
			encoding := Encoding_enum.UTF_8
		end

	make (a_mp3_path: FILE_PATH)
	 		--
		do
			make_version (a_mp3_path, 0.0)
		end

	make_version (a_mp3_path: FILE_PATH; a_version: REAL)
		require
			valid_version: a_version /~ 0.0 implies a_version >= 2.2 and a_version <= 2.4
		local
			real: REAL
		do
			default_create
			create header.make (a_mp3_path)
			if header.is_valid then
				if a_version ~ real.zero then
					implementation := new_implementation (header.version)
				else
					implementation := new_implementation (a_version)
				end
				implementation.link_and_read (a_mp3_path)

				initialize_tables
				set_encoding_from_basic_fields
			else
				-- Create an empty ID3 2.3 set
				create {LIBID3_TAG_INFO} implementation.make
				implementation.link (a_mp3_path)
				update
				create header.make (a_mp3_path)
			end
		end

	make_version_23 (id3_info: like Current)
			-- make version 2.3 id3
		do
			default_create
			create header.make (id3_info.mp3_path)

			create {LIBID3_TAG_INFO} implementation.make
			implementation.set_version (2.3)
			header.set_version (2.3)

			-- Link without reading tags as they might be version 2.4 which will cause problems with libid3
			implementation.link (id3_info.mp3_path)
			encoding := id3_info.encoding

			across id3_info.basic_fields as field loop
				set_field_string (field.key, field.item.string, encoding)
			end
			across id3_info.comment_table as entry loop
				set_comment (entry.key, entry.item.string)
			end
			across id3_info.user_text_table as entry loop
				set_user_text (entry.key, entry.item.string)
			end
			across id3_info.unique_id_list as unique_id loop
				set_unique_id (unique_id.item.owner, unique_id.item.id)
			end
			if id3_info.has_album_picture then
				set_album_picture (id3_info.album_picture)
			end
		end

	initialize_tables
		local
			field: ID3_FRAME
			name, value: ZSTRING
		do
--			log.enter ("initialize_tables")
			across fields as l_field loop
				field := l_field.item
				if attached {ID3_UNIQUE_FILE_ID_FRAME} field as unique_id_field and then not unique_id_field.id.is_empty then
					unique_id_list.extend (unique_id_field)
					name := unique_id_field.owner; value := unique_id_field.id

				elseif attached {ID3_ALBUM_PICTURE_FRAME} field as picture_field then
					internal_album_picture.extend (picture_field)
					name := picture_field.description
					value := picture_field.picture.checksum.out

				elseif field.code ~ Tag.User_text then
					user_text_table [field.description] := field
					name := field.description; value := field.string

				elseif field.code ~ Tag.Comment then
					if field.description.is_empty then
						create name.make_empty
						name.append_string_general ("COMM_")
						name.append_integer (comment_table.count + 1)
					else
						name := field.description
					end
					comment_table.put (field, name)
					value := field.string

				elseif Tag.Basic.has (field.code) then
					basic_fields [field.code] := field
					name := Encoding_enum.name (field.encoding)
					if field.code ~ Tag.Album_picture then
						value := field.out
					else
						value := field.string
					end

				else
					name := Encoding_enum.name (field.encoding); value := field.string

				end
--				log.put_string_field (field.code + " (" + name + ")", value)
--				log.put_new_line
			end
--			log.exit
		end

feature -- Basic fields

	title: ZSTRING
			--
		do
			Result := field_string (Tag.Title)
		end

	artist: ZSTRING
			--
		do
			Result := field_string (Tag.Artist)
		end

	album: ZSTRING
			--
		do
			Result := field_string (Tag.Album)
		end

	album_artist: ZSTRING
			--
		do
			Result := field_string (Tag.Album_artist)
		end

	album_picture: ID3_ALBUM_PICTURE
		do
			if has_album_picture then
--				log_or_io.put_string_field ("APIC", basic_fields.item (Tag.album_picture).out)
--				log_or_io.put_new_line
				Result := internal_album_picture.first.picture
			else
				create Result
			end
		end

	composer: ZSTRING
			--
		do
			Result := field_string (Tag.Composer)
		end

	genre: ZSTRING
			--
		do
			Result := field_string (Tag.Genre)
		end

	year: INTEGER
			--
		do
			Result := field_integer (Tag.Recording_time)
			if Result = 0 then
				Result := field_integer (Tag.Year)
			end
		end

	track: INTEGER
			--
		do
			Result := field_integer (Tag.Track)
		end

	duration: TIME_DURATION
			--
		do
			create Result.make_by_fine_seconds (field_integer (Tag.duration) / 1000)
		end

	basic_fields: HASH_TABLE [ID3_FRAME, STRING]
		-- Basic field frames

feature -- Access

	header: ID3_HEADER

	version: REAL
			--
		do
			Result := header.version
		end

	major_version: INTEGER
			--
		do
			Result := 2
		end

	comment (a_key: ZSTRING): ZSTRING
			--
		do
			Result := table_text (a_key, comment_table)
		end

	user_text (a_key: ZSTRING): ZSTRING
			--
		do
			Result := table_text (a_key, user_text_table)
		end

	unique_id_for_owner (owner: ZSTRING): STRING
			--
		do
			create Result.make_empty
			from unique_id_list.start until unique_id_list.after loop
				if unique_id_list.item.owner ~ owner then
					Result := unique_id_list.item.id
				end
				unique_id_list.forth
			end
		end

	field_string (id: STRING): ZSTRING
				--
		do
--			Result := field_of_type (agent {ID3_FRAME}.string, id)
			if basic_fields.has_key (id) then
				Result := basic_fields.found_item.string
			else
				create Result.make_empty
			end
		end

	field_language (id: STRING): ZSTRING
				--
		do
			Result := field_of_type (agent {ID3_FRAME}.string, id)
		end

	field_description (id: STRING): STRING
				--
		do
			if basic_fields.has_key (id) then
				Result := basic_fields.found_item.description
			else
				create Result.make_empty
			end
		end

	field_integer (id: STRING): INTEGER
			--
		local
			l_str: ZSTRING
		do
			l_str := field_string (id)
			if l_str.is_integer then
				Result := l_str.to_integer
			end
		end

	fields_with_id (id: STRING): LIST [ID3_FRAME]
				--
		do
			Result := fields.query_if (agent field_code_equals (?, id))
		end

	encoding_name: STRING
			--
		do
			Result := Encoding_enum.name (encoding)
		end

	encoding: NATURAL_8

	mp3_path: FILE_PATH
		do
			Result := implementation.mp3_path
		end

	comment_table: EL_ZSTRING_HASH_TABLE [ID3_FRAME]

	user_text_table: EL_ZSTRING_HASH_TABLE [ID3_FRAME]

	unique_id_list: LINKED_LIST [ID3_UNIQUE_FILE_ID_FRAME]

feature -- Status report

	is_libid3_implementation: BOOLEAN
			--
		do
			Result := attached {LIBID3_TAG_INFO} implementation
		end

	has_multiple_owners_for_UFID: BOOLEAN
		do
			Result := across unique_id_owner_counts as count some count.item > 1 end
		end

	has_album_picture: BOOLEAN
		do
			Result := not internal_album_picture.is_empty
		end

	has_unique_id (owner: ZSTRING): BOOLEAN
			--
		do
			Result := across unique_id_list as unique_file_id some unique_file_id.item.owner ~ owner end
		end

	duplicates_found: BOOLEAN

feature -- Element change

	set_beats_per_minute (a_beats_per_minute: INTEGER)
			--
		do
			set_beats_per_minute_from_string (a_beats_per_minute.out)
		end

	set_beats_per_minute_from_string (a_beats_per_minute: ZSTRING)
			--
		do
			set_field_string (Tag.Beats_per_minute, a_beats_per_minute, encoding)
		end

	set_encoding (a_name: STRING)
			--
		local
			l_changed: BOOLEAN; l_encoding: ID3_ENCODING
		do
			if Encoding_enum.is_valid_name (a_name.as_upper) then
				create l_encoding.make (a_name.as_upper)
			else
				create l_encoding.make_default
			end
			if l_encoding.value /= Encoding_enum.Unknown then
				encoding := l_encoding.value
				across fields as field loop
					if field.item.encoding /= encoding then
						l_changed := True
						field.item.set_encoding (encoding)
					end
				end
				if l_changed then
					update
				end
			end
		end

	set_music_brainz_track_id (track_id: STRING)
		do
			remove_unique_id (Http_musicbrainz_org)
			set_unique_id (Http_musicbrainz_org, track_id)
		end

	set_music_brainz_field (name: STRING; value: ZSTRING)
		require
			valid_name: Music_brainz_fields.has (name)
		local
			mb_name: ZSTRING
		do
			mb_name := Music_brainz_prefix + name
			if value.is_empty then
				remove_user_text (mb_name)
			else
				set_user_text (mb_name, value)
			end
		end

	set_version (a_version: REAL)
			--
		local
			id3_info_23: like Current
		do
			if version /= a_version then
				if a_version < 2.4 then
					create id3_info_23.make_version_23 (Current)

					-- Dispose 2.4 immediately to ensure file write lock is released (Underbit implementation)
					implementation.dispose
					copy (id3_info_23)
				end
			end
		end

	set_unique_id (owner_id: ZSTRING; hex_string: STRING)
			--
		local
			owner_found: BOOLEAN
		do
			across unique_id_list as unique_id until owner_found loop
				owner_found := unique_id.item.owner ~ owner_id
			end
			if owner_found then
				unique_id_list.item.set_id (hex_string)
			else
				unique_id_list.extend (implementation.new_unique_file_id_field (owner_id, hex_string))
			end
		end

	set_year (a_year: INTEGER)
			--
		do
			set_year_from_string (a_year.out)
		ensure
			year_set: a_year > 0 implies year ~ a_year
		end

	set_year_from_string (a_year: ZSTRING)
			--
		do
			set_field_string (Tag.Recording_time, a_year, encoding)
		end

	set_year_from_days (days: INTEGER)
			--
		do
			set_year (days // Days_in_year)
		end

	set_artist (a_artist: ZSTRING)
			--
		do
			set_field_string (Tag.Artist, a_artist, encoding)
		ensure
			is_set: artist ~ a_artist
		end

	set_album_artist (a_album_artist: ZSTRING)
			--
		do
			set_field_string (Tag.Album_artist, a_album_artist, encoding)
		ensure
			is_set: album_artist ~ a_album_artist
		end

	set_title (a_title: ZSTRING)
			--
		do
			set_field_string (Tag.Title, a_title, encoding)
		ensure
			is_set: title ~ a_title
		end

	set_album (a_album: ZSTRING)
			--
		do
			set_field_string (Tag.Album, a_album, encoding)
		ensure
			is_set: album ~ a_album
		end

	set_album_picture (a_picture: ID3_ALBUM_PICTURE)
		do
			if has_album_picture then
				remove_field (internal_album_picture.first)
				internal_album_picture.wipe_out
			end
			internal_album_picture.extend (implementation.new_album_picture_frame (a_picture))
		end

	set_genre (a_genre: ZSTRING)
			--
		do
			set_field_string (Tag.Genre, a_genre, encoding)
		ensure
			is_set: genre ~ a_genre
		end

	set_composer (a_composer: ZSTRING)
			--
		do
			set_field_string (Tag.Composer, a_composer, encoding)
		ensure
			is_set: composer ~ a_composer
		end

	set_comment (a_key, a_comment: ZSTRING)
			--
		do
			set_table_item (comment_table, a_key, a_comment, Tag.Comment)
		end

	set_user_text (a_key, a_text: ZSTRING)
			--
		do
			set_table_item (user_text_table, a_key, a_text, Tag.User_text)
		end

	set_track (a_track: INTEGER)
			--
		do
			set_field_string (Tag.Track, a_track.out, encoding)
		ensure
			is_set: track ~ a_track
		end

	set_duration (a_duration: TIME_DURATION)
			--
		local
			millisecs: INTEGER
		do
			millisecs := (a_duration.fine_seconds_count * 1000).rounded
			set_field_string (Tag.Duration, millisecs.out, Encoding_enum.ISO_8859_1)
		ensure
			is_set: (duration.fine_seconds_count - a_duration.fine_seconds_count).abs < 0.001
		end

	Zero_duration: TIME
		once
			create Result.make_by_compact_time (0)
		end

	set_field_string (name: STRING; value: ZSTRING; a_encoding: INTEGER)
		do
			set_field_of_type (agent {ID3_FRAME}.set_string, name, value, a_encoding)
		end

	set_field_description (name: STRING; value: ZSTRING; a_encoding: INTEGER)
		do
			set_field_of_type (agent {ID3_FRAME}.set_description, name, value, a_encoding)
		end

	set_field_language (name: STRING; value: ZSTRING; a_encoding: INTEGER)
		do
			set_field_of_type (agent {ID3_FRAME}.set_language, name, value, a_encoding)
		end

	append_unique_ids (id_list: ITERABLE [ID3_UNIQUE_FILE_ID_FRAME])
			--
		do
			across id_list as l_unique loop
				set_unique_id (l_unique.item.owner, l_unique.item.id)
			end
		end

feature {NONE} -- Element change

	set_table_item (table: like comment_table; a_key, a_string: ZSTRING; a_code: STRING)
			-- set comment or user text table
		require
			valid_table: table = comment_table or table = user_text_table
		local
			field: ID3_FRAME
		do
			table.search (a_key)
			if table.found then
				field := table.found_item
			else
				field := implementation.new_field (a_code)
				field.set_encoding (encoding)
				table.extend (field, a_key)
			end
			set_field (field, a_key, a_string)
		ensure
			string_set: table.item (a_key).string ~ a_string and table.item (a_key).description ~ a_key
		end

	set_field (a_field: ID3_FRAME; a_description, a_text: ZSTRING)
		do
			a_field.set_encoding (encoding)
			a_field.set_description (a_description)
			a_field.set_string (a_text)
		end

	set_field_of_type (set_id3_field: PROCEDURE; name: STRING; value: ZSTRING; a_encoding: INTEGER)
			--
		local
			field: ID3_FRAME
		do
			if basic_fields.has_key (name) then
				field := basic_fields.found_item
			else
				field := implementation.new_field (name)
				if Tag.Basic.has (name) then
					basic_fields [name] := field
				end
			end
			field.set_encoding (encoding)
			set_id3_field (field, value)
		end

feature -- Removal

	remove_duplicate_unique_ids
			--
		do
			across unique_id_owner_counts as owner_count loop
				across 2 |..| owner_count.item as n loop
					remove_unique_id (owner_count.key)
				end
			end
		end

	remove_unique_id (owner_id: ZSTRING)
			--
		local
			found: BOOLEAN
		do
			from unique_id_list.start until found or unique_id_list.after loop
				if unique_id_list.item.owner ~ owner_id then
					remove_field (unique_id_list.item)
					unique_id_list.remove
					found := True
				else
					unique_id_list.forth
				end
			end
		end

	remove_comment (a_key: ZSTRING)
			--
		do
			remove_table_item (comment_table, a_key, Tag.Comment)
		end

	remove_user_text (a_key: ZSTRING)
			--
		do
			remove_table_item (user_text_table, a_key, Tag.User_text)
		end

	remove_all_unique_ids
			--
		do
			remove_fields_with_id (Tag.unique_file_id)
		end

	remove_fields_with_id (id: STRING)
				--
		do
			fields_with_id (id).do_all (agent remove_field)
			if id ~ Tag.Comment then
				comment_table.wipe_out
			end
			if id ~ Tag.Unique_file_id then
				unique_id_list.wipe_out
			end
			if Tag.Basic.has (id) then
				Basic_fields.remove (id)
			end
		end

	remove_basic_field (code: STRING)
				-- Remove field
		do
			basic_fields.search (code)
			if basic_fields.found then
				remove_field (basic_fields.found_item)
				basic_fields.remove (code)
			end
		end

	remove_field (field: ID3_FRAME)
				-- Remove field
		do
			implementation.prune (field)
		end

	remove_album_picture
		do
			if has_album_picture then
				remove_field (internal_album_picture.first)
				internal_album_picture.wipe_out
			end
		end

	wipe_out
			--
		local
			containers: ARRAY [COLLECTION [ID3_FRAME]]
		do
			containers := << unique_id_list, basic_fields, comment_table, user_text_table >>
			across containers as container loop
				container.item.wipe_out
			end
			implementation.wipe_out
		end

feature -- File writes

	update
			--
		do
			implementation.update_v2
		end

--	update_v1
--			--
--		do
--			implementation.update_v1
--		end

--	update_v2
--			--
--		do
--			implementation.update_v2
--		end

	strip_v1
			--
		require
			valid_implementation: is_libid3_implementation
		do
			implementation.strip_v1
		end

	strip_v2
			--
		require
			valid_implementation: is_libid3_implementation
		do
			implementation.strip_v2
		end

feature {NONE} -- Implementation

	field_code_equals (field: ID3_FRAME id: STRING): BOOLEAN
		do
			Result := field.code ~ id
		end

	table_text (a_key: ZSTRING; a_table: like comment_table): ZSTRING
			--
		do
			a_table.search (a_key)
			if a_table.found then
				Result := a_table.found_item.string
			else
				create Result.make_empty
			end
		end

	set_encoding_from_basic_fields
		do
			basic_fields.search (Tag.Title)
			if not basic_fields.found then
				basic_fields.search (Tag.Artist)
			end
			if basic_fields.found then
				encoding := basic_fields.found_item.encoding
			end
		end

	remove_table_item (a_table: like comment_table; a_key: ZSTRING; a_code: STRING)
			--
		do
			a_table.search (a_key)
			if a_table.found then
				-- Make sure any duplicate fields are removed
				from fields.start until fields.after loop
					if fields.item.code ~ a_code and then fields.item.description ~ a_key then
						implementation.detach (fields.item)
						fields.remove
					else
						fields.forth
					end
				end
				a_table.remove (a_key)
			end
		end

	unique_id_owner_counts: HASH_TABLE [INTEGER, ZSTRING]
		do
			create Result.make_equal (7)
			across unique_id_list as unique_id loop
				Result.put (1, unique_id.item.owner)
				if Result.conflict then
					Result [unique_id.item.owner] := Result [unique_id.item.owner] + 1
				end
			end
		end

	field_of_type (getter_action: FUNCTION [ZSTRING]; id: STRING): ZSTRING
				--
		do
			basic_fields.search (id)
			if basic_fields.found then
				Result := getter_action.item ([basic_fields.found_item])
			else
				create Result.make_empty
			end
		end

	new_implementation (a_version: REAL): like implementation
		do
			if a_version = 2.4 then
				create {UNDERBIT_ID3_TAG_INFO} Result.make

			elseif a_version <= 2.3 and a_version >= 2.0 then
				create {LIBID3_TAG_INFO} Result.make
				Result.set_version (a_version)

			else
				-- Unknown version
				create {UNDERBIT_ID3_TAG_INFO} Result.make
			end

		end

	fields: EL_ARRAYED_LIST [ID3_FRAME]
		do
			Result := implementation.frame_list
		end

	internal_album_picture: ARRAYED_LIST [ID3_ALBUM_PICTURE_FRAME]

	implementation: ID3_INFO_I

feature -- Constants

	Days_in_year: INTEGER = 365

	Http_musicbrainz_org: ZSTRING
		once
			Result := "http://musicbrainz.org"
		end

	Music_brainz_prefix: ZSTRING
		once
			Result := "musicbrainz_"
		end

	Music_brainz_fields: ARRAY [STRING]
		once
			Result := << "artistid", "albumid", "albumartistid", "artistsortname" >>
			Result.compare_objects
		end
end