Repository: devsnd/tinytag

Issue:
Opus duration incorrect
The reported duration for Opus files is slightly incorrect, compared to the official _opusinfo_ as well as other libraries (mutage, audio-metadata). The way the others calculate the duration is by looking at the "preskip" value in the OpusHeader and the "last_granule_position" (i.e. the granule position in the last audio package of the stream, which contains the "end of stream" flag). The correct calculation then seems to be `duration = (last_granule_position - preskip) / 48000`.

Hints from issue comments:
@mathiascode: Good catch! Do you happen to know how Opus files without an EOS flag should be treated? The test.opus file in our test suite currently lacks one, and reports a length of ~2 seconds instead of the expected ~1.@fladd: @mathiascode I thought an EOS flag on the last page was mandatory. But in either case, you could also just run through all audio pages until the end of the file, and always catch the granule position.

Below is an example script that searches for the first Opus stream in the Ogg container and prints both the duration and also the bitrate of the Opus file (I think bitrate you are also missing, if I am not mistaken). Both calculated values seem to be in line with what the official _opusinfo_ calculates for ~"normal"~ Opus files (for the bitrate it matches the "without overhead" value, which only considers actual audio data, and ignores all metadata - this prevents the bitrate to increase when you for instance add a huge picture in the tags). ~It does not work correctly on your test file, though, for which _opusinfo_ reports a duration of 0.992 seconds, and a bitrate of 51.58 (I don't know why).~
**EDIT: I adapted the code to now also work with streams that don't have an EOS flag, and it does the correct calculation for both duration and bitrate for your test Opus file.**

```python3
#!/usr/bin/env python3

"""get_opus_duration_and_bitrate.py

Get the duration and bitrate of an Opus file."""


import os
import sys
import struct
import argparse


def get_opus_duration_and_bitrate(filename):
    """Calculate the duration and bitrate of an Opus file.

    Parameters
    -----------
    filename : str
        the name of the Opus file (i.e. "*.opus")

    Returns
    --------
    duration : float
        the duration of the Opus file is seconds
    bitrate : float
        the bitrate of the Opus file in kbps

    """

    def _read_ogg_page(f):
        # Read the Ogg page header (27 bytes)
        header = f.read(27)
        if len(header) < 27:
            return None  # End of file or error

        # Unpack the header
        try:
            (capture_pattern, version, header_type, granule_position,
             serial_number, page_sequence_no, checksum, page_segments) = \
                struct.unpack('<4sBsqIIIB', header)
        except struct.error:
            raise ValueError("Not a valid Ogg file")

        # Check for the Ogg capture pattern
        if capture_pattern != b'OggS' or version != 0:
            raise ValueError("Not a valid Ogg file")

        # Read the segment table
        segment_table = f.read(page_segments)
        if len(segment_table) < page_segments:
            return None  # End of file or error

        # Read the segment data
        segment_sizes = [seg for seg in segment_table]
        body_size = sum(segment_sizes)
        body = f.read(body_size)

        return {'header': header, 'body': body, 'serial_number': serial_number,
                'granule_position': granule_position, 'header_type': header_type,
                'page_sequence_no': page_sequence_no,
                'page_segments': page_segments}

    def _get_opus_stream_size_and_duration(f):
        f.seek(0)
        header_count = 0
        serial = None
        size = 0
        preskip = 0
        last_granule_position = 0
        final_granule_position = 0
        last_page_size = None
        eos = False

        while True:
            page = _read_ogg_page(f)
            if page is None:
                break  # End of file or error
            # Read pages until first Opus header page (identification header)
            elif header_count == 0: # Identification header
                if page['body'][:8] != b'OpusHead':  # Page from other stream?
                    continue
                else:  # Found it
                    serial = page['serial_number']
                    preskip = struct.unpack('<H', page['body'][10:12])[0]
                    header_count += 1
            # Check for expected second Opus header page (comment header)
            elif header_count == 1 and page['serial_number'] == serial:
                if page['body'][:8] != b'OpusTags':
                    raise ValueError("Not a valid Opus file")
                else:
                    header_count += 1
            # Read pages until first page with audio data
            elif header_count == 2 and page['serial_number'] == serial:
                if int.from_bytes(page['header_type'], 'little') & 0x01:
                    continue  # Contiunation of second Opus header page
                elif page['granule_position'] > 0:  # Found it
                    last_granule_position = page['granule_position']
                    final_granule_position = last_granule_position
                    header_count += 1
                    last_page_size = len(page['body'])
                    size += last_page_size
            # Read remaining pages with audio data
            elif page['granule_position'] > 0 and page['serial_number'] == serial:
                last_page_size = len(page['body'])
                size += last_page_size
                final_granule_position = last_granule_position
                last_granule_position = page['granule_position']
                # If page is last page (end of stream), we are done
                if int.from_bytes(page['header_type'], 'little') & 0x04:
                    eos = True
                    last_page_size = None
                    final_granule_position = last_granule_position
                    break

        # If last page did not contain "end of stream" flag, it was incomplete
        if not eos:
            size -= last_page_size

        # Calculate duration
        duration = (final_granule_position - preskip) / 48000.0

        return size, duration

    with open(filename, 'rb') as f:
        size, duration = _get_opus_stream_size_and_duration(f)
        bitrate = round((size * 8 / duration) / 1000, 1)

    return duration, bitrate


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        prog='get_opus_duration_and_bitrate.py',
        description="Print the duration and bitrate of an Opus file")
    parser.add_argument('filename', type=str)
    args = parser.parse_args()
    duration, bitrate = get_opus_duration_and_bitrate(args.filename)
    minutes = int(duration / 60)
    seconds = round(duration % 60)
    print(f"Duration: {minutes:02d}:{seconds:02d} ({duration}s)")
    print(f"Bitrate: {bitrate} kbps")
```
@mathiascode: Thanks! Does the following PR work for your Opus files? https://github.com/tinytag/tinytag/pull/238

> I thought an EOS flag on the last page was mandatory.

I think the test.opus file is just truncated, but according to opusinfo, EOS not being set is "normal for live streams".@fladd: Yes, looks good!

Solution patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 81a04a0..5a64758 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -1,8 +1,3 @@
-<!--
-SPDX-FileCopyrightText: 2021-2024 tinytag Contributors
-SPDX-License-Identifier: MIT
--->
-
 ---
 name: Bug report
 about: Something is broken or doesn't work as expected
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md.license b/.github/ISSUE_TEMPLATE/bug_report.md.license
new file mode 100644
index 0000000..96be622
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md.license
@@ -0,0 +1,2 @@
+SPDX-FileCopyrightText: 2021-2024 tinytag Contributors
+SPDX-License-Identifier: MIT
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..294d5f1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2025 tinytag Contributors
+# SPDX-License-Identifier: MIT
+
+blank_issues_enabled: false
+contact_links:
+  - name: Other
+    url: https://github.com/tinytag/tinytag/discussions
+    about: Question, discussion or anything else
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 9504c50..9390310 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -1,8 +1,3 @@
-<!--
-SPDX-FileCopyrightText: 2021-2024 tinytag Contributors
-SPDX-License-Identifier: MIT
--->
-
 ---
 name: Feature request
 about: Suggest an idea for this project
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md.license b/.github/ISSUE_TEMPLATE/feature_request.md.license
new file mode 100644
index 0000000..96be622
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md.license
@@ -0,0 +1,2 @@
+SPDX-FileCopyrightText: 2021-2024 tinytag Contributors
+SPDX-License-Identifier: MIT
diff --git a/LICENSE b/LICENSE
index 443b21a..9781aad 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode), et al.
+Copyright (c) 2014-2025 Tom Wallroth, Mat (mathiascode), et al.
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py
index ee5f5ce..1b0a1c0 100644
--- a/tinytag/tinytag.py
+++ b/tinytag/tinytag.py
@@ -1,4 +1,4 @@
-# SPDX-FileCopyrightText: 2014-2024 tinytag Contributors
+# SPDX-FileCopyrightText: 2014-2025 tinytag Contributors
 # SPDX-License-Identifier: MIT
 
 # tinytag - an audio file metadata reader
@@ -1314,14 +1314,21 @@ class _Ogg(TinyTag):
 
     def __init__(self) -> None:
         super().__init__()
-        self._max_samplenum = 0  # maximum sample position ever read
+        self._granule_pos = 0
+        self._pre_skip = 0  # number of samples to skip in opus stream
+        self._audio_size: int | None = None  # size of opus audio stream
 
     def _determine_duration(self, fh: BinaryIO) -> None:
         if not self._tags_parsed:
             self._parse_tag(fh)  # determine sample rate
         if self.duration is not None or not self.samplerate:
             return  # either ogg flac or invalid file
-        self.duration = self._max_samplenum / self.samplerate
+        self.duration = max(
+            (self._granule_pos - self._pre_skip) / self.samplerate, 0
+        )
+        if self._audio_size is None or not self.duration:
+            return  # not an opus file
+        self.bitrate = self._audio_size * 8 / self.duration / 1000
 
     def _parse_tag(self, fh: BinaryIO) -> None:
         check_flac_second_packet = False
@@ -1341,15 +1348,17 @@ def _parse_tag(self, fh: BinaryIO) -> None:
                 if self._parse_duration:  # parse opus header
                     # https://www.videolan.org/developers/vlc/modules/codec/opus_header.c
                     # https://mf4.xiph.org/jenkins/view/opus/job/opusfile-unix/ws/doc/html/structOpusHead.html
-                    version, ch = unpack("BB", packet[8:10])
+                    version, ch, pre_skip = unpack("<BBH", packet[8:12])
                     if (version & 0xF0) == 0:  # only major version 0 supported
                         self.channels = ch
-                        self.samplerate = 48000  # opus always uses 48khz
+                        self.samplerate = 48000
+                        self._pre_skip = pre_skip
             elif packet.startswith(b'OpusTags'):
                 if self._parse_tags:  # parse opus metadata:
                     walker = BytesIO(packet)
                     walker.seek(8)  # jump over header name
                     self._parse_vorbis_comment(walker)
+                self._audio_size = 0  # start counting size of audio stream
             elif packet.startswith(b'\x7fFLAC'):
                 # https://xiph.org/flac/ogg_mapping.html
                 walker = BytesIO(packet)
@@ -1392,7 +1401,7 @@ def _parse_tag(self, fh: BinaryIO) -> None:
                 check_speex_second_packet = False
             else:
                 # Optimization: If we need to determine the duration, read
-                # max_samplenum of remaining pages, but skip contents of
+                # granule_pos of remaining pages, but skip contents of
                 # segments. If we don't need the duration, stop here.
                 self._tags_parsed = True
                 if not self._parse_duration:
@@ -1444,6 +1453,9 @@ def _parse_vorbis_comment(self,
     def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]:
         # for the spec, see: https://wiki.xiph.org/Ogg
         packet_data = bytearray()
+        current_serial = None
+        last_granule_pos = 0
+        last_audio_size = 0
         header_len = 27
         page_header = fh.read(header_len)  # read ogg page header
         while len(page_header) == header_len:
@@ -1451,26 +1463,43 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]:
             if page_header[:4] != b'OggS' or version != 0:
                 raise ParseError('Invalid OGG header')
             # https://xiph.org/ogg/doc/framing.html
-            pos = unpack('<q', page_header[6:14])[0]
+            header_type = page_header[5]
+            eos = header_type & 0x04
+            granule_pos, serial = unpack('<qI', page_header[6:18])
+            if current_serial is None:
+                current_serial = serial
+            serial_match = serial == current_serial
+            if serial_match and granule_pos > 0:
+                if eos:
+                    self._granule_pos = granule_pos
+                else:
+                    self._granule_pos = last_granule_pos
+                    last_granule_pos = granule_pos
             segments = page_header[26]
-            # pylint: disable=consider-using-max-builtin
-            if pos > self._max_samplenum:
-                self._max_samplenum = pos
             seg_sizes = unpack('B' * segments, fh.read(segments))
             read_size = 0
+            audio_size = 0
             for seg_size in seg_sizes:  # read all segments
                 read_size += seg_size
+                if self._audio_size is not None:
+                    audio_size += seg_size
                 # less than 255 bytes means end of packet
-                if seg_size < 255 and not self._tags_parsed:
+                if seg_size < 255 and serial_match and not self._tags_parsed:
                     packet_data += fh.read(read_size)
                     yield packet_data
                     packet_data.clear()
                     read_size = 0
             if read_size:
-                if self._tags_parsed:
+                if not serial_match or self._tags_parsed:
                     fh.seek(read_size, SEEK_CUR)
                 else:  # packet continues on next page
                     packet_data += fh.read(read_size)
+            if serial_match and self._audio_size is not None:
+                if eos:
+                    self._audio_size += last_audio_size + audio_size
+                else:
+                    self._audio_size += last_audio_size
+                    last_audio_size = audio_size
             page_header = fh.read(header_len)
 
 
@@ -1635,7 +1664,7 @@ def _parse_tag(self, fh: BinaryIO) -> None:
                 self.duration = duration = tot_samples / sr
                 self.samplerate = sr
                 if duration > 0:
-                    self.bitrate = self.filesize / duration * 8 / 1000
+                    self.bitrate = self.filesize * 8 / duration / 1000
             elif block_type == self._VORBIS_COMMENT and self._parse_tags:
                 # pylint: disable=protected-access
                 walker = BytesIO(fh.read(size))


Test patch:
diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py
index 0916720..ece3141 100644
--- a/tinytag/tests/test_all.py
+++ b/tinytag/tests/test_all.py
@@ -1,4 +1,4 @@
-# SPDX-FileCopyrightText: 2019-2024 tinytag Contributors
+# SPDX-FileCopyrightText: 2019-2025 tinytag Contributors
 # SPDX-License-Identifier: MIT
 
 # pylint: disable=missing-function-docstring,missing-module-docstring
@@ -679,7 +679,8 @@
         'track': 1,
         'disc': 1,
         'title': 'Bad Apple!!',
-        'duration': 2.0,
+        'duration': 0.9935,
+        'bitrate': 51.5832913940614,
         'year': '2008.05.25',
         'filesize': 10000,
         'artist': 'nomico',
@@ -695,7 +696,8 @@
         'filesize': 7251,
         'channels': 1,
         'samplerate': 48000,
-        'duration': 5.0065,
+        'duration': 5.0,
+        'bitrate': 9.5952
     }),
     ('test_flac.oga', {
         'other': {