Repository: AntennaPod/AntennaPod (language: java)

Issue:
Support WebVTT transcript format
### Checklist

- [X] I have used the search function for [**OPEN**](https://github.com/AntennaPod/AntennaPod/issues) issues to see if someone else has already submitted the same feature request.
- [X] I have **also** used the search function for [**CLOSED**](https://github.com/AntennaPod/AntennaPod/issues?q=is%3Aissue+is%3Aclosed) issues to see if the feature was already implemented and is just waiting to be released, or if the feature was rejected.
- [X] I will describe the problem with as much detail as possible.
- [X] This request contains only one single feature, **not** a list of multiple (related) features.

### App version

3.5.0

### Where did you get the app from

Google Play

### Feature or improvement you want

We currently support SRT, JSON transcript.   We should add support for WebVTT as well.  Detailed reasons why certain podcast hosts are only supporting WebVTT here https://github.com/jamescridland/podnews/issues/2

### Why should this be added?

Wider support of transcript

### Screenshots / Drawings / Technical details

```
curl https://podnews.net/rss > ~/Downloads/podnews.rss
```

```
<podcast:transcript url="https://podnews.net/audio/podnews240927.mp3.vtt" type="text/vtt" rel="captions"/>
```

```
curl https://podnews.net/audio/podnews240926.mp3.vtt | less

WEBVTT

00:00:00.000 --> 00:00:05.900
<v James Cridland>Paid New York Times podcasts go to Apple and Spotify - from the Pacific Island of Niue,

00:00:05.900 --> 00:00:13.100
the latest from Podnews.net with Podpage: making SEO-optimized podcast websites.

00:00:13.100 --> 00:00:16.900
<v Sabrina Tavernise>From the New York Times, I'm Sabrina Tavernise, and this is The Daily.

00:00:16.900 --> 00:00:21.640
<v James Cridland>The New York Times is to make its premium paid podcasts available for New York Times subscribers

```


Discussion hints:
:)

This is a good VTT to show, in that it has the speaker identification in it.
Hello,
Thanks a lot for the work already done on transcripts!

I’d very much like to see VTT support happening, and to that end I volunteer to try and add the feature.

But I see that @tonytamsf you already assigned yourself to that feature. Are you already working on this, or should I start an implementation on my end?
@Shaac go ahead and start working on it. I will assign to you
Great!

So I started working on it and here is my progress so far [(you can look at the current work here)](https://github.com/AntennaPod/AntennaPod/compare/develop...Shaac:AntennaPod:vtt?expand=1).

I added a third supported transcript format and used the following preference if several are present: JSON > VTT > SRT. Previously it was JSON > SRT, I inserted VTT in the middle, that is what made the most sense to me, but I guess it could be anything.

For the parser, I mostly copy-pasted the SRT one, since VTT is very similar. The issue with VTT is that there a many tags, that can add italics, bold, or other metadata. There a basically 3 options of what we can do with those tags. For instance, if the text is `<b>Hello</b> <i>you</i>`, we can either:

- do nothing (and the text shown on screen will be ugly: “`<b>Hello</b> <i>you</i>`”)
- properly ignore them (“Hello you”)
- show them in the final text (“**Hello** _you_”)

Of course the third is the best, but it is the most work. I don’t know if there are many podcast that use them. Maybe for now the first option will be enough. What do you think?

The only tag that is often used is the one identifying the speaker. I parsed it, but only if it is at the beginning of the text (if not it would be complex for us, because we would not have the timestamp for the change of speaker). I did not handle the fact that the tag could be closed (`</v>`), because I’m not sure people use it.
One big issue I encountered though, and it seems to be more of architecture problem on the existing codebase, is that my feature only works for new episodes. If the podcast is already loaded with a version of AntennaPod on the branch `develop`, using my branch without uninstalling the app with not work for those episodes already present in the local DB.

I did not manage to understand how the feed DB works just yet, but somehow, if a new `podcastIndexTranscriptType` is set, it will be overridden with the previous value (`null`) before it has time to write the new value into the database. The result is that if an episode has no transcript initially (or not supported transcript), AntennaPod will never be able to change that state in the future!
My vote for formatting is to store all the tags as is and strip it when we display (when you return the string/text)

Maybe add a function to return the raw text for future use 


> So I started working on it and here is my progress so far

Nice! Could you please open a PR? That makes it easier to discuss the code by commenting on it.

> For the parser, I mostly copy-pasted the SRT one

Any chance for re-using parts of the code instead of copy-pasting? That ensures that in case we find a bug in one of them in the future, we cannot forget to apply the fix to the other one as well.

> My vote for formatting is to store all the tags as is

Showing the formatting should be quite easy: `textView.setText(Html.fromHtml(xy))`. What could be difficult is to make sure that the re-formatting step we do while displaying does not make the html invalid. Because of that, I think it might be easier to strip it already while parsing. Then one does not have to modify the re-formatting for now. I think we already have a function somewhere that strips the html of a feed's description.

> my feature only works for new episodes

Did you try force-refreshing? Swipe to refresh on the podcast screen performs a force-refresh even if the rss feed did not change (in contrast to refreshing on any other screen).

> if a new podcastIndexTranscriptType is set, it will be overridden with the previous value

Hmm. If that's really the case, we should fix this :) But first please make sure to try force-refresh :)
All right, I wished I had time to work on this sooner, but I finally managed to find some. :smile: 

> Nice! Could you please open a PR? That makes it easier to discuss the code by commenting on it.

Done

> Any chance for re-using parts of the code instead of copy-pasting? That ensures that in case we find a bug in one of them in the future, we cannot forget to apply the fix to the other one as well.

My first reaction when reading this comment was “sure, of course, code duplication is awful; code should always be factorized”. But then looking closely at the code I’m not so sure. We’re taking about parsing 2 different specs. Sure there are very close, but I’ve seen in some other projects how factorizing parts of 2 slightly different, but still two independents things, can do more harm than wrong. Because then the code on both parts is complex, with lots of functions, and it becomes very hard to update one part without harming the other.

They are definitely some functions that should be factorize, like `parseTimecode`, which is the same for both. But apart from that, I’m not so sure. Maybe it will be easier to make a decision when the feature is completed.

> I think it might be easier to strip it already while parsing. Then one does not have to modify the re-formatting for now. I think we already have a function somewhere that strips the html of a feed's description.

I found the `HtmlToPlainText` class in the codebase. But it’s VTT spec here, it’s not really HTML. I guess it should work because the tags look the same. I should probably write a test VTT file with all the features and either make a fake podcast with it or add some integration tests with it.

> Did you try force-refreshing? Swipe to refresh on the podcast screen performs a force-refresh even if the rss feed did not change (in contrast to refreshing on any other screen).

I think I tried it all. Going on the podcast view (with the list of episodes). Swiping down, or clicking on the 3 dots and hitting “refresh”. It will trigger a reprocessing of the data, but it fails with the following:
- `FeedItem::setTranscriptUrl` is called, with the correct type (`text/vtt`) and URL
- then for some reason `PodDBAdapter::updateOrInsertFeedItem` is called, but the type here is `null`
- And then the `FeedItem` constructor used by DBReader will get called, with `transcriptType = null`

I’ve added some logs in my branch. It’s easy to reproduce: without my branch, subscribe to podnews daily, launch an episode, there will be now transcript working (because it’s VTT). Then checkout my branch, refresh and it still won’t work. But clearing app data and starting over will.

Pull request: Vtt support for transcripts
### Description

This PR adds VTT support for transcripts (in addition to existing SRT and JSON).

Closes #7431 

### Checklist
<!-- 
  To help us keep the issue tracker clean and work as efficient as possible,
  please make sure that you have done all of the following.
  You can tick the boxes below by placing an x inside the brackets like this: [x]
-->
- [x] I have read the contribution guidelines: https://github.com/AntennaPod/AntennaPod/blob/develop/CONTRIBUTING.md#submit-a-pull-request
- [x] I have performed a self-review of my code
- [x] I have run the automated code checks using `./gradlew checkstyle spotbugsPlayDebug spotbugsDebug :app:lintPlayDebug`
- [x] My code follows the style guidelines of the AntennaPod project: https://github.com/AntennaPod/AntennaPod/wiki/Code-style
- [x] I have mentioned the corresponding issue and the relevant keyword (e.g., "Closes: #xy") in the description (see https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue)
- [x] If it is a core feature, I have added automated tests


Test command: ./gradlew :parser:transcript:testDebugUnitTest --tests "de.danoeh.antennapod.parser.transcript.VttTranscriptParserTest"

Fix patch:
diff --git a/app/src/main/java/de/danoeh/antennapod/ui/screen/playback/TranscriptAdapter.java b/app/src/main/java/de/danoeh/antennapod/ui/screen/playback/TranscriptAdapter.java
index ffb3b7bdae5..7ce8b0964e3 100644
--- a/app/src/main/java/de/danoeh/antennapod/ui/screen/playback/TranscriptAdapter.java
+++ b/app/src/main/java/de/danoeh/antennapod/ui/screen/playback/TranscriptAdapter.java
@@ -78,6 +78,8 @@ public void onBindViewHolder(@NonNull TranscriptViewholder holder, int position)
             if (speakers.isEmpty() && (position % 5 == 0)) {
                 holder.viewTimecode.setVisibility(View.VISIBLE);
                 holder.viewTimecode.setText(timecode);
+            } else {
+                holder.viewTimecode.setVisibility(View.GONE);
             }
             holder.viewContent.setText(seg.getWords());
         }
diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java
index 603c6666774..f10871992a7 100644
--- a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java
+++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java
@@ -45,7 +45,6 @@ public class FeedItem implements Serializable {
     private String socialInteractUrl;
     private String podcastIndexTranscriptUrl;
     private String podcastIndexTranscriptType;
-    private String podcastIndexTranscriptText;
     private Transcript transcript;
 
     private int state;
@@ -456,29 +455,15 @@ public String getTranscriptType() {
         return podcastIndexTranscriptType;
     }
 
-    public void updateTranscriptPreferredFormat(String type, String url) {
-        if (StringUtils.isEmpty(type) || StringUtils.isEmpty(url)) {
+    public void updateTranscriptPreferredFormat(String typeStr, String url) {
+        if (StringUtils.isEmpty(typeStr) || StringUtils.isEmpty(url)) {
             return;
         }
-
-        String canonicalSrr = "application/srr";
-        String jsonType = "application/json";
-
-        switch (type) {
-            case "application/json":
-                podcastIndexTranscriptUrl = url;
-                podcastIndexTranscriptType = type;
-                break;
-            case "application/srr":
-            case "application/srt":
-            case "application/x-subrip":
-                if (podcastIndexTranscriptUrl == null || !podcastIndexTranscriptType.equals(jsonType)) {
-                    podcastIndexTranscriptUrl = url;
-                    podcastIndexTranscriptType = canonicalSrr;
-                }
-                break;
-            default:
-                break;
+        TranscriptType type = TranscriptType.fromMime(typeStr);
+        TranscriptType previousType = TranscriptType.fromMime(podcastIndexTranscriptType);
+        if (type.priority > previousType.priority) {
+            podcastIndexTranscriptUrl = url;
+            podcastIndexTranscriptType = type.canonicalMime;
         }
     }
 
@@ -490,14 +475,6 @@ public void setTranscript(Transcript t) {
         transcript = t;
     }
 
-    public String getPodcastIndexTranscriptText() {
-        return podcastIndexTranscriptText;
-    }
-
-    public String setPodcastIndexTranscriptText(String str) {
-        return podcastIndexTranscriptText = str;
-    }
-
     public boolean hasTranscript() {
         return (podcastIndexTranscriptUrl != null);
     }
diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptSegment.java b/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptSegment.java
index 0101bb8ed51..6f3401c3bd4 100644
--- a/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptSegment.java
+++ b/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptSegment.java
@@ -2,8 +2,8 @@
 
 public class TranscriptSegment {
     private final long startTime;
-    private final long endTime;
-    private final String words;
+    private long endTime;
+    private String words;
     private final String speaker;
 
     public TranscriptSegment(long start, long end, String w, String s) {
@@ -13,6 +13,11 @@ public TranscriptSegment(long start, long end, String w, String s) {
         speaker = s;
     }
 
+    public void append(long newEndTime, String wordsToAppend) {
+        endTime = newEndTime;
+        words += " " + wordsToAppend;
+    }
+
     public long getStartTime() {
         return startTime;
     }
diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptType.java b/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptType.java
new file mode 100644
index 00000000000..4a9d1315326
--- /dev/null
+++ b/model/src/main/java/de/danoeh/antennapod/model/feed/TranscriptType.java
@@ -0,0 +1,28 @@
+package de.danoeh.antennapod.model.feed;
+
+public enum TranscriptType {
+    JSON(4, "application/json"),
+    VTT(3, "text/vtt"),
+    SRT(2, "application/srt"),
+    NONE(0, "");
+
+    public final int priority;
+    public final String canonicalMime;
+
+    TranscriptType(int priority, String canonicalMime) {
+        this.priority = priority;
+        this.canonicalMime = canonicalMime;
+    }
+
+    public static TranscriptType fromMime(String type) {
+        if (type == null) {
+            return NONE;
+        }
+        return switch (type) {
+            case "application/json" -> JSON;
+            case "text/vtt" -> VTT;
+            case "application/srt", "application/srr", "application/x-subrip" -> SRT;
+            default -> NONE;
+        };
+    }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
index 4958ba529b7..293f3065219 100644
--- a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
@@ -70,7 +70,6 @@ public void run() {
             if (item != null && item.getTranscriptUrl() != null) {
                 String transcript = TranscriptUtils.loadTranscriptFromUrl(item.getTranscriptUrl(), true);
                 if (!StringUtils.isEmpty(transcript)) {
-                    item.setPodcastIndexTranscriptText(transcript);
                     TranscriptUtils.storeTranscript(media, transcript);
                 }
             }
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
index cd6954ac822..1b7ffffec34 100644
--- a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
@@ -167,6 +167,7 @@ private void refreshFeeds(List<Feed> toUpdate, boolean force) {
                         newEpisodesNotification.showIfNeeded(getApplicationContext(), savedFeed);
                     }
                 } catch (Exception e) {
+                    e.printStackTrace();
                     DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
                     DownloadResult status = new DownloadResult(feed.getTitle(),
                             feed.getId(), Feed.FEEDFILETYPE_FEED, false,
diff --git a/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/TranscriptParser.java b/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/TranscriptParser.java
index 4049b2c8f85..e428a3ce105 100644
--- a/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/TranscriptParser.java
+++ b/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/TranscriptParser.java
@@ -1,5 +1,6 @@
 package de.danoeh.antennapod.parser.transcript;
 
+import de.danoeh.antennapod.model.feed.TranscriptType;
 import org.apache.commons.lang3.StringUtils;
 
 import de.danoeh.antennapod.model.feed.Transcript;
@@ -8,18 +9,16 @@ public class TranscriptParser {
     static final long MIN_SPAN = 5000L; // Merge short segments together to form a span of 5 seconds
     static final long MAX_SPAN = 8000L; // Don't go beyond 10 seconds when merging
 
-    public static Transcript parse(String str, String type) {
+    public static Transcript parse(String str, String typeStr) {
         if (str == null || StringUtils.isBlank(str)) {
             return null;
         }
-
-        if ("application/json".equals(type)) {
-            return JsonTranscriptParser.parse(str);
-        }
-
-        if ("application/srt".equals(type) || "application/srr".equals(type) || "application/x-subrip".equals(type)) {
-            return SrtTranscriptParser.parse(str);
-        }
-        return null;
+        TranscriptType type = TranscriptType.fromMime(typeStr);
+        return switch (type) {
+            case JSON -> JsonTranscriptParser.parse(str);
+            case VTT -> VttTranscriptParser.parse(str);
+            case SRT -> SrtTranscriptParser.parse(str);
+            default -> null;
+        };
     }
 }
diff --git a/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParser.java b/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParser.java
new file mode 100644
index 00000000000..33d7071a506
--- /dev/null
+++ b/parser/transcript/src/main/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParser.java
@@ -0,0 +1,144 @@
+package de.danoeh.antennapod.parser.transcript;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.StringUtils;
+import org.jsoup.Jsoup;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import de.danoeh.antennapod.model.feed.Transcript;
+import de.danoeh.antennapod.model.feed.TranscriptSegment;
+
+public class VttTranscriptParser {
+    private static final Pattern TIMESTAMP_PATTERN =
+            Pattern.compile("^(?:([0-9]{2}):)?([0-9]{2}):([0-9]{2})\\.([0-9]{3})$");
+
+    private static final Pattern VOICE_SPAN =
+            Pattern.compile("<v(?:\\.[^\\t\\n\\r &<>.]+)*[ \\t]([^\\n\\r&>]+)>");
+
+    private record Timings(long start, long end) {}
+
+    public static Transcript parse(String str) {
+        // This is basically a very light WebVTT parser.
+        // It uses WebVTT properties to be both exact and very light.
+        // We will only be parsing the WebVTT cue blocks.
+
+        if (StringUtils.isBlank(str)) {
+            return null;
+        }
+
+        // WebVTT line terminator can be \r\n, \n or \n, let's use only one
+        str = str.replaceAll("\r\n?", "\n");
+        List<String> lines = Arrays.asList(str.split("\n"));
+
+        Transcript transcript = new Transcript();
+        Iterator<String> iterator = lines.iterator();
+        Set<String> speakers = new HashSet<>();
+        String speaker = "";
+        TranscriptSegment segment = null;
+
+        // Iterate through cue blocks
+        while (iterator.hasNext()) {
+            String line = iterator.next();
+
+            if (!line.contains("-->")) {
+                continue;
+            }
+
+            Timings timings = parseCueTimings(line);
+            if (timings == null) {
+                return null; // Input is broken
+            }
+
+            String payload = parseCuePayload(iterator);
+
+            Matcher matcher = VOICE_SPAN.matcher(payload);
+            if (matcher.find()) {
+                speaker = matcher.group(1);
+                speakers.add(speaker);
+            }
+
+            payload = Jsoup.parse(payload).text(); // remove all HTML tags
+
+            // should we merge this segment with the previous one?
+            if (segment != null && segment.getSpeaker().equals(speaker)
+                    && timings.end - segment.getStartTime() < TranscriptParser.MAX_SPAN) {
+                segment.append(timings.end, payload);
+            } else {
+                if (segment != null) {
+                    transcript.addSegment(segment);
+                }
+                segment = new TranscriptSegment(timings.start, timings.end, payload, speaker);
+            }
+
+            // do we have a candidate segment long enough to add it without trying to add more
+            if (segment.getEndTime() - segment.getStartTime() >= TranscriptParser.MIN_SPAN) {
+                transcript.addSegment(segment);
+                segment = null;
+            }
+        }
+
+        if (segment != null) {
+            transcript.addSegment(segment);
+        }
+
+        if (transcript.getSegmentCount() == 0) {
+            return null;
+        }
+        transcript.setSpeakers(speakers);
+        return transcript;
+    }
+
+    private static long parseIntOrNull(@Nullable String s) {
+        return StringUtils.isEmpty(s) ? 0 : Integer.parseInt(s);
+    }
+
+    private static long parseTimestamp(@NonNull String timestamp) {
+        Matcher matcher = TIMESTAMP_PATTERN.matcher(timestamp);
+        if (!matcher.matches()) {
+            return -1;
+        }
+        long hours = parseIntOrNull(matcher.group(1));
+        long minutes = parseIntOrNull(matcher.group(2));
+        long seconds = parseIntOrNull(matcher.group(3));
+        long milliseconds = parseIntOrNull(matcher.group(4));
+        return (hours * 60 * 60 * 1000) + (minutes * 60 * 1000) + (seconds * 1000) + milliseconds;
+    }
+
+    @Nullable
+    private static Timings parseCueTimings(@NonNull String line) {
+        String[] timestamps = line.split("-->");
+        if (timestamps.length < 2) {
+            return null;
+        }
+        long start = parseTimestamp(timestamps[0].trim());
+        long end = parseTimestamp(timestamps[1].trim().split("[ \\t]")[0]);
+        if (start == -1 || end == -1) {
+            return null;
+        }
+        return new Timings(start, end);
+    }
+
+    @NonNull
+    private static String parseCuePayload(@NonNull Iterator<String> iterator) {
+        StringBuilder body = new StringBuilder();
+        while (iterator.hasNext()) {
+            String line = iterator.next();
+            if (line.isEmpty()) {
+                break;
+            }
+            body.append(line.strip());
+            body.append(" ");
+        }
+        return body.toString().strip();
+    }
+
+}


Test patch:
diff --git a/parser/transcript/src/test/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParserTest.java b/parser/transcript/src/test/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParserTest.java
new file mode 100644
index 00000000000..ed9a506e330
--- /dev/null
+++ b/parser/transcript/src/test/java/de/danoeh/antennapod/parser/transcript/VttTranscriptParserTest.java
@@ -0,0 +1,172 @@
+package de.danoeh.antennapod.parser.transcript;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import de.danoeh.antennapod.model.feed.Transcript;
+
+@RunWith(RobolectricTestRunner.class)
+public class VttTranscriptParserTest {
+
+    private static String vttStrSimple = """
+        WEBVTT
+
+        00:00.000 --> 00:02.000
+        Intro without speaker
+
+        00:00:02.000 --> 00:03.000
+        Still no speaker
+
+        00:03.389 --> 00:00:09.000
+        <v Speaker 1>This is the first speaker.
+
+        00:09.150 --> 00:12.123
+        Let's assume it's still the first speaker.
+
+        00:13.000 --> 00:15.000
+        <v Speaker 2>And this is the second.
+
+        00:15.000 --> 00:16.000
+        <v Speaker 2>Still talking.
+
+        00:16.000 --> 00:18.000
+        <v Speaker 2>Still same line.
+
+        00:18.000 --> 00:19.000
+        <v Speaker 2>New line.
+
+        00:22.000 --> 00:26.500
+        <v Speaker 2> Too long to collapse with previous.
+
+        00:36.000 --> 00:38.000
+        <v Speaker 1>And again.
+        Je suis speaker 1.
+
+        01:00:00.000 --> 01:00:01.000
+        <v Speaker 1>Still talking after one hour.""";
+
+    // This is the same content as above, but with lots more WEBVTT features
+    private static String vttStrComplex = """
+        WEBVTT
+
+        NOTE This is a note
+
+        00:00.000 --> 00:02.000
+        Intro without speaker
+
+        00:00:02.000 --> 00:03.000
+        Still no speaker
+
+        NOTE Here is an other note
+
+        First block with a speaker
+        00:03.389 --> 00:00:09.000
+        <v.first Speaker 1>This is the first speaker.
+
+        00:09.150 --> 00:12.123
+        Let's assume it's still the first speaker.
+
+        00:13.000 --> 00:15.000\tposition:90% align:right size:35%
+        <v.second.loud Speaker 2>And this is the second.</v>
+
+        00:15.000 --> 00:16.000 position:10%,line-left align:left size:35%
+        <v Speaker 2>Still talking.
+
+        00:16.000 --> 00:18.000
+        <v Speaker 2>Still same line.
+
+        00:18.000 --> 00:19.000
+        <v Speaker 2>New line.
+
+        00:22.000 --> 00:26.500
+        <v Speaker 2> Too long to collapse with previous.
+
+        00:36.000 --> 00:38.000
+        <v Speaker 1>And again.
+        <i.foreignphrase><lang fr>Je suis</lang></i> speaker 1.
+
+        After one hour
+        01:00:00.000 --> 01:00:01.000
+        <v Speaker 1>Still talking after one hour.""";
+
+    private void checkResults(Transcript result) {
+        assertEquals("Intro without speaker Still no speaker", result.getSegmentAtTime(0L).getWords());
+        assertEquals("", result.getSegmentAtTime(0L).getSpeaker());
+        assertEquals(3000L, result.getSegmentAtTime(0L).getEndTime());
+
+        assertEquals("This is the first speaker.", result.getSegmentAtTime(3389L).getWords());
+        assertEquals("Speaker 1", result.getSegmentAtTime(3389L).getSpeaker());
+        assertEquals(9000L, result.getSegmentAtTime(3389L).getEndTime());
+
+        assertEquals("Let's assume it's still the first speaker.", result.getSegmentAtTime(9150L).getWords());
+        assertEquals("Speaker 1", result.getSegmentAtTime(9150L).getSpeaker());
+        assertEquals(12123L, result.getSegmentAtTime(9150L).getEndTime());
+
+        assertEquals("And this is the second. Still talking. Still same line.",
+                result.getSegmentAtTime(13000L).getWords());
+        assertEquals("Speaker 2", result.getSegmentAtTime(13000L).getSpeaker());
+        assertEquals(18000L, result.getSegmentAtTime(13000L).getEndTime());
+
+        assertEquals("New line.", result.getSegmentAtTime(18000L).getWords());
+        assertEquals("Speaker 2", result.getSegmentAtTime(18000L).getSpeaker());
+        assertEquals(19000L, result.getSegmentAtTime(18000L).getEndTime());
+
+        assertEquals("Too long to collapse with previous.", result.getSegmentAtTime(22000L).getWords());
+        assertEquals("Speaker 2", result.getSegmentAtTime(22000L).getSpeaker());
+        assertEquals(26500L, result.getSegmentAtTime(22000L).getEndTime());
+
+        assertEquals("And again. Je suis speaker 1.", result.getSegmentAtTime(36000L).getWords());
+        assertEquals("Speaker 1", result.getSegmentAtTime(36000L).getSpeaker());
+        assertEquals(38000L, result.getSegmentAtTime(36000L).getEndTime());
+
+        assertEquals("Still talking after one hour.", result.getSegmentAtTime(10000000L).getWords());
+        assertEquals("Speaker 1", result.getSegmentAtTime(3600000L).getSpeaker());
+        assertEquals(3601000L, result.getSegmentAtTime(3600000L).getEndTime());
+    }
+
+    @Test
+    public void testParseVttSimple() {
+        Transcript result = VttTranscriptParser.parse(vttStrSimple);
+        checkResults(result);
+    }
+
+    @Test
+    public void testParseVttComplex() {
+        Transcript result = VttTranscriptParser.parse(vttStrComplex);
+        checkResults(result);
+    }
+
+    @Test
+    public void testParse() {
+        String type = "text/vtt";
+        Transcript result;
+
+        result = TranscriptParser.parse(vttStrSimple, type);
+        // There isn't a segment at 800L, so go backwards and get the segment at 0L
+        assertEquals("Intro without speaker Still no speaker", result.getSegmentAtTime(800L).getWords());
+
+        result = TranscriptParser.parse(null, type);
+        assertNull(result);
+
+        // blank string
+        result = TranscriptParser.parse("", type);
+        assertNull(result);
+
+        // All empty lines
+        result = TranscriptParser.parse("\r\n\r\n\r\n\r\n", type);
+        assertNull(result);
+
+        // Just plain text
+        result = TranscriptParser.parse("<v Speaker 1> Just text", type);
+        assertNull(result);
+
+        // passing the wrong type
+        result = TranscriptParser.parse(vttStrSimple, "application/srr");
+        assertNull(result);
+        result = TranscriptParser.parse(vttStrSimple, "unknown");
+        assertNull(result);
+    }
+}
+