MediaItem.java

/*
 * *********************************************************************************************************************
 *
 * blueMarine II: Semantic Media Centre
 * http://tidalwave.it/projects/bluemarine2
 *
 * Copyright (C) 2015 - 2021 by Tidalwave s.a.s. (http://tidalwave.it)
 *
 * *********************************************************************************************************************
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
 * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations under the License.
 *
 * *********************************************************************************************************************
 *
 * git clone https://bitbucket.org/tidalwave/bluemarine2-src
 * git clone https://github.com/tidalwave-it/bluemarine2-src
 *
 * *********************************************************************************************************************
 */
package it.tidalwave.bluemarine2.model;

import javax.annotation.Nonnegative;
import javax.annotation.Nonnull;
import javax.annotation.concurrent.Immutable;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import it.tidalwave.util.Id;
import it.tidalwave.util.Key;
import it.tidalwave.bluemarine2.model.role.AudioFileSupplier;
import it.tidalwave.bluemarine2.model.spi.PathAwareEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
import static lombok.AccessLevel.PRIVATE;

/***********************************************************************************************************************
 *
 * Represents a media item. It is usually associated with one or more files on a filesystem.
 *
 * @stereotype  Datum
 *
 * @author  Fabrizio Giudici
 *
 **********************************************************************************************************************/
public interface MediaItem extends PathAwareEntity, AudioFileSupplier
  {
    /*******************************************************************************************************************
     *
     * A container of metadata objects for a {@link MediaItem}.
     *
     ******************************************************************************************************************/
    public interface Metadata
      {
        public static final Key<Long> FILE_SIZE = Key.of("file.size", Long.class);

        public static final Key<Duration> DURATION = Key.of("mp3.duration", Duration.class);
        public static final Key<Integer> BIT_RATE = Key.of("mp3.bitRate", Integer.class);
        public static final Key<Integer> SAMPLE_RATE = Key.of("mp3.sampleRate", Integer.class);
        public static final Key<String> ARTIST = Key.of("mp3.artist", String.class);
        public static final Key<String> COMPOSER = Key.of("mp3.composer", String.class);
        public static final Key<String> PUBLISHER = Key.of("mp3.publisher", String.class);
        public static final Key<String> TITLE = Key.of("mp3.title", String.class);
        public static final Key<Integer> YEAR = Key.of("mp3.year", Integer.class);
        public static final Key<String> ALBUM = Key.of("mp3.album", String.class);
        public static final Key<Integer> TRACK_NUMBER = Key.of("mp3.trackNumber", Integer.class);
        public static final Key<Integer> DISK_NUMBER = Key.of("mp3.diskNumber", Integer.class);
        public static final Key<Integer> DISK_COUNT = Key.of("mp3.diskCount", Integer.class);
        public static final Key<List<String>> COMMENT = new Key<>("mp3.comment") {};
        public static final Key<Integer> BITS_PER_SAMPLE = Key.of("mp3.bitsPerSample", Integer.class);
        public static final Key<String> FORMAT = Key.of("mp3.format", String.class);
        public static final Key<String> ENCODING_TYPE = Key.of("mp3.encodingType", String.class);
        public static final Key<Integer> CHANNELS = Key.of("mp3.channels", Integer.class);

        public static final Key<List<byte[]>> ARTWORK = new Key<>("mp3.artwork") {};

        public static final Key<Id> MBZ_TRACK_ID = Key.of("mbz.trackId", Id.class);
        public static final Key<Id> MBZ_WORK_ID = Key.of("mbz.workId", Id.class);
        public static final Key<Id> MBZ_DISC_ID = Key.of("mbz.discId", Id.class);
        public static final Key<List<Id>> MBZ_ARTIST_ID = new Key<>("mbz.artistId") {};

        public final Key<List<String>> ENCODER = new Key<>("tag.ENCODER") {}; // FIXME: key name

        public static final Key<ITunesComment> ITUNES_COMMENT = Key.of("iTunes.comment", ITunesComment.class);
        public static final Key<Cddb> CDDB = Key.of("cddb", Cddb.class);

        /***************************************************************************************************************
         *
         * The CDDB item.
         *
         **************************************************************************************************************/
        @Immutable @AllArgsConstructor(access = PRIVATE) @Getter @Builder @ToString @EqualsAndHashCode
        public static class Cddb
          {
            @Nonnull
            private final String discId;

            @Nonnull
            private final int[] trackFrameOffsets;

            private final int discLength;

            /***********************************************************************************************************
             *
             * Returns the TOC (Table Of Contents) of this CDDB in string form (e.g. {@code 1+3+4506+150+3400+4000})
             *
             * @return  the TOC
             *
             **********************************************************************************************************/
            @Nonnull
            public String getToc()
              {
                return String.format("1+%d+%d+%s", trackFrameOffsets.length, discLength,
                                                   Arrays.toString(trackFrameOffsets).replace(", ", "+").replace("[", "").replace("]", ""));
              }

            /***********************************************************************************************************
             *
             * Returns the number of tracks in the TOC
             *
             * @return  the number of tracks
             *
             **********************************************************************************************************/
            @Nonnegative
            public int getTrackCount()
              {
                return trackFrameOffsets.length;
              }

            /***********************************************************************************************************
             *
             * Returns {@code true} if this object matches the other CDDB within a given threshold.
             *
             * @param   other       the other CDDB
             * @param   threshold   the threshold of the comparison
             * @return              {@code true} if this object matches
             *
             **********************************************************************************************************/
            public boolean matches (@Nonnull final Cddb other, @Nonnegative final int threshold)
              {
                if (Arrays.equals(this.trackFrameOffsets, other.trackFrameOffsets))
                  {
                    return true;
                  }

                if (!this.sameTrackCountOf(other))
                  {
                    return false;
                  }

                return this.computeDifference(other) <= threshold;
              }

            /***********************************************************************************************************
             *
             * Returns {@code true} if this object contains the same number of tracks of the other CDDB
             *
             * @param   other       the other CDDB
             * @return              {@code true} if the number of tracks matches
             *
             **********************************************************************************************************/
            public boolean sameTrackCountOf (@Nonnull final Cddb other)
              {
                return this.trackFrameOffsets.length == other.trackFrameOffsets.length;
              }

            /***********************************************************************************************************
             *
             * Computes the difference to another CDDB.
             *
             * @param   other       the other CDDB
             * @return              the difference
             *
             **********************************************************************************************************/
            public int computeDifference (@Nonnull final Cddb other)
              {
                final int delta = this.trackFrameOffsets[0] - other.trackFrameOffsets[0];
                double acc = 0;

                for (int i = 1; i < this.trackFrameOffsets.length; i++)
                  {
                    final double x = (this.trackFrameOffsets[i] - other.trackFrameOffsets[i] - delta)
                                            / (double)other.trackFrameOffsets[i];
                    acc += x * x;
                  }

                return (int)Math.round(acc * 1E6);
              }
          }

        /***************************************************************************************************************
         *
         *
         *
         **************************************************************************************************************/
        @Immutable @AllArgsConstructor(access = PRIVATE) @Getter @ToString @EqualsAndHashCode
        public static class ITunesComment
          {
            private static final Pattern PATTERN_TO_STRING = Pattern.compile(
                    "MediaItem.Metadata.ITunesComment\\(cddb1=([^,]*), cddbTrackNumber=([0-9]+)\\)");

            @Nonnull
            private final String cddb1;

            @Nonnull
            private final String cddbTrackNumber;

            /***********************************************************************************************************
             *
             * Returns an unique track id out of the data in this object.
             *
             * @return              the track id
             *
             **********************************************************************************************************/
            @Nonnull
            public String getTrackId()
              {
                return cddb1 + "/" + cddbTrackNumber;
              }

            /***********************************************************************************************************
             *
             * Returns the same data in form of a CDDB.
             *
             * @return              the CDDB
             *
             **********************************************************************************************************/
            @Nonnull
            public Cddb getCddb()
              {
                return Cddb.builder().discId(cddb1.split("\\+")[0])
                                     .discLength(Integer.parseInt(cddb1.split("\\+")[1]))
                                     .trackFrameOffsets(Stream.of(cddb1.split("\\+"))
                                                              .skip(3)
                                                              .mapToInt(Integer::parseInt)
                                                              .toArray())
                                     .build();
              }

            /***********************************************************************************************************
             *
             * Factory method extracting data from a {@link Metadata} instance.
             *
             * @param   metadata    the data source
             * @return              the {@code ITunesComment}
             *
             **********************************************************************************************************/
            @Nonnull
            public static Optional<ITunesComment> from (@Nonnull final Metadata metadata)
              {
                return metadata.get(ENCODER).flatMap(
                        encoders -> encoders.stream().anyMatch(encoder -> encoder.startsWith("iTunes"))
                                                        ? metadata.get(COMMENT).flatMap(ITunesComment::from)
                                                        : Optional.empty());
              }

            /***********************************************************************************************************
             *
             * Factory method extracting data from a string representation.
             *
             * @param   string      the string source
             * @return              the {@code ITunesComment}
             *
             **********************************************************************************************************/
            @Nonnull
            public static ITunesComment fromToString (@Nonnull final String string)
              {
                final Matcher matcher = PATTERN_TO_STRING.matcher(string);

                if (!matcher.matches())
                  {
                    throw new IllegalArgumentException("Invalid string: " + string);
                  }

                return new ITunesComment(matcher.group(1), matcher.group(2));
              }

            /***********************************************************************************************************
             *
             * Factory method extracting data from a string representation as in the iTunes Comment MP3 tag.
             *
             * @param   comments    the source
             * @return              the {@code ITunesComment}
             *
             **********************************************************************************************************/
            @Nonnull
            private static Optional<ITunesComment> from (@Nonnull final List <String> comments)
              {
                return comments.get(comments.size() - 2).contains("+")
                        ? Optional.of(new ITunesComment(comments.get(3), comments.get(4)))
                        : Optional.empty();
              }
          }

        /***************************************************************************************************************
         *
         * Extracts a single metadata item associated to the given key.
         *
         * @param       <T>     the type of the item
         * @param       key     the key
         * @return              the item
         *
         **************************************************************************************************************/
        @Nonnull
        public <T> Optional<T> get (@Nonnull Key<T> key);

        /***************************************************************************************************************
         *
         * Extracts a metadata item (typically a collection) associated to the given key.
         *
         * @param       <T>     the type of the item
         * @param       key     the key
         * @return              the item
         *
         **************************************************************************************************************/
        @Nonnull
        public <T> T getAll (@Nonnull Key<T> key);

        /***************************************************************************************************************
         *
         * Returns {@code true} if an item with the given key is present.
         *
         * @param       key     the key
         * @return              {@code true} if found
         *
         **************************************************************************************************************/
        public boolean containsKey (@Nonnull Key<?> key);

        /***************************************************************************************************************
         *
         * Returns all the keys contained in this instance.
         *
         * @return              all the keys
         *
         **************************************************************************************************************/
        @Nonnull
        public Set<Key<?>> getKeys();

        /***************************************************************************************************************
         *
         * Returns all the entries (key -> value) contained in this instance.
         *
         * @return              all the entries
         *
         **************************************************************************************************************/
        @Nonnull
        public Set<Map.Entry<Key<?>, ?>> getEntries();

        /***************************************************************************************************************
         *
         * Returns a clone of this object with an additional item.
         *
         * @param       <T>     the type of the item
         * @param       key     the key
         * @param       value   the value
         * @return              the clone
         *
         **************************************************************************************************************/
        @Nonnull
        public <T> Metadata with (@Nonnull Key<T> key, T value);

        /***************************************************************************************************************
         *
         * Returns a clone of this object with an additional optional value.
         *
         * @param       <T>     the type of the item
         * @param       key     the key
         * @param       value   the value
         * @return              the clone
         *
         **************************************************************************************************************/
        @Nonnull
        public <T> Metadata with (@Nonnull Key<T> key, Optional<T> value);

        /***************************************************************************************************************
         *
         * Returns a clone of this object with a fallback data source; when an item is searched and not found, before
         * giving up it will be searched in the given fallback.
         *
         * @param       fallback    the fallback
         * @return                  the clone
         *
         **************************************************************************************************************/
        @Nonnull
        public Metadata withFallback (@Nonnull Function<Key<?>, Metadata> fallback);
    }

    /*******************************************************************************************************************
     *
     * Returns the {@link Metadata} associated with this object.
     *
     * @return  the metadata
     *
     ******************************************************************************************************************/
    @Nonnull
    public Metadata getMetadata();
  }