diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0a9c1f03..d24a298c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,7 @@ jobs: # You can use PyPy versions in python-version. # For example, pypy2 and pypy3 matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -37,4 +37,8 @@ jobs: # Runs a single command using the runners shell - name: Run all tests run: ./runtests + - name: Run all tests (no C extension) + env: + M3U8_NO_C_EXTENSION: "1" + run: ./runtests diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml new file mode 100644 index 00000000..6e078a6e --- /dev/null +++ b/.github/workflows/wheels.yml @@ -0,0 +1,89 @@ +name: Build Wheels + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - '**.md' + push: + branches: + - main + paths-ignore: + - '**.md' + release: + types: + - published + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + # Linux x86_64 and aarch64 (via QEMU) on ubuntu + - os: ubuntu-latest + archs: "x86_64 aarch64" + # macOS x86_64 on Intel runner + - os: macos-latest + archs: "x86_64" + # macOS arm64 on Apple Silicon runner + - os: macos-14 + archs: "arm64" + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + if: runner.os == 'Linux' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Build wheels + uses: pypa/cibuildwheel@v2.21.3 + env: + CIBW_ARCHS: ${{ matrix.archs }} + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }} + path: ./wheelhouse/*.whl + + build_sdist: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build sdist + run: pipx run build --sdist + + - name: Upload sdist + uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + + merge_artifacts: + name: Merge artifacts + runs-on: ubuntu-latest + needs: [build_wheels, build_sdist] + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: List artifacts + run: ls -la dist/ + + - name: Upload combined artifacts + uses: actions/upload-artifact@v4 + with: + name: all-dist-files + path: dist/ + diff --git a/.gitignore b/.gitignore index 35c96ea1..e0b26a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ +.DS_Store *.pyc *.egg-info +*.so +*.o +*.dylib tests/server.stdout dist/ build/ diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 259261b0..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include requirements.txt -include LICENSE -include README.md diff --git a/m3u8/__init__.py b/m3u8/__init__.py index f6fc5174..13e52efb 100644 --- a/m3u8/__init__.py +++ b/m3u8/__init__.py @@ -31,7 +31,14 @@ Start, Tiles, ) -from m3u8.parser import ParseError, parse +from m3u8.parser import parse, ParseError + +# Try to import the C extension for faster parsing, fall back to Python +if os.environ.get("M3U8_NO_C_EXTENSION", "") != "1": + try: + from m3u8._m3u8_parser import parse + except ImportError: + pass __all__ = ( "M3U8", diff --git a/m3u8/_m3u8_parser.c b/m3u8/_m3u8_parser.c new file mode 100644 index 00000000..8f495439 --- /dev/null +++ b/m3u8/_m3u8_parser.c @@ -0,0 +1,3121 @@ +/* + * Copyright 2014 Globo.com Player authors. All rights reserved. + * Use of this source code is governed by a MIT License + * license that can be found in the LICENSE file. + * + * C extension for m3u8 parser - provides optimized parsing of M3U8 playlists. + * + * This module implements the same parsing logic as m3u8/parser.py but in C + * for improved performance. The output is designed to be identical to the + * Python implementation. + * + * Design notes (following CPython extension best practices per PEP 7): + * + * Memory Management: + * - Uses module state instead of static globals for subinterpreter safety + * (PEP 573, PEP 3121) + * - Uses PyMem_* allocators consistently for better debugging/tracing + * - Single cleanup path via goto for reliable resource management + * - All borrowed references are clearly documented + * + * Performance Optimizations: + * - Frequently-used dict keys are cached as interned strings + * - Attribute parsers are const static arrays built at compile time + * - String operations use restrict pointers where applicable + * + * Error Handling: + * - All Python C API calls that can fail are checked + * - Helper macros (DICT_SET_AND_DECREF) ensure consistent cleanup + * - ParseError exception is shared with the Python parser module + * + * Thread Safety: + * - No mutable static state; all state is per-module + * - GIL is held throughout parsing (no release/acquire) + */ + +#define PY_SSIZE_T_CLEAN +#include +#include +#include +#include +#include + +/* + * Whitespace/Case handling for protocol parsing. + * + * We intentionally treat playlist syntax as ASCII per RFC 8216. Using the C + * locale-dependent ctype tables (isspace/tolower) can cause surprising + * behavior changes depending on process locale and input bytes > 0x7F. + * + * Note: This does not attempt to mirror Python's full Unicode whitespace + * semantics for str.strip(); the HLS grammar is ASCII and the input here is + * parsed as UTF-8 bytes. + */ +static inline int +ascii_isspace(unsigned char c) +{ + return c == ' ' || c == '\t' || c == '\n' || + c == '\r' || c == '\f' || c == '\v'; +} + +static inline unsigned char +ascii_tolower(unsigned char c) +{ + if (c >= 'A' && c <= 'Z') { + return (unsigned char)(c + ('a' - 'A')); + } + return c; +} + +/* + * Case-insensitive match between a raw buffer and a null-terminated key. + * Also treats '-' as '_' to match normalized attribute names. + * This avoids creating Python objects during schema lookup (hot path optimization). + */ +static inline int +buffer_matches_key(const char *buf, size_t len, const char *key) +{ + for (size_t i = 0; i < len; i++) { + if (key[i] == '\0') return 0; /* Key shorter than buffer */ + unsigned char c = (unsigned char)buf[i]; + /* Normalize: lowercase and treat '-' as '_' */ + if (c == '-') c = '_'; + else c = ascii_tolower(c); + if (c != (unsigned char)key[i]) return 0; + } + return key[len] == '\0'; /* Ensure exact length match */ +} + +/* + * Compatibility shims for Py_NewRef/Py_XNewRef (added in Python 3.10). + * These make reference ownership more explicit at call sites. + */ +#if PY_VERSION_HEX < 0x030a00f0 +static inline PyObject * +Py_NewRef(PyObject *obj) +{ + Py_INCREF(obj); + return obj; +} + +static inline PyObject * +Py_XNewRef(PyObject *obj) +{ + Py_XINCREF(obj); + return obj; +} +#endif + +/* + * Forward declarations for inline helpers used before their definitions. + */ +static inline int dict_set_interned(PyObject *dict, PyObject *interned_key, PyObject *value); +static inline PyObject *dict_get_interned(PyObject *dict, PyObject *interned_key); + +/* + * Helper macro for setting dict items with proper error handling. + * Decrefs value and returns/gotos on failure. + */ +#define DICT_SET_AND_DECREF(dict, key, value, cleanup_label) \ + do { \ + if (PyDict_SetItemString((dict), (key), (value)) < 0) { \ + Py_DECREF(value); \ + goto cleanup_label; \ + } \ + Py_DECREF(value); \ + } while (0) + +/* Protocol tag definitions - must match protocol.py */ +#define EXT_M3U "#EXTM3U" +#define EXT_X_TARGETDURATION "#EXT-X-TARGETDURATION" +#define EXT_X_MEDIA_SEQUENCE "#EXT-X-MEDIA-SEQUENCE" +#define EXT_X_DISCONTINUITY_SEQUENCE "#EXT-X-DISCONTINUITY-SEQUENCE" +#define EXT_X_PROGRAM_DATE_TIME "#EXT-X-PROGRAM-DATE-TIME" +#define EXT_X_MEDIA "#EXT-X-MEDIA" +#define EXT_X_PLAYLIST_TYPE "#EXT-X-PLAYLIST-TYPE" +#define EXT_X_KEY "#EXT-X-KEY" +#define EXT_X_STREAM_INF "#EXT-X-STREAM-INF" +#define EXT_X_VERSION "#EXT-X-VERSION" +#define EXT_X_ALLOW_CACHE "#EXT-X-ALLOW-CACHE" +#define EXT_X_ENDLIST "#EXT-X-ENDLIST" +#define EXTINF "#EXTINF" +#define EXT_I_FRAMES_ONLY "#EXT-X-I-FRAMES-ONLY" +#define EXT_X_ASSET "#EXT-X-ASSET" +#define EXT_X_BITRATE "#EXT-X-BITRATE" +#define EXT_X_BYTERANGE "#EXT-X-BYTERANGE" +#define EXT_X_I_FRAME_STREAM_INF "#EXT-X-I-FRAME-STREAM-INF" +#define EXT_X_DISCONTINUITY "#EXT-X-DISCONTINUITY" +#define EXT_X_CUE_OUT "#EXT-X-CUE-OUT" +#define EXT_X_CUE_OUT_CONT "#EXT-X-CUE-OUT-CONT" +#define EXT_X_CUE_IN "#EXT-X-CUE-IN" +#define EXT_X_CUE_SPAN "#EXT-X-CUE-SPAN" +#define EXT_OATCLS_SCTE35 "#EXT-OATCLS-SCTE35" +#define EXT_IS_INDEPENDENT_SEGMENTS "#EXT-X-INDEPENDENT-SEGMENTS" +#define EXT_X_MAP "#EXT-X-MAP" +#define EXT_X_START "#EXT-X-START" +#define EXT_X_SERVER_CONTROL "#EXT-X-SERVER-CONTROL" +#define EXT_X_PART_INF "#EXT-X-PART-INF" +#define EXT_X_PART "#EXT-X-PART" +#define EXT_X_RENDITION_REPORT "#EXT-X-RENDITION-REPORT" +#define EXT_X_SKIP "#EXT-X-SKIP" +#define EXT_X_SESSION_DATA "#EXT-X-SESSION-DATA" +#define EXT_X_SESSION_KEY "#EXT-X-SESSION-KEY" +#define EXT_X_PRELOAD_HINT "#EXT-X-PRELOAD-HINT" +#define EXT_X_DATERANGE "#EXT-X-DATERANGE" +#define EXT_X_GAP "#EXT-X-GAP" +#define EXT_X_CONTENT_STEERING "#EXT-X-CONTENT-STEERING" +#define EXT_X_IMAGE_STREAM_INF "#EXT-X-IMAGE-STREAM-INF" +#define EXT_X_IMAGES_ONLY "#EXT-X-IMAGES-ONLY" +#define EXT_X_TILES "#EXT-X-TILES" +#define EXT_X_BLACKOUT "#EXT-X-BLACKOUT" + +/* + * X-macro for interned strings. + * + * This eliminates 4x duplication: struct fields, init, traverse, clear. + * Define once, expand everywhere with different operations. + * + * Format: X(field_name, string_value) + */ +#define INTERNED_STRINGS(X) \ + /* Core parsing keys */ \ + X(str_segment, "segment") \ + X(str_segments, "segments") \ + X(str_duration, "duration") \ + X(str_uri, "uri") \ + X(str_title, "title") \ + X(str_expect_segment, "expect_segment") \ + X(str_expect_playlist, "expect_playlist") \ + X(str_current_key, "current_key") \ + X(str_keys, "keys") \ + X(str_cue_out, "cue_out") \ + X(str_cue_in, "cue_in") \ + /* Segment/state keys */ \ + X(str_program_date_time, "program_date_time") \ + X(str_current_program_date_time, "current_program_date_time") \ + X(str_cue_out_start, "cue_out_start") \ + X(str_cue_out_explicitly_duration, "cue_out_explicitly_duration") \ + X(str_current_cue_out_scte35, "current_cue_out_scte35") \ + X(str_current_cue_out_oatcls_scte35, "current_cue_out_oatcls_scte35") \ + X(str_current_cue_out_duration, "current_cue_out_duration") \ + X(str_current_cue_out_elapsedtime, "current_cue_out_elapsedtime") \ + X(str_scte35, "scte35") \ + X(str_oatcls_scte35, "oatcls_scte35") \ + X(str_scte35_duration, "scte35_duration") \ + X(str_scte35_elapsedtime, "scte35_elapsedtime") \ + X(str_asset_metadata, "asset_metadata") \ + X(str_discontinuity, "discontinuity") \ + X(str_key, "key") \ + X(str_current_segment_map, "current_segment_map") \ + X(str_init_section, "init_section") \ + X(str_dateranges, "dateranges") \ + X(str_gap, "gap") \ + X(str_gap_tag, "gap_tag") \ + X(str_blackout, "blackout") \ + X(str_byterange, "byterange") \ + X(str_bitrate, "bitrate") \ + /* Data dict keys */ \ + X(str_playlists, "playlists") \ + X(str_iframe_playlists, "iframe_playlists") \ + X(str_image_playlists, "image_playlists") \ + X(str_tiles, "tiles") \ + X(str_media, "media") \ + X(str_rendition_reports, "rendition_reports") \ + X(str_session_data, "session_data") \ + X(str_session_keys, "session_keys") \ + X(str_segment_map, "segment_map") \ + X(str_skip, "skip") \ + X(str_part_inf, "part_inf") \ + X(str_is_variant, "is_variant") \ + X(str_is_endlist, "is_endlist") \ + X(str_is_i_frames_only, "is_i_frames_only") \ + X(str_is_independent_segments, "is_independent_segments") \ + X(str_is_images_only, "is_images_only") \ + X(str_playlist_type, "playlist_type") \ + X(str_media_sequence, "media_sequence") \ + X(str_targetduration, "targetduration") \ + X(str_discontinuity_sequence, "discontinuity_sequence") \ + X(str_version, "version") \ + X(str_allow_cache, "allow_cache") \ + X(str_start, "start") \ + X(str_server_control, "server_control") \ + X(str_preload_hint, "preload_hint") \ + X(str_content_steering, "content_steering") \ + X(str_stream_info, "stream_info") \ + X(str_parts, "parts") \ + X(str_iframe_stream_info, "iframe_stream_info") \ + X(str_image_stream_info, "image_stream_info") + +/* + * Module state - holds all per-module data. + * + * Using module state instead of static globals ensures: + * - Proper cleanup when the module is garbage collected + * - Compatibility with subinterpreters (PEP 573, PEP 3121) + * - Thread-safe access to cached objects + */ +typedef struct { + PyObject *ParseError; + PyObject *datetime_cls; + PyObject *timedelta_cls; + PyObject *fromisoformat_meth; + /* Interned strings - generated from X-macro */ + #define DECLARE_INTERNED(name, str) PyObject *name; + INTERNED_STRINGS(DECLARE_INTERNED) + #undef DECLARE_INTERNED +} m3u8_state; + +/* + * Parse context - holds all state needed during a single parse() call. + * + * This structure reduces parameter passing between functions and makes + * the parsing state explicit. All PyObject pointers in this struct are + * borrowed references except where noted. + * + * Shadow State Optimization: + * Hot flags (expect_segment, expect_playlist) are kept in C variables + * to avoid dict lookup overhead in the main parsing loop. They are + * synced to the Python state dict only when needed: + * - Before calling custom_tags_parser (so callback sees current state) + * - After custom_tags_parser returns (in case it modified state) + * - At the end of parsing (for final state consistency) + */ +typedef struct { + m3u8_state *mod_state; /* Module state (borrowed) */ + PyObject *data; /* Result dict being built (owned) */ + PyObject *state; /* Parser state dict (owned) */ + int strict; /* Strict parsing mode flag */ + int lineno; /* Current line number (1-based) */ + /* Shadow state for hot flags - avoids dict lookups in main loop */ + int expect_segment; /* Shadow of state["expect_segment"] */ + int expect_playlist; /* Shadow of state["expect_playlist"] */ +} ParseContext; + +/* + * Unified tag handler function type. + * + * All tag handlers receive the same arguments for consistency and to enable + * the dispatch table pattern. This mirrors Python's **parse_kwargs approach. + * + * Args: + * ctx: Parse context (holds mod_state, data, state, strict, lineno) + * line: Null-terminated line content (the full line including tag) + * + * Returns: + * 0 on success, -1 on failure with exception set + */ +typedef int (*TagHandler)(ParseContext *ctx, const char *line); + +/* + * Dispatch table entry for tag-to-handler mapping. + * + * Using a dispatch table instead of a long if/else chain: + * - Matches Python's DISPATCH dict pattern + * - More maintainable and readable + * - Easier to add/remove tags + * - Linear scan is fast for <50 tags (comparable to dict lookup overhead) + */ +typedef struct { + const char *tag; /* Tag string, e.g., "#EXTINF" */ + size_t tag_len; /* Pre-computed length for fast prefix matching */ + TagHandler handler; /* Handler function */ +} TagDispatch; + +/* + * Sync shadow state TO Python dict (before custom_tags_parser or at end). + */ +static int +sync_shadow_to_dict(ParseContext *ctx) +{ + m3u8_state *mod_state = ctx->mod_state; + if (dict_set_interned(ctx->state, mod_state->str_expect_segment, + ctx->expect_segment ? Py_True : Py_False) < 0) { + return -1; + } + if (dict_set_interned(ctx->state, mod_state->str_expect_playlist, + ctx->expect_playlist ? Py_True : Py_False) < 0) { + return -1; + } + return 0; +} + +/* + * Sync shadow state FROM Python dict (after custom_tags_parser modifies it). + */ +static void +sync_shadow_from_dict(ParseContext *ctx) +{ + m3u8_state *mod_state = ctx->mod_state; + PyObject *val; + + val = dict_get_interned(ctx->state, mod_state->str_expect_segment); + ctx->expect_segment = (val == Py_True); + + val = dict_get_interned(ctx->state, mod_state->str_expect_playlist); + ctx->expect_playlist = (val == Py_True); +} + +/* Forward declaration for module definition */ +static struct PyModuleDef m3u8_parser_module; + +/* Get module state from module object */ +static inline m3u8_state * +get_m3u8_state(PyObject *module) +{ + void *state = PyModule_GetState(module); + assert(state != NULL); + return (m3u8_state *)state; +} + +/* + * Initialize datetime-related cached objects in module state. + * Called during module initialization. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +init_datetime_cache(m3u8_state *state) +{ + PyObject *datetime_mod = PyImport_ImportModule("datetime"); + if (datetime_mod == NULL) { + return -1; + } + + state->datetime_cls = PyObject_GetAttrString(datetime_mod, "datetime"); + state->timedelta_cls = PyObject_GetAttrString(datetime_mod, "timedelta"); + + if (state->datetime_cls != NULL) { + state->fromisoformat_meth = PyObject_GetAttrString( + state->datetime_cls, "fromisoformat"); + } + + Py_DECREF(datetime_mod); + + if (state->datetime_cls == NULL || + state->timedelta_cls == NULL || + state->fromisoformat_meth == NULL) + { + Py_CLEAR(state->datetime_cls); + Py_CLEAR(state->timedelta_cls); + Py_CLEAR(state->fromisoformat_meth); + return -1; + } + return 0; +} + +/* + * Initialize interned string cache using X-macro expansion. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +init_interned_strings(m3u8_state *state) +{ + #define INIT_INTERNED(name, str) \ + state->name = PyUnicode_InternFromString(str); \ + if (state->name == NULL) return -1; + INTERNED_STRINGS(INIT_INTERNED) + #undef INIT_INTERNED + return 0; +} + +/* + * Raise ParseError with lineno and line arguments. + * Takes module state to get the ParseError class. + * + * Optimization: Uses direct tuple construction instead of Py_BuildValue + * to avoid format string parsing overhead. + */ +static void +raise_parse_error(m3u8_state *state, int lineno, const char *line) +{ + /* Direct tuple construction - faster than Py_BuildValue("(is)", ...) */ + PyObject *py_lineno = PyLong_FromLong(lineno); + if (py_lineno == NULL) { + return; + } + + PyObject *py_line = PyUnicode_FromString(line); + if (py_line == NULL) { + Py_DECREF(py_lineno); + return; + } + + PyObject *args = PyTuple_Pack(2, py_lineno, py_line); + Py_DECREF(py_lineno); + Py_DECREF(py_line); + if (args == NULL) { + return; + } + + PyObject *exc = PyObject_Call(state->ParseError, args, NULL); + Py_DECREF(args); + + if (exc != NULL) { + PyErr_SetObject(state->ParseError, exc); + Py_DECREF(exc); + } +} + +/* + * remove_quotes(), implemented at the Python level as: + * + * quotes = ('"', "'") + * if string.startswith(quotes) and string.endswith(quotes): + * return string[1:-1] + * + * Note the subtlety: Python does NOT require matching quote characters. + * We mirror that behavior for parity. + * + * Returns: new reference. + */ +static PyObject * +remove_quotes_py(PyObject *str) +{ + if (!PyUnicode_Check(str)) { + PyErr_SetString(PyExc_TypeError, "expected str"); + return NULL; + } + + Py_ssize_t len = PyUnicode_GetLength(str); + if (len < 2) { + return Py_NewRef(str); + } + + Py_UCS4 first = PyUnicode_ReadChar(str, 0); + if (first == (Py_UCS4)-1 && PyErr_Occurred()) { + return NULL; + } + Py_UCS4 last = PyUnicode_ReadChar(str, len - 1); + if (last == (Py_UCS4)-1 && PyErr_Occurred()) { + return NULL; + } + + if ((first == '"' || first == '\'') && (last == '"' || last == '\'')) { + return PyUnicode_Substring(str, 1, len - 1); + } + return Py_NewRef(str); +} + +/* + * Strip leading and trailing whitespace from string in-place. + * + * Warning: This modifies the string in place by writing a NUL terminator. + * Only use on mutable strings (e.g., our reusable line buffer). + * + * Returns pointer to first non-whitespace character (may be same as input + * or point into the middle of the string). + */ +static char *strip(char *str) { + while (ascii_isspace((unsigned char)*str)) str++; + if (*str == '\0') return str; + /* Safety: check length before computing end pointer to avoid UB */ + size_t len = strlen(str); + if (len == 0) return str; + char *end = str + len - 1; + while (end > str && ascii_isspace((unsigned char)*end)) end--; + *(end + 1) = '\0'; + return str; +} + +/* + * Fast dict operations using interned string keys. + * + * These avoid the string creation overhead of PyDict_SetItemString by + * using pre-interned strings from module state. PyDict_SetItem with + * interned strings can use pointer comparison for fast key lookup. + */ + +/* Set dict[key] = value using interned key. Returns 0 on success, -1 on error. */ +static inline int +dict_set_interned(PyObject *dict, PyObject *interned_key, PyObject *value) +{ + return PyDict_SetItem(dict, interned_key, value); +} + +/* Get dict[key] using interned key. Returns borrowed ref or NULL. */ +static inline PyObject * +dict_get_interned(PyObject *dict, PyObject *interned_key) +{ + return PyDict_GetItem(dict, interned_key); +} + +/* + * Get or create segment dict in state using interned string. + * Returns borrowed reference on success, NULL with exception on failure. + */ +static PyObject * +get_or_create_segment(m3u8_state *mod_state, PyObject *state) +{ + PyObject *segment = dict_get_interned(state, mod_state->str_segment); + if (segment != NULL) { + return segment; /* borrowed reference */ + } + segment = PyDict_New(); + if (segment == NULL) { + return NULL; + } + if (dict_set_interned(state, mod_state->str_segment, segment) < 0) { + Py_DECREF(segment); + return NULL; + } + Py_DECREF(segment); + return dict_get_interned(state, mod_state->str_segment); +} + +/* Utility: build list like Python's content.strip().splitlines() (preserve internal blanks) */ +static PyObject *build_stripped_splitlines(const char *content) { + const unsigned char *p = (const unsigned char *)content; + const unsigned char *end = p + strlen(content); + + while (p < end && ascii_isspace(*p)) p++; + while (end > p && ascii_isspace(*(end - 1))) end--; + + PyObject *lines = PyList_New(0); + if (!lines) return NULL; + + const unsigned char *line_start = p; + while (p < end) { + if (*p == '\n' || *p == '\r') { + PyObject *line = PyUnicode_FromStringAndSize((const char *)line_start, + (Py_ssize_t)(p - line_start)); + if (!line) { + Py_DECREF(lines); + return NULL; + } + if (PyList_Append(lines, line) < 0) { + Py_DECREF(line); + Py_DECREF(lines); + return NULL; + } + Py_DECREF(line); + + /* Consume newline sequence */ + if (*p == '\r' && (p + 1) < end && *(p + 1) == '\n') p++; + p++; + line_start = p; + continue; + } + p++; + } + + /* Last line (even if empty) */ + PyObject *line = PyUnicode_FromStringAndSize((const char *)line_start, + (Py_ssize_t)(end - line_start)); + if (!line) { + Py_DECREF(lines); + return NULL; + } + if (PyList_Append(lines, line) < 0) { + Py_DECREF(line); + Py_DECREF(lines); + return NULL; + } + Py_DECREF(line); + + return lines; +} + +/* + * Helper to initialize multiple list fields using interned keys. + * Returns 0 on success, -1 on failure. + */ +static int +init_list_fields(PyObject *data, PyObject **keys, size_t count) +{ + for (size_t i = 0; i < count; i++) { + PyObject *list = PyList_New(0); + if (list == NULL) { + return -1; + } + if (dict_set_interned(data, keys[i], list) < 0) { + Py_DECREF(list); + return -1; + } + Py_DECREF(list); + } + return 0; +} + +/* + * Helper to initialize multiple dict fields using interned keys. + * Returns 0 on success, -1 on failure. + */ +static int +init_dict_fields(PyObject *data, PyObject **keys, size_t count) +{ + for (size_t i = 0; i < count; i++) { + PyObject *dict = PyDict_New(); + if (dict == NULL) { + return -1; + } + if (dict_set_interned(data, keys[i], dict) < 0) { + Py_DECREF(dict); + return -1; + } + Py_DECREF(dict); + } + return 0; +} + +/* + * Initialize the result data dictionary with default values. + * + * This sets up all the required keys with their initial values, + * matching the structure created by the Python parser. + * + * Uses interned strings for faster dict operations (pointer comparison + * instead of string hashing on each SetItem). + * + * Returns: New reference to data dict on success, NULL on failure. + */ +static PyObject * +init_parse_data(m3u8_state *ms) +{ + PyObject *data = PyDict_New(); + if (data == NULL) { + return NULL; + } + + /* Set scalar defaults using interned keys */ + PyObject *zero = PyLong_FromLong(0); + if (zero == NULL) goto fail; + if (dict_set_interned(data, ms->str_media_sequence, zero) < 0) { + Py_DECREF(zero); + goto fail; + } + Py_DECREF(zero); + + if (dict_set_interned(data, ms->str_is_variant, Py_False) < 0) goto fail; + if (dict_set_interned(data, ms->str_is_endlist, Py_False) < 0) goto fail; + if (dict_set_interned(data, ms->str_is_i_frames_only, Py_False) < 0) goto fail; + if (dict_set_interned(data, ms->str_is_independent_segments, Py_False) < 0) goto fail; + if (dict_set_interned(data, ms->str_is_images_only, Py_False) < 0) goto fail; + if (dict_set_interned(data, ms->str_playlist_type, Py_None) < 0) goto fail; + + /* Initialize list fields using interned keys */ + PyObject *list_keys[] = { + ms->str_playlists, + ms->str_segments, + ms->str_iframe_playlists, + ms->str_image_playlists, + ms->str_tiles, + ms->str_media, + ms->str_keys, + ms->str_rendition_reports, + ms->str_session_data, + ms->str_session_keys, + ms->str_segment_map, + }; + if (init_list_fields(data, list_keys, sizeof(list_keys) / sizeof(list_keys[0])) < 0) { + goto fail; + } + + /* Initialize dict fields using interned keys */ + PyObject *dict_keys[] = { + ms->str_skip, + ms->str_part_inf, + }; + if (init_dict_fields(data, dict_keys, sizeof(dict_keys) / sizeof(dict_keys[0])) < 0) { + goto fail; + } + + return data; + +fail: + Py_DECREF(data); + return NULL; +} + +/* + * Initialize the parser state dictionary. + * + * The state dict tracks parsing progress and carries values between + * tags (e.g., current key, segment being built, etc.). + * + * Returns: New reference to state dict on success, NULL on failure. + */ +static PyObject * +init_parse_state(m3u8_state *mod_state) +{ + PyObject *state = PyDict_New(); + if (state == NULL) { + return NULL; + } + + /* Use interned strings for commonly-accessed keys */ + if (dict_set_interned(state, mod_state->str_expect_segment, Py_False) < 0) goto fail; + if (dict_set_interned(state, mod_state->str_expect_playlist, Py_False) < 0) goto fail; + + return state; + +fail: + Py_DECREF(state); + return NULL; +} + +/* + * Add seconds to a datetime object: dt + timedelta(seconds=secs) + * Returns new reference on success, NULL with exception on failure. + */ +static PyObject * +datetime_add_seconds(m3u8_state *state, PyObject *dt, double secs) +{ + PyObject *args = PyTuple_New(0); + if (args == NULL) { + return NULL; + } + + PyObject *kwargs = Py_BuildValue("{s:d}", "seconds", secs); + if (kwargs == NULL) { + Py_DECREF(args); + return NULL; + } + + PyObject *delta = PyObject_Call(state->timedelta_cls, args, kwargs); + Py_DECREF(kwargs); + Py_DECREF(args); + if (delta == NULL) { + return NULL; + } + + PyObject *new_dt = PyNumber_Add(dt, delta); + Py_DECREF(delta); + return new_dt; +} + +/* + * Create normalized Python string directly from buffer (zero-copy optimization). + * + * This avoids malloc for keys < 64 chars (covers 99%+ of real-world cases). + * Normalization: replace '-' with '_', lowercase, strip whitespace. + * + * Returns: New reference to Python string, or NULL with exception set. + */ +static PyObject * +create_normalized_key(const char *s, Py_ssize_t len) +{ + char stack_buf[64]; + char *buf = stack_buf; + int use_heap = (len >= (Py_ssize_t)sizeof(stack_buf)); + + if (use_heap) { + buf = PyMem_Malloc(len + 1); + if (buf == NULL) { + return PyErr_NoMemory(); + } + } + + /* Normalize: skip leading whitespace, replace - with _, tolower */ + Py_ssize_t in_idx = 0; + Py_ssize_t out_len = 0; + + /* Skip leading whitespace */ + while (in_idx < len && ascii_isspace((unsigned char)s[in_idx])) { + in_idx++; + } + + /* Transform characters */ + for (; in_idx < len; in_idx++) { + unsigned char c = (unsigned char)s[in_idx]; + if (c == '-') { + c = '_'; + } else { + c = ascii_tolower(c); + } + buf[out_len++] = (char)c; + } + + /* Strip trailing whitespace */ + while (out_len > 0 && ascii_isspace((unsigned char)buf[out_len - 1])) { + out_len--; + } + buf[out_len] = '\0'; + + PyObject *res = PyUnicode_FromStringAndSize(buf, out_len); + + if (use_heap) { + PyMem_Free(buf); + } + return res; +} + +/* Utility: remove quotes from string */ +/* Utility: delete a key from dict by interned key; ignore missing-key KeyError. */ +static int +del_item_interned_ignore_keyerror(PyObject *dict, PyObject *interned_key) +{ + if (PyDict_DelItem(dict, interned_key) == 0) { + return 0; + } + if (PyErr_ExceptionMatches(PyExc_KeyError)) { + PyErr_Clear(); + return 0; + } + return -1; +} + +/* + * Helper: Transfer boolean flag from state to segment. + * + * Sets segment[key] = True if state[key] exists, False otherwise. + * Deletes state[key] if it existed. + * Returns 0 on success, -1 on failure. + */ +static int +transfer_state_bool(PyObject *state, PyObject *segment, PyObject *key) +{ + PyObject *val = PyDict_GetItem(state, key); /* borrowed ref, no error */ + if (PyDict_SetItem(segment, key, val ? Py_True : Py_False) < 0) return -1; + if (val && del_item_interned_ignore_keyerror(state, key) < 0) return -1; + return 0; +} + +/* + * Helper: Transfer value from state to segment (or None if missing). + * + * Sets segment[key] = state[key] if exists, else segment[key] = None. + * Deletes state[key] if it existed. + * Returns 0 on success, -1 on failure. + */ +static int +transfer_state_value(PyObject *state, PyObject *segment, PyObject *key) +{ + PyObject *val = PyDict_GetItem(state, key); /* borrowed ref */ + if (PyDict_SetItem(segment, key, val ? val : Py_None) < 0) return -1; + if (val && del_item_interned_ignore_keyerror(state, key) < 0) return -1; + return 0; +} + +/* + * Zero-copy attribute list parser. + * + * Parses "KEY=value,KEY2=value2" format directly from buffer pointers. + * Creates Python objects directly without intermediate C string allocations. + * + * Args: + * start: Pointer to start of attribute list (after the ":" in the tag) + * end: Pointer to end of buffer + * + * Returns: New reference to dict, or NULL with exception set. + */ +static PyObject * +parse_attribute_list_raw(const char *start, const char *end) +{ + PyObject *attrs = PyDict_New(); + if (attrs == NULL) { + return NULL; + } + + const char *p = start; + while (p < end) { + /* Skip leading whitespace and commas */ + while (p < end && (ascii_isspace((unsigned char)*p) || *p == ',')) { + p++; + } + if (p >= end) { + break; + } + + /* Find key */ + const char *key_start = p; + while (p < end && *p != '=' && *p != ',') { + p++; + } + const char *key_end = p; + + /* Create normalized key directly from buffer */ + PyObject *py_key = create_normalized_key(key_start, key_end - key_start); + if (py_key == NULL) { + Py_DECREF(attrs); + return NULL; + } + + PyObject *py_val = NULL; + + if (p < end && *p == '=') { + p++; /* Skip '=' */ + + if (p < end && (*p == '"' || *p == '\'')) { + /* Quoted string - include quotes in value for later processing */ + char quote = *p; + const char *val_start = p; /* Include opening quote */ + p++; /* Skip opening quote */ + while (p < end && *p != quote) { + p++; + } + if (p < end) { + p++; /* Include closing quote */ + } + /* Create string with quotes (for compatibility with typed parser) */ + py_val = PyUnicode_FromStringAndSize(val_start, p - val_start); + } else { + /* Unquoted value */ + const char *val_start = p; + while (p < end && *p != ',') { + p++; + } + /* Strip trailing whitespace from unquoted values */ + const char *val_end = p; + while (val_end > val_start && ascii_isspace((unsigned char)*(val_end - 1))) { + val_end--; + } + py_val = PyUnicode_FromStringAndSize(val_start, val_end - val_start); + } + } else { + /* Key without value - store the key content as value with empty key */ + /* This handles formats like "EXT-X-CUE-OUT-CONT:2.436/120" */ + Py_ssize_t key_len = key_end - key_start; + /* Strip trailing whitespace */ + while (key_len > 0 && ascii_isspace((unsigned char)key_start[key_len - 1])) { + key_len--; + } + py_val = PyUnicode_FromStringAndSize(key_start, key_len); + Py_DECREF(py_key); + py_key = PyUnicode_FromString(""); + if (py_key == NULL) { + Py_XDECREF(py_val); + Py_DECREF(attrs); + return NULL; + } + } + + if (py_val == NULL) { + Py_DECREF(py_key); + Py_DECREF(attrs); + return NULL; + } + + if (PyDict_SetItem(attrs, py_key, py_val) < 0) { + Py_DECREF(py_key); + Py_DECREF(py_val); + Py_DECREF(attrs); + return NULL; + } + + Py_DECREF(py_key); + Py_DECREF(py_val); + } + + return attrs; +} + +/* + * Parse attribute list from a line like "PREFIX:KEY=value,KEY2=value2" + * + * This is a wrapper around parse_attribute_list_raw that handles the + * prefix-skipping logic for compatibility with existing callers. + * + * Returns new reference to dict on success, NULL with exception on failure. + */ +static PyObject * +parse_attribute_list(const char *line, const char *prefix) +{ + /* Skip prefix if present */ + const char *content = line; + if (prefix != NULL) { + size_t prefix_len = strlen(prefix); + if (strncmp(line, prefix, prefix_len) == 0) { + content = line + prefix_len; + if (*content == ':') { + content++; + } + } else { + /* Prefix not found - return empty dict */ + return PyDict_New(); + } + } + + /* Delegate to zero-copy implementation */ + return parse_attribute_list_raw(content, content + strlen(content)); +} + +/* Parse a key/value attribute list with type conversion */ +typedef enum { + ATTR_STRING, + ATTR_INT, + ATTR_FLOAT, + ATTR_QUOTED_STRING, + ATTR_BANDWIDTH +} AttrType; + +typedef struct { + const char *name; + AttrType type; +} AttrParser; + +/* + * Schema-aware attribute parser. + * + * This is the optimized version that converts values to their final types + * directly during parsing, avoiding the "double allocation" problem where + * we first create a Python string, then convert it to int/float. + * + * The schema (parsers array) tells us the expected type for each key, + * so we can parse directly to the correct Python type. + * + * Args: + * start: Pointer to start of attribute list (after "TAG:") + * end: Pointer to end of content + * parsers: Array of AttrParser structs defining key->type mappings + * num_parsers: Number of parsers in array + * + * Returns: New reference to dict on success, NULL with exception set. + */ +static PyObject * +parse_attributes_with_schema(const char *start, const char *end, + const AttrParser *parsers, size_t num_parsers) +{ + PyObject *attrs = PyDict_New(); + if (attrs == NULL) { + return NULL; + } + + const char *p = start; + while (p < end) { + /* Skip leading whitespace and commas */ + while (p < end && (ascii_isspace((unsigned char)*p) || *p == ',')) { + p++; + } + if (p >= end) { + break; + } + + /* Find key */ + const char *key_start = p; + while (p < end && *p != '=' && *p != ',') { + p++; + } + const char *key_end = p; + size_t key_len = key_end - key_start; + + /* Determine type via schema lookup BEFORE creating Python objects */ + AttrType type = ATTR_STRING; + if (parsers != NULL) { + for (size_t i = 0; i < num_parsers; i++) { + if (buffer_matches_key(key_start, key_len, parsers[i].name)) { + type = parsers[i].type; + break; + } + } + } + + /* Create normalized Python key only once, after schema lookup */ + PyObject *py_key = create_normalized_key(key_start, key_len); + if (py_key == NULL) { + Py_DECREF(attrs); + return NULL; + } + + PyObject *py_val = NULL; + + if (p < end && *p == '=') { + p++; /* Skip '=' */ + + if (p < end && (*p == '"' || *p == '\'')) { + /* Quoted value */ + char quote = *p++; + const char *full_start = p - 1; /* include opening quote */ + const char *val_start = p; /* inside quotes */ + while (p < end && *p != quote) { + p++; + } + const char *val_end = p; /* points at closing quote or end */ + int has_closing_quote = (p < end && *p == quote); + Py_ssize_t val_len = val_end - val_start; + if (has_closing_quote) { + p++; /* Skip closing quote */ + } + + /* + * Python parity: + * - Known "quoted string" attributes use remove_quotes() => no quotes + * - Unknown attributes keep the original token (including quotes) + */ + if (type == ATTR_QUOTED_STRING) { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } else if (type == ATTR_STRING) { + Py_ssize_t full_len = has_closing_quote + ? (Py_ssize_t)((val_end - full_start) + 1) + : (Py_ssize_t)(val_end - full_start); + py_val = PyUnicode_FromStringAndSize(full_start, full_len); + } else if (type == ATTR_INT || type == ATTR_BANDWIDTH) { + /* Numeric inside quotes - parse directly */ + char num_buf[64]; + if (val_len < (Py_ssize_t)sizeof(num_buf)) { + memcpy(num_buf, val_start, val_len); + num_buf[val_len] = '\0'; + if (type == ATTR_BANDWIDTH) { + double v = PyOS_string_to_double(num_buf, NULL, NULL); + if (v == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + } else { + py_val = PyLong_FromDouble(v); + } + } else { + py_val = PyLong_FromString(num_buf, NULL, 10); + if (py_val == NULL) { + PyErr_Clear(); + } + } + } + /* Fallback to string if conversion fails */ + if (py_val == NULL) { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else if (type == ATTR_FLOAT) { + char num_buf[64]; + if (val_len < (Py_ssize_t)sizeof(num_buf)) { + memcpy(num_buf, val_start, val_len); + num_buf[val_len] = '\0'; + double v = PyOS_string_to_double(num_buf, NULL, NULL); + if (v == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } else { + py_val = PyFloat_FromDouble(v); + } + } else { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else { + /* Unquoted value */ + const char *val_start = p; + while (p < end && *p != ',') { + p++; + } + /* Strip trailing whitespace */ + const char *val_end = p; + while (val_end > val_start && ascii_isspace((unsigned char)*(val_end - 1))) { + val_end--; + } + Py_ssize_t val_len = val_end - val_start; + + /* Direct type conversion - no intermediate Python string! */ + if (type == ATTR_INT) { + char num_buf[64]; + if (val_len < (Py_ssize_t)sizeof(num_buf)) { + memcpy(num_buf, val_start, val_len); + num_buf[val_len] = '\0'; + py_val = PyLong_FromString(num_buf, NULL, 10); + if (py_val == NULL) { + PyErr_Clear(); + } + } + if (py_val == NULL) { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else if (type == ATTR_BANDWIDTH) { + char num_buf[64]; + if (val_len < (Py_ssize_t)sizeof(num_buf)) { + memcpy(num_buf, val_start, val_len); + num_buf[val_len] = '\0'; + double v = PyOS_string_to_double(num_buf, NULL, NULL); + if (v == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + } else { + py_val = PyLong_FromDouble(v); + } + } + if (py_val == NULL) { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else if (type == ATTR_FLOAT) { + char num_buf[64]; + if (val_len < (Py_ssize_t)sizeof(num_buf)) { + memcpy(num_buf, val_start, val_len); + num_buf[val_len] = '\0'; + double v = PyOS_string_to_double(num_buf, NULL, NULL); + if (v == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } else { + py_val = PyFloat_FromDouble(v); + } + } else { + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } else { + /* ATTR_STRING or ATTR_QUOTED_STRING (unquoted case) */ + py_val = PyUnicode_FromStringAndSize(val_start, val_len); + } + } + } else { + /* Key without value - store key content as value with empty key */ + Py_ssize_t key_len = key_end - key_start; + while (key_len > 0 && ascii_isspace((unsigned char)key_start[key_len - 1])) { + key_len--; + } + py_val = PyUnicode_FromStringAndSize(key_start, key_len); + Py_DECREF(py_key); + py_key = PyUnicode_FromString(""); + if (py_key == NULL) { + Py_XDECREF(py_val); + Py_DECREF(attrs); + return NULL; + } + } + + if (py_val == NULL) { + Py_DECREF(py_key); + Py_DECREF(attrs); + return NULL; + } + + if (PyDict_SetItem(attrs, py_key, py_val) < 0) { + Py_DECREF(py_key); + Py_DECREF(py_val); + Py_DECREF(attrs); + return NULL; + } + + Py_DECREF(py_key); + Py_DECREF(py_val); + } + + return attrs; +} + +/* + * Wrapper for parse_attributes_with_schema that handles prefix skipping. + * This maintains backward compatibility with existing callers. + */ +static PyObject *parse_typed_attribute_list(const char *line, const char *prefix, + const AttrParser *parsers, size_t num_parsers) { + /* Skip prefix if present */ + const char *content = line; + if (prefix != NULL) { + size_t prefix_len = strlen(prefix); + if (strncmp(line, prefix, prefix_len) == 0) { + content = line + prefix_len; + if (*content == ':') { + content++; + } + } else { + /* Prefix not found - return empty dict */ + return PyDict_New(); + } + } + + /* Delegate to schema-aware parser */ + return parse_attributes_with_schema(content, content + strlen(content), + parsers, num_parsers); +} + +/* Stream info attribute parsers */ +static const AttrParser stream_inf_parsers[] = { + {"codecs", ATTR_QUOTED_STRING}, + {"audio", ATTR_QUOTED_STRING}, + {"video", ATTR_QUOTED_STRING}, + {"video_range", ATTR_QUOTED_STRING}, + {"subtitles", ATTR_QUOTED_STRING}, + {"pathway_id", ATTR_QUOTED_STRING}, + {"stable_variant_id", ATTR_QUOTED_STRING}, + {"program_id", ATTR_INT}, + {"bandwidth", ATTR_BANDWIDTH}, + {"average_bandwidth", ATTR_INT}, + {"frame_rate", ATTR_FLOAT}, + {"hdcp_level", ATTR_STRING}, +}; +#define NUM_STREAM_INF_PARSERS (sizeof(stream_inf_parsers) / sizeof(stream_inf_parsers[0])) + +/* Media attribute parsers */ +static const AttrParser media_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"group_id", ATTR_QUOTED_STRING}, + {"language", ATTR_QUOTED_STRING}, + {"assoc_language", ATTR_QUOTED_STRING}, + {"name", ATTR_QUOTED_STRING}, + {"instream_id", ATTR_QUOTED_STRING}, + {"characteristics", ATTR_QUOTED_STRING}, + {"channels", ATTR_QUOTED_STRING}, + {"stable_rendition_id", ATTR_QUOTED_STRING}, + {"thumbnails", ATTR_QUOTED_STRING}, + {"image", ATTR_QUOTED_STRING}, +}; +#define NUM_MEDIA_PARSERS (sizeof(media_parsers) / sizeof(media_parsers[0])) + +/* Part attribute parsers */ +static const AttrParser part_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"duration", ATTR_FLOAT}, + {"independent", ATTR_STRING}, + {"gap", ATTR_STRING}, + {"byterange", ATTR_STRING}, +}; +#define NUM_PART_PARSERS (sizeof(part_parsers) / sizeof(part_parsers[0])) + +/* Rendition report parsers */ +static const AttrParser rendition_report_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"last_msn", ATTR_INT}, + {"last_part", ATTR_INT}, +}; +#define NUM_RENDITION_REPORT_PARSERS (sizeof(rendition_report_parsers) / sizeof(rendition_report_parsers[0])) + +/* Skip parsers */ +static const AttrParser skip_parsers[] = { + {"recently_removed_dateranges", ATTR_QUOTED_STRING}, + {"skipped_segments", ATTR_INT}, +}; +#define NUM_SKIP_PARSERS (sizeof(skip_parsers) / sizeof(skip_parsers[0])) + +/* Server control parsers */ +static const AttrParser server_control_parsers[] = { + {"can_block_reload", ATTR_STRING}, + {"hold_back", ATTR_FLOAT}, + {"part_hold_back", ATTR_FLOAT}, + {"can_skip_until", ATTR_FLOAT}, + {"can_skip_dateranges", ATTR_STRING}, +}; +#define NUM_SERVER_CONTROL_PARSERS (sizeof(server_control_parsers) / sizeof(server_control_parsers[0])) + +/* Part inf parsers */ +static const AttrParser part_inf_parsers[] = { + {"part_target", ATTR_FLOAT}, +}; +#define NUM_PART_INF_PARSERS (sizeof(part_inf_parsers) / sizeof(part_inf_parsers[0])) + +/* Preload hint parsers */ +static const AttrParser preload_hint_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"type", ATTR_STRING}, + {"byterange_start", ATTR_INT}, + {"byterange_length", ATTR_INT}, +}; +#define NUM_PRELOAD_HINT_PARSERS (sizeof(preload_hint_parsers) / sizeof(preload_hint_parsers[0])) + +/* Daterange parsers */ +static const AttrParser daterange_parsers[] = { + {"id", ATTR_QUOTED_STRING}, + {"class", ATTR_QUOTED_STRING}, + {"start_date", ATTR_QUOTED_STRING}, + {"end_date", ATTR_QUOTED_STRING}, + {"duration", ATTR_FLOAT}, + {"planned_duration", ATTR_FLOAT}, + {"end_on_next", ATTR_STRING}, + {"scte35_cmd", ATTR_STRING}, + {"scte35_out", ATTR_STRING}, + {"scte35_in", ATTR_STRING}, +}; +#define NUM_DATERANGE_PARSERS (sizeof(daterange_parsers) / sizeof(daterange_parsers[0])) + +/* Session data parsers */ +static const AttrParser session_data_parsers[] = { + {"data_id", ATTR_QUOTED_STRING}, + {"value", ATTR_QUOTED_STRING}, + {"uri", ATTR_QUOTED_STRING}, + {"language", ATTR_QUOTED_STRING}, +}; +#define NUM_SESSION_DATA_PARSERS (sizeof(session_data_parsers) / sizeof(session_data_parsers[0])) + +/* Content steering parsers */ +static const AttrParser content_steering_parsers[] = { + {"server_uri", ATTR_QUOTED_STRING}, + {"pathway_id", ATTR_QUOTED_STRING}, +}; +#define NUM_CONTENT_STEERING_PARSERS (sizeof(content_steering_parsers) / sizeof(content_steering_parsers[0])) + +/* X-MAP parsers */ +static const AttrParser x_map_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"byterange", ATTR_QUOTED_STRING}, +}; +#define NUM_X_MAP_PARSERS (sizeof(x_map_parsers) / sizeof(x_map_parsers[0])) + +/* Start parsers */ +static const AttrParser start_parsers[] = { + {"time_offset", ATTR_FLOAT}, +}; +#define NUM_START_PARSERS (sizeof(start_parsers) / sizeof(start_parsers[0])) + +/* Tiles parsers */ +static const AttrParser tiles_parsers[] = { + {"uri", ATTR_QUOTED_STRING}, + {"resolution", ATTR_STRING}, + {"layout", ATTR_STRING}, + {"duration", ATTR_FLOAT}, +}; +#define NUM_TILES_PARSERS (sizeof(tiles_parsers) / sizeof(tiles_parsers[0])) + +/* Image stream inf parsers */ +static const AttrParser image_stream_inf_parsers[] = { + {"codecs", ATTR_QUOTED_STRING}, + {"uri", ATTR_QUOTED_STRING}, + {"pathway_id", ATTR_QUOTED_STRING}, + {"stable_variant_id", ATTR_QUOTED_STRING}, + {"program_id", ATTR_INT}, + {"bandwidth", ATTR_INT}, + {"average_bandwidth", ATTR_INT}, + {"resolution", ATTR_STRING}, +}; +#define NUM_IMAGE_STREAM_INF_PARSERS (sizeof(image_stream_inf_parsers) / sizeof(image_stream_inf_parsers[0])) + +/* IFrame stream inf parsers */ +static const AttrParser iframe_stream_inf_parsers[] = { + {"codecs", ATTR_QUOTED_STRING}, + {"uri", ATTR_QUOTED_STRING}, + {"pathway_id", ATTR_QUOTED_STRING}, + {"stable_variant_id", ATTR_QUOTED_STRING}, + {"program_id", ATTR_INT}, + {"bandwidth", ATTR_INT}, + {"average_bandwidth", ATTR_INT}, + {"hdcp_level", ATTR_STRING}, +}; +#define NUM_IFRAME_STREAM_INF_PARSERS (sizeof(iframe_stream_inf_parsers) / sizeof(iframe_stream_inf_parsers[0])) + +/* Cueout cont parsers */ +static const AttrParser cueout_cont_parsers[] = { + {"duration", ATTR_QUOTED_STRING}, + {"elapsedtime", ATTR_QUOTED_STRING}, + {"scte35", ATTR_QUOTED_STRING}, +}; +#define NUM_CUEOUT_CONT_PARSERS (sizeof(cueout_cont_parsers) / sizeof(cueout_cont_parsers[0])) + +/* Cueout parsers */ +static const AttrParser cueout_parsers[] = { + {"cue", ATTR_QUOTED_STRING}, +}; +#define NUM_CUEOUT_PARSERS (sizeof(cueout_parsers) / sizeof(cueout_parsers[0])) + + +/* + * Helper: parse attribute list with quote removal. + * Returns new dict with unquoted values, or NULL on error. + */ +static PyObject * +parse_attrs_unquoted(const char *line, const char *tag) +{ + PyObject *raw_attrs = parse_attribute_list(line, tag); + if (!raw_attrs) return NULL; + + PyObject *result = PyDict_New(); + if (!result) { Py_DECREF(raw_attrs); return NULL; } + + PyObject *k, *v; + Py_ssize_t pos = 0; + while (PyDict_Next(raw_attrs, &pos, &k, &v)) { + PyObject *unquoted = remove_quotes_py(v); + if (unquoted == NULL || PyDict_SetItem(result, k, unquoted) < 0) { + Py_XDECREF(unquoted); + Py_DECREF(result); + Py_DECREF(raw_attrs); + return NULL; + } + Py_DECREF(unquoted); + } + Py_DECREF(raw_attrs); + return result; +} + +/* Parse a key tag */ +static int +parse_key(m3u8_state *mod_state, const char *line, PyObject *data, PyObject *state) +{ + PyObject *key = parse_attrs_unquoted(line, EXT_X_KEY); + if (!key) return -1; + + /* Set current key in state */ + if (dict_set_interned(state, mod_state->str_current_key, key) < 0) { + Py_DECREF(key); + return -1; + } + + /* Add to keys list if not already present */ + PyObject *keys = dict_get_interned(data, mod_state->str_keys); + if (keys) { + int found = PySequence_Contains(keys, key); + if (found < 0) { + Py_DECREF(key); + return -1; + } + if (found == 0) { + if (PyList_Append(keys, key) < 0) { + Py_DECREF(key); + return -1; + } + } + } + + Py_DECREF(key); + return 0; +} + +/* + * Parse #EXTINF tag. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +parse_extinf(m3u8_state *mod_state, const char *line, PyObject *state, + int lineno, int strict) +{ + const char *content = line + strlen(EXTINF) + 1; /* Skip "#EXTINF:" */ + + /* Find comma separator */ + const char *comma = strchr(content, ','); + double duration; + const char *title = ""; + + if (comma != NULL) { + char duration_str[64]; + size_t dur_len = comma - content; + if (dur_len >= sizeof(duration_str)) { + dur_len = sizeof(duration_str) - 1; + } + memcpy(duration_str, content, dur_len); + duration_str[dur_len] = '\0'; + duration = PyOS_string_to_double(duration_str, NULL, NULL); + if (duration == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + duration = 0.0; + } + title = comma + 1; + } else { + if (strict) { + raise_parse_error(mod_state, lineno, line); + return -1; + } + duration = PyOS_string_to_double(content, NULL, NULL); + if (duration == -1.0 && PyErr_Occurred()) { + PyErr_Clear(); + duration = 0.0; + } + } + + /* Get or create segment dict in state using interned string */ + PyObject *segment = get_or_create_segment(mod_state, state); + if (segment == NULL) { + return -1; + } + + /* Set duration using interned key */ + PyObject *py_duration = PyFloat_FromDouble(duration); + if (py_duration == NULL) { + return -1; + } + if (dict_set_interned(segment, mod_state->str_duration, py_duration) < 0) { + Py_DECREF(py_duration); + return -1; + } + Py_DECREF(py_duration); + + /* Set title using interned key */ + PyObject *py_title = PyUnicode_FromString(title); + if (py_title == NULL) { + return -1; + } + if (dict_set_interned(segment, mod_state->str_title, py_title) < 0) { + Py_DECREF(py_title); + return -1; + } + Py_DECREF(py_title); + + /* Set expect_segment flag using interned key */ + if (dict_set_interned(state, mod_state->str_expect_segment, Py_True) < 0) { + return -1; + } + return 0; +} + +/* + * Parse a segment URI line. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +parse_ts_chunk(m3u8_state *mod_state, const char *line, + PyObject *data, PyObject *state) +{ + /* Get segment dict from state using interned key, or create new one */ + PyObject *segment = dict_get_interned(state, mod_state->str_segment); + if (segment == NULL) { + segment = PyDict_New(); + if (segment == NULL) { + return -1; + } + } else { + Py_INCREF(segment); + } + /* Remove segment from state (we're taking ownership) */ + if (PyDict_DelItem(state, mod_state->str_segment) < 0) { + if (!PyErr_ExceptionMatches(PyExc_KeyError)) { + Py_DECREF(segment); + return -1; + } + PyErr_Clear(); + } + + /* Add URI using interned key */ + PyObject *uri = PyUnicode_FromString(line); + if (uri == NULL) { + Py_DECREF(segment); + return -1; + } + if (dict_set_interned(segment, mod_state->str_uri, uri) < 0) { + Py_DECREF(uri); + Py_DECREF(segment); + return -1; + } + Py_DECREF(uri); + + /* Transfer state values to segment (borrowed references) */ + PyObject *pdt = dict_get_interned(state, mod_state->str_program_date_time); + if (pdt != NULL) { + if (dict_set_interned(segment, mod_state->str_program_date_time, pdt) < 0) { + Py_DECREF(segment); + return -1; + } + if (del_item_interned_ignore_keyerror(state, mod_state->str_program_date_time) < 0) { + Py_DECREF(segment); + return -1; + } + } + + PyObject *current_pdt = dict_get_interned(state, mod_state->str_current_program_date_time); + if (current_pdt != NULL) { + if (dict_set_interned(segment, mod_state->str_current_program_date_time, current_pdt) < 0) { + Py_DECREF(segment); + return -1; + } + /* Update current_program_date_time by adding duration */ + PyObject *duration = dict_get_interned(segment, mod_state->str_duration); + if (duration != NULL && current_pdt != NULL) { + double secs = PyFloat_AsDouble(duration); + if (PyErr_Occurred()) { + Py_DECREF(segment); + return -1; + } + PyObject *new_pdt = datetime_add_seconds(mod_state, current_pdt, secs); + if (new_pdt == NULL) { + Py_DECREF(segment); + return -1; + } + if (dict_set_interned(state, mod_state->str_current_program_date_time, new_pdt) < 0) { + Py_DECREF(new_pdt); + Py_DECREF(segment); + return -1; + } + Py_DECREF(new_pdt); + } + } + + /* Boolean flags from state - use transfer_state_bool helper */ + if (transfer_state_bool(state, segment, mod_state->str_cue_in) < 0) { + Py_DECREF(segment); + return -1; + } + + /* cue_out needs special handling: check truthiness and keep state ref */ + PyObject *cue_out = dict_get_interned(state, mod_state->str_cue_out); + int cue_out_truth = cue_out ? PyObject_IsTrue(cue_out) : 0; + if (cue_out_truth < 0) { + Py_DECREF(segment); + return -1; + } + if (dict_set_interned(segment, mod_state->str_cue_out, cue_out_truth ? Py_True : Py_False) < 0) { + Py_DECREF(segment); + return -1; + } + + if (transfer_state_bool(state, segment, mod_state->str_cue_out_start) < 0 || + transfer_state_bool(state, segment, mod_state->str_cue_out_explicitly_duration) < 0) { + Py_DECREF(segment); + return -1; + } + + /* SCTE35 values - get if cue_out, pop otherwise */ + PyObject *scte_keys[] = { + mod_state->str_current_cue_out_scte35, + mod_state->str_current_cue_out_oatcls_scte35, + mod_state->str_current_cue_out_duration, + mod_state->str_current_cue_out_elapsedtime, + mod_state->str_asset_metadata, + }; + PyObject *seg_keys[] = { + mod_state->str_scte35, + mod_state->str_oatcls_scte35, + mod_state->str_scte35_duration, + mod_state->str_scte35_elapsedtime, + mod_state->str_asset_metadata, + }; + + for (int i = 0; i < 5; i++) { + PyObject *val = dict_get_interned(state, scte_keys[i]); + if (val) { + if (dict_set_interned(segment, seg_keys[i], val) < 0) { + Py_DECREF(segment); + return -1; + } + if (!cue_out_truth) { + if (del_item_interned_ignore_keyerror(state, scte_keys[i]) < 0) { + Py_DECREF(segment); + return -1; + } + } + } else { + /* Clear any potential error from GetItem (though unlikely) */ + PyErr_Clear(); + if (dict_set_interned(segment, seg_keys[i], Py_None) < 0) { + Py_DECREF(segment); + return -1; + } + } + } + + if (del_item_interned_ignore_keyerror(state, mod_state->str_cue_out) < 0) { + Py_DECREF(segment); + return -1; + } + + /* Discontinuity */ + if (transfer_state_bool(state, segment, mod_state->str_discontinuity) < 0) { + Py_DECREF(segment); + return -1; + } + + /* Key - use interned string for current_key lookup */ + PyObject *current_key = dict_get_interned(state, mod_state->str_current_key); + if (current_key) { + if (dict_set_interned(segment, mod_state->str_key, current_key) < 0) { + Py_DECREF(segment); + return -1; + } + } else { + /* For unencrypted segments, ensure None is in keys list */ + PyObject *keys = dict_get_interned(data, mod_state->str_keys); + if (keys) { + int has_none = 0; + Py_ssize_t n = PyList_Size(keys); + for (Py_ssize_t i = 0; i < n; i++) { + if (PyList_GetItem(keys, i) == Py_None) { + has_none = 1; + break; + } + } + if (!has_none) { + PyList_Append(keys, Py_None); + } + } + } + + /* Init section */ + PyObject *current_segment_map = dict_get_interned(state, mod_state->str_current_segment_map); + /* Only set init_section if the map dict is non-empty (matches Python's truthiness check) */ + if (current_segment_map && PyDict_Size(current_segment_map) > 0) { + if (dict_set_interned(segment, mod_state->str_init_section, current_segment_map) < 0) { + Py_DECREF(segment); + return -1; + } + } + + /* Dateranges and Blackout - transfer value or None */ + if (transfer_state_value(state, segment, mod_state->str_dateranges) < 0 || + transfer_state_value(state, segment, mod_state->str_blackout) < 0) { + Py_DECREF(segment); + return -1; + } + + /* Gap - special: read str_gap, write to str_gap_tag as True/None */ + PyObject *gap = dict_get_interned(state, mod_state->str_gap); + if (dict_set_interned(segment, mod_state->str_gap_tag, gap ? Py_True : Py_None) < 0) { + Py_DECREF(segment); + return -1; + } + if (gap && del_item_interned_ignore_keyerror(state, mod_state->str_gap) < 0) { + Py_DECREF(segment); + return -1; + } + + /* Add to segments list using interned key */ + PyObject *segments = dict_get_interned(data, mod_state->str_segments); + if (segments) { + if (PyList_Append(segments, segment) < 0) { + Py_DECREF(segment); + return -1; + } + } + + /* Clear expect_segment flag using interned key */ + if (dict_set_interned(state, mod_state->str_expect_segment, Py_False) < 0) { + Py_DECREF(segment); + return -1; + } + Py_DECREF(segment); + return 0; +} + +/* Parse variant playlist - uses interned strings throughout */ +static int parse_variant_playlist(m3u8_state *ms, const char *line, + PyObject *data, PyObject *state) { + PyObject *stream_info = dict_get_interned(state, ms->str_stream_info); + if (!stream_info) { + stream_info = PyDict_New(); + if (!stream_info) return -1; + } else { + Py_INCREF(stream_info); + } + if (del_item_interned_ignore_keyerror(state, ms->str_stream_info) < 0) { + Py_DECREF(stream_info); + return -1; + } + + PyObject *playlist = PyDict_New(); + if (!playlist) { + Py_DECREF(stream_info); + return -1; + } + + PyObject *uri = PyUnicode_FromString(line); + if (!uri) { + Py_DECREF(playlist); + Py_DECREF(stream_info); + return -1; + } + if (dict_set_interned(playlist, ms->str_uri, uri) < 0) { + Py_DECREF(uri); + Py_DECREF(playlist); + Py_DECREF(stream_info); + return -1; + } + Py_DECREF(uri); + + if (dict_set_interned(playlist, ms->str_stream_info, stream_info) < 0) { + Py_DECREF(playlist); + Py_DECREF(stream_info); + return -1; + } + Py_DECREF(stream_info); + + PyObject *playlists = dict_get_interned(data, ms->str_playlists); + if (playlists && PyList_Append(playlists, playlist) < 0) { + Py_DECREF(playlist); + return -1; + } + Py_DECREF(playlist); + + return dict_set_interned(state, ms->str_expect_playlist, Py_False); +} + +/* + * Parse EXT-X-PROGRAM-DATE-TIME tag - uses interned strings. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +parse_program_date_time(m3u8_state *ms, const char *line, + PyObject *data, PyObject *state) +{ + const char *value = strchr(line, ':'); + if (value == NULL) return 0; + value++; + + PyObject *dt = PyObject_CallFunction(ms->fromisoformat_meth, "s", value); + if (dt == NULL) return -1; + + /* Set in data if not already set */ + PyObject *existing = dict_get_interned(data, ms->str_program_date_time); + if (existing == NULL || existing == Py_None) { + if (dict_set_interned(data, ms->str_program_date_time, dt) < 0) { + Py_DECREF(dt); + return -1; + } + } + + if (dict_set_interned(state, ms->str_current_program_date_time, dt) < 0 || + dict_set_interned(state, ms->str_program_date_time, dt) < 0) { + Py_DECREF(dt); + return -1; + } + Py_DECREF(dt); + return 0; +} + +/* + * Parse EXT-X-PART tag - uses interned strings throughout. + * Returns 0 on success, -1 on failure with exception set. + */ +static int +parse_part(m3u8_state *ms, const char *line, PyObject *state) +{ + PyObject *part = parse_typed_attribute_list(line, EXT_X_PART, + part_parsers, NUM_PART_PARSERS); + if (part == NULL) return -1; + + /* Add program_date_time if available */ + PyObject *current_pdt = dict_get_interned(state, ms->str_current_program_date_time); + if (current_pdt != NULL) { + if (dict_set_interned(part, ms->str_program_date_time, current_pdt) < 0) { + Py_DECREF(part); + return -1; + } + /* Update current_program_date_time */ + PyObject *duration = dict_get_interned(part, ms->str_duration); + if (duration != NULL) { + double secs = PyFloat_AsDouble(duration); + if (PyErr_Occurred()) { + Py_DECREF(part); + return -1; + } + PyObject *new_pdt = datetime_add_seconds(ms, current_pdt, secs); + if (new_pdt == NULL) { + Py_DECREF(part); + return -1; + } + if (dict_set_interned(state, ms->str_current_program_date_time, new_pdt) < 0) { + Py_DECREF(new_pdt); + Py_DECREF(part); + return -1; + } + Py_DECREF(new_pdt); + } + } + + /* Add dateranges - use transfer_state_value pattern */ + if (transfer_state_value(state, part, ms->str_dateranges) < 0) { + Py_DECREF(part); + return -1; + } + + /* Add gap_tag - read from str_gap, write True/None to str_gap_tag */ + PyObject *gap = dict_get_interned(state, ms->str_gap); + if (dict_set_interned(part, ms->str_gap_tag, gap ? Py_True : Py_None) < 0) { + Py_DECREF(part); + return -1; + } + if (gap && del_item_interned_ignore_keyerror(state, ms->str_gap) < 0) { + Py_DECREF(part); + return -1; + } + + /* Get or create segment */ + PyObject *segment = dict_get_interned(state, ms->str_segment); + if (segment == NULL) { + segment = PyDict_New(); + if (segment == NULL) { + Py_DECREF(part); + return -1; + } + if (dict_set_interned(state, ms->str_segment, segment) < 0) { + Py_DECREF(segment); + Py_DECREF(part); + return -1; + } + Py_DECREF(segment); + segment = dict_get_interned(state, ms->str_segment); + } + + /* Get or create parts list in segment */ + PyObject *parts = dict_get_interned(segment, ms->str_parts); + if (parts == NULL) { + parts = PyList_New(0); + if (parts == NULL) { + Py_DECREF(part); + return -1; + } + if (dict_set_interned(segment, ms->str_parts, parts) < 0) { + Py_DECREF(parts); + Py_DECREF(part); + return -1; + } + Py_DECREF(parts); + parts = dict_get_interned(segment, ms->str_parts); + } + + if (PyList_Append(parts, part) < 0) { + Py_DECREF(part); + return -1; + } + Py_DECREF(part); + return 0; +} + +/* Parse cue out - uses interned strings for state dict */ +static int parse_cueout(m3u8_state *ms, const char *line, PyObject *state) { + if (dict_set_interned(state, ms->str_cue_out_start, Py_True) < 0 || + dict_set_interned(state, ms->str_cue_out, Py_True) < 0) { + return -1; + } + + /* Check for DURATION keyword */ + char upper_line[1024]; + size_t i; + for (i = 0; i < sizeof(upper_line) - 1 && line[i]; i++) { + upper_line[i] = toupper((unsigned char)line[i]); + } + upper_line[i] = '\0'; + + if (strstr(upper_line, "DURATION")) { + if (dict_set_interned(state, ms->str_cue_out_explicitly_duration, Py_True) < 0) { + return -1; + } + } + + /* Parse attributes if present */ + const char *colon = strchr(line, ':'); + if (!colon || *(colon + 1) == '\0') { + return 0; + } + + PyObject *cue_info = parse_typed_attribute_list(line, EXT_X_CUE_OUT, + cueout_parsers, NUM_CUEOUT_PARSERS); + if (!cue_info) return -1; + + /* cue_info uses attr keys like "cue", "duration", "" - not interned */ + PyObject *cue_out_scte35 = PyDict_GetItemString(cue_info, "cue"); + PyObject *cue_out_duration = PyDict_GetItemString(cue_info, "duration"); + if (!cue_out_duration) { + cue_out_duration = PyDict_GetItemString(cue_info, ""); + } + + /* State dict uses interned keys */ + if (cue_out_scte35) { + if (dict_set_interned(state, ms->str_current_cue_out_scte35, cue_out_scte35) < 0) { + Py_DECREF(cue_info); + return -1; + } + } + if (cue_out_duration) { + if (dict_set_interned(state, ms->str_current_cue_out_duration, cue_out_duration) < 0) { + Py_DECREF(cue_info); + return -1; + } + } + + Py_DECREF(cue_info); + return 0; +} + +/* Parse cue out cont - uses interned strings for state dict */ +static int parse_cueout_cont(m3u8_state *ms, const char *line, PyObject *state) { + if (dict_set_interned(state, ms->str_cue_out, Py_True) < 0) return -1; + + const char *colon = strchr(line, ':'); + if (!colon || *(colon + 1) == '\0') return 0; + + PyObject *cue_info = parse_typed_attribute_list(line, EXT_X_CUE_OUT_CONT, + cueout_cont_parsers, NUM_CUEOUT_CONT_PARSERS); + if (!cue_info) return -1; + + /* cue_info uses attr keys like "", "duration", etc. - not interned */ + PyObject *progress = PyDict_GetItemString(cue_info, ""); + if (progress) { + if (!PyUnicode_Check(progress)) { + PyErr_SetString(PyExc_TypeError, "expected str for cue-out progress"); + Py_DECREF(cue_info); + return -1; + } + Py_ssize_t n = PyUnicode_GetLength(progress); + Py_ssize_t slash = PyUnicode_FindChar(progress, '/', 0, n, 1); + if (slash >= 0) { + PyObject *elapsed = PyUnicode_Substring(progress, 0, slash); + PyObject *duration = PyUnicode_Substring(progress, slash + 1, n); + if (elapsed == NULL || duration == NULL) { + Py_XDECREF(elapsed); + Py_XDECREF(duration); + Py_DECREF(cue_info); + return -1; + } + /* Use interned keys for state dict */ + int rc1 = dict_set_interned(state, ms->str_current_cue_out_elapsedtime, elapsed); + int rc2 = dict_set_interned(state, ms->str_current_cue_out_duration, duration); + Py_DECREF(elapsed); + Py_DECREF(duration); + if (rc1 < 0 || rc2 < 0) { + Py_DECREF(cue_info); + return -1; + } + } else { + if (dict_set_interned(state, ms->str_current_cue_out_duration, progress) < 0) { + Py_DECREF(cue_info); + return -1; + } + } + } + + PyObject *duration = PyDict_GetItemString(cue_info, "duration"); + if (duration && dict_set_interned(state, ms->str_current_cue_out_duration, duration) < 0) { + Py_DECREF(cue_info); + return -1; + } + + PyObject *scte35 = PyDict_GetItemString(cue_info, "scte35"); + if (scte35 && dict_set_interned(state, ms->str_current_cue_out_scte35, scte35) < 0) { + Py_DECREF(cue_info); + return -1; + } + + PyObject *elapsedtime = PyDict_GetItemString(cue_info, "elapsedtime"); + if (elapsedtime && dict_set_interned(state, ms->str_current_cue_out_elapsedtime, elapsedtime) < 0) { + Py_DECREF(cue_info); + return -1; + } + + Py_DECREF(cue_info); + return 0; +} + +/* + * ============================================================================ + * Dispatch Table Handlers + * + * These wrapper functions provide a unified signature for the dispatch table. + * Each handler receives a ParseContext and the full line, extracts what it + * needs, and calls the appropriate parsing logic. + * ============================================================================ + */ + +/* + * Macro-generated integer value handlers. + * These handlers parse an integer value after the tag and store it. + */ +#define MAKE_INT_HANDLER(name, tag, field) \ + static int name(ParseContext *ctx, const char *line) { \ + const char *value = line + sizeof(tag); \ + PyObject *py_value = PyLong_FromString(value, NULL, 10); \ + if (py_value == NULL) { PyErr_Clear(); return 0; } \ + int rc = dict_set_interned(ctx->data, ctx->mod_state->field, py_value); \ + Py_DECREF(py_value); \ + return rc < 0 ? -1 : 0; \ + } + +MAKE_INT_HANDLER(handle_targetduration, EXT_X_TARGETDURATION, str_targetduration) +MAKE_INT_HANDLER(handle_media_sequence, EXT_X_MEDIA_SEQUENCE, str_media_sequence) +MAKE_INT_HANDLER(handle_discontinuity_sequence, EXT_X_DISCONTINUITY_SEQUENCE, str_discontinuity_sequence) + +/* + * Macro-generated wrapper handlers that delegate to existing parse functions. + */ +#define MAKE_PARSE_WRAPPER(name, fn) \ + static int name(ParseContext *ctx, const char *line) { \ + return fn(ctx->mod_state, line, ctx->data, ctx->state); \ + } + +MAKE_PARSE_WRAPPER(handle_program_date_time, parse_program_date_time) +MAKE_PARSE_WRAPPER(handle_key, parse_key) + +#undef MAKE_PARSE_WRAPPER + +/* Handler for #EXTINF */ +static int +handle_extinf(ParseContext *ctx, const char *line) +{ + int rc = parse_extinf(ctx->mod_state, line, ctx->state, ctx->lineno, ctx->strict); + if (rc == 0) { + ctx->expect_segment = 1; + } + return rc; +} + +/* Handler for #EXT-X-BYTERANGE */ +static int +handle_byterange(ParseContext *ctx, const char *line) +{ + const char *value = line + sizeof(EXT_X_BYTERANGE); + PyObject *segment = get_or_create_segment(ctx->mod_state, ctx->state); + if (segment == NULL) { + return -1; + } + PyObject *py_value = PyUnicode_FromString(value); + if (py_value == NULL) { + return -1; + } + int rc = dict_set_interned(segment, ctx->mod_state->str_byterange, py_value); + Py_DECREF(py_value); + if (rc < 0) { + return -1; + } + ctx->expect_segment = 1; + return dict_set_interned(ctx->state, ctx->mod_state->str_expect_segment, Py_True); +} + +/* Handler for #EXT-X-BITRATE */ +static int +handle_bitrate(ParseContext *ctx, const char *line) +{ + const char *value = line + sizeof(EXT_X_BITRATE); + PyObject *segment = get_or_create_segment(ctx->mod_state, ctx->state); + if (segment == NULL) { + return -1; + } + PyObject *py_value = PyLong_FromString(value, NULL, 10); + if (py_value == NULL) { + PyErr_Clear(); + return 0; + } + int rc = dict_set_interned(segment, ctx->mod_state->str_bitrate, py_value); + Py_DECREF(py_value); + return rc < 0 ? -1 : 0; +} + +/* Handler for #EXT-X-STREAM-INF */ +static int +handle_stream_inf(ParseContext *ctx, const char *line) +{ + m3u8_state *ms = ctx->mod_state; + /* Use shadow state only - synced to dict before custom parser/end of parse */ + ctx->expect_playlist = 1; + if (dict_set_interned(ctx->data, ms->str_is_variant, Py_True) < 0) return -1; + if (dict_set_interned(ctx->data, ms->str_media_sequence, Py_None) < 0) return -1; + + PyObject *stream_info = parse_typed_attribute_list(line, EXT_X_STREAM_INF, + stream_inf_parsers, NUM_STREAM_INF_PARSERS); + if (stream_info == NULL) return -1; + int rc = dict_set_interned(ctx->state, ms->str_stream_info, stream_info); + Py_DECREF(stream_info); + return rc < 0 ? -1 : 0; +} + +/* + * Helper for iframe/image stream handlers - parses attrs, extracts URI, + * builds playlist dict, and appends to list. Uses interned str_uri. + */ +static int +handle_stream_inf_with_uri(ParseContext *ctx, const char *line, const char *tag, + const AttrParser *parsers, size_t num_parsers, + const char *info_key, PyObject *list_key) +{ + m3u8_state *ms = ctx->mod_state; + PyObject *info = parse_typed_attribute_list(line, tag, parsers, num_parsers); + if (info == NULL) return -1; + + /* Use interned string for URI lookup */ + PyObject *uri = dict_get_interned(info, ms->str_uri); + if (uri == NULL) { Py_DECREF(info); return 0; } + + Py_INCREF(uri); + if (del_item_interned_ignore_keyerror(info, ms->str_uri) < 0) { + Py_DECREF(uri); + Py_DECREF(info); + return -1; + } + + /* info_key passed as char* - Py_BuildValue "s" is fine for rare keys */ + PyObject *playlist = Py_BuildValue("{s:N,s:N}", "uri", uri, info_key, info); + if (playlist == NULL) return -1; + + PyObject *list = dict_get_interned(ctx->data, list_key); + int rc = PyList_Append(list, playlist); + Py_DECREF(playlist); + return rc; +} + +static int handle_i_frame_stream_inf(ParseContext *ctx, const char *line) { + return handle_stream_inf_with_uri(ctx, line, EXT_X_I_FRAME_STREAM_INF, + iframe_stream_inf_parsers, NUM_IFRAME_STREAM_INF_PARSERS, + "iframe_stream_info", ctx->mod_state->str_iframe_playlists); +} + +static int handle_image_stream_inf(ParseContext *ctx, const char *line) { + return handle_stream_inf_with_uri(ctx, line, EXT_X_IMAGE_STREAM_INF, + image_stream_inf_parsers, NUM_IMAGE_STREAM_INF_PARSERS, + "image_stream_info", ctx->mod_state->str_image_playlists); +} + +/* + * Macro-generated handlers that parse typed attributes and append to a list. + */ +#define MAKE_TYPED_ATTR_LIST_HANDLER(name, tag, parsers, num_parsers, field) \ + static int name(ParseContext *ctx, const char *line) { \ + PyObject *result = parse_typed_attribute_list(line, tag, parsers, num_parsers); \ + if (result == NULL) return -1; \ + PyObject *list = dict_get_interned(ctx->data, ctx->mod_state->field); \ + int rc = PyList_Append(list, result); \ + Py_DECREF(result); \ + return rc; \ + } + +MAKE_TYPED_ATTR_LIST_HANDLER(handle_media, EXT_X_MEDIA, media_parsers, NUM_MEDIA_PARSERS, str_media) + +/* Handler for #EXT-X-PLAYLIST-TYPE */ +static int +handle_playlist_type(ParseContext *ctx, const char *line) +{ + const char *value = line + sizeof(EXT_X_PLAYLIST_TYPE); + /* Use create_normalized_key for safe, DRY normalization (tolower + strip) */ + PyObject *py_value = create_normalized_key(value, strlen(value)); + if (py_value == NULL) return -1; + int rc = dict_set_interned(ctx->data, ctx->mod_state->str_playlist_type, py_value); + Py_DECREF(py_value); + return rc < 0 ? -1 : 0; +} + +MAKE_INT_HANDLER(handle_version, EXT_X_VERSION, str_version) + +#undef MAKE_INT_HANDLER + +/* Handler for #EXT-X-ALLOW-CACHE */ +static int +handle_allow_cache(ParseContext *ctx, const char *line) +{ + const char *value = line + sizeof(EXT_X_ALLOW_CACHE); + char normalized[64]; + size_t i; + for (i = 0; i < sizeof(normalized) - 1 && value[i]; i++) { + normalized[i] = ascii_tolower((unsigned char)value[i]); + } + normalized[i] = '\0'; + PyObject *py_value = PyUnicode_FromString(strip(normalized)); + if (py_value == NULL) { + return -1; + } + int rc = dict_set_interned(ctx->data, ctx->mod_state->str_allow_cache, py_value); + Py_DECREF(py_value); + return rc < 0 ? -1 : 0; +} + +/* + * Macro-generated flag handlers. + * These handlers just set a boolean flag in either data or state dict. + */ +#define MAKE_DATA_FLAG_HANDLER(name, field) \ + static int name(ParseContext *ctx, const char *line) { \ + (void)line; \ + return dict_set_interned(ctx->data, ctx->mod_state->field, Py_True); \ + } + +#define MAKE_STATE_FLAG_HANDLER(name, field) \ + static int name(ParseContext *ctx, const char *line) { \ + (void)line; \ + return dict_set_interned(ctx->state, ctx->mod_state->field, Py_True); \ + } + +MAKE_DATA_FLAG_HANDLER(handle_i_frames_only, str_is_i_frames_only) +MAKE_DATA_FLAG_HANDLER(handle_independent_segments, str_is_independent_segments) +MAKE_DATA_FLAG_HANDLER(handle_endlist, str_is_endlist) +MAKE_DATA_FLAG_HANDLER(handle_images_only, str_is_images_only) +MAKE_STATE_FLAG_HANDLER(handle_discontinuity, str_discontinuity) +MAKE_STATE_FLAG_HANDLER(handle_cue_in, str_cue_in) +MAKE_STATE_FLAG_HANDLER(handle_cue_span, str_cue_out) +MAKE_STATE_FLAG_HANDLER(handle_gap, str_gap) + +#undef MAKE_DATA_FLAG_HANDLER +#undef MAKE_STATE_FLAG_HANDLER + +/* Wrapper handlers for cue parsing */ +static int handle_cue_out(ParseContext *ctx, const char *line) { + return parse_cueout(ctx->mod_state, line, ctx->state); +} +static int handle_cue_out_cont(ParseContext *ctx, const char *line) { + return parse_cueout_cont(ctx->mod_state, line, ctx->state); +} + +/* Handler for #EXT-OATCLS-SCTE35 - uses interned strings */ +static int +handle_oatcls_scte35(ParseContext *ctx, const char *line) +{ + m3u8_state *ms = ctx->mod_state; + const char *value = strchr(line, ':'); + if (value == NULL) return 0; + value++; + + PyObject *py_value = PyUnicode_FromString(value); + if (py_value == NULL) return -1; + + if (dict_set_interned(ctx->state, ms->str_current_cue_out_oatcls_scte35, py_value) < 0) { + Py_DECREF(py_value); + return -1; + } + PyObject *current = dict_get_interned(ctx->state, ms->str_current_cue_out_scte35); + if (current == NULL) { + if (dict_set_interned(ctx->state, ms->str_current_cue_out_scte35, py_value) < 0) { + Py_DECREF(py_value); + return -1; + } + } + Py_DECREF(py_value); + return 0; +} + +/* Handler for #EXT-X-ASSET - uses interned strings */ +static int +handle_asset(ParseContext *ctx, const char *line) +{ + PyObject *asset = parse_attribute_list(line, EXT_X_ASSET); + if (asset == NULL) return -1; + int rc = dict_set_interned(ctx->state, ctx->mod_state->str_asset_metadata, asset); + Py_DECREF(asset); + return rc < 0 ? -1 : 0; +} + +/* Handler for #EXT-X-MAP */ +static int +handle_map(ParseContext *ctx, const char *line) +{ + m3u8_state *ms = ctx->mod_state; + PyObject *map_info = parse_typed_attribute_list(line, EXT_X_MAP, + x_map_parsers, NUM_X_MAP_PARSERS); + if (map_info == NULL) { + return -1; + } + if (dict_set_interned(ctx->state, ms->str_current_segment_map, map_info) < 0) { + Py_DECREF(map_info); + return -1; + } + PyObject *segment_map = dict_get_interned(ctx->data, ms->str_segment_map); + int rc = PyList_Append(segment_map, map_info); + Py_DECREF(map_info); + return rc; +} + +/* + * Macro-generated typed attribute handlers. + * These parse typed attributes and store the result in ctx->data. + */ +#define MAKE_TYPED_ATTR_HANDLER(name, tag, parsers, num_parsers, field) \ + static int name(ParseContext *ctx, const char *line) { \ + PyObject *result = parse_typed_attribute_list(line, tag, parsers, num_parsers); \ + if (result == NULL) return -1; \ + int rc = dict_set_interned(ctx->data, ctx->mod_state->field, result); \ + Py_DECREF(result); \ + return rc < 0 ? -1 : 0; \ + } + +MAKE_TYPED_ATTR_HANDLER(handle_start, EXT_X_START, start_parsers, NUM_START_PARSERS, str_start) +MAKE_TYPED_ATTR_HANDLER(handle_server_control, EXT_X_SERVER_CONTROL, server_control_parsers, NUM_SERVER_CONTROL_PARSERS, str_server_control) +MAKE_TYPED_ATTR_HANDLER(handle_part_inf, EXT_X_PART_INF, part_inf_parsers, NUM_PART_INF_PARSERS, str_part_inf) + +/* Handler for #EXT-X-PART */ +static int handle_part(ParseContext *ctx, const char *line) { + return parse_part(ctx->mod_state, line, ctx->state); +} + +MAKE_TYPED_ATTR_LIST_HANDLER(handle_rendition_report, EXT_X_RENDITION_REPORT, rendition_report_parsers, NUM_RENDITION_REPORT_PARSERS, str_rendition_reports) + +MAKE_TYPED_ATTR_HANDLER(handle_skip, EXT_X_SKIP, skip_parsers, NUM_SKIP_PARSERS, str_skip) + +MAKE_TYPED_ATTR_LIST_HANDLER(handle_session_data, EXT_X_SESSION_DATA, session_data_parsers, NUM_SESSION_DATA_PARSERS, str_session_data) +MAKE_TYPED_ATTR_LIST_HANDLER(handle_tiles, EXT_X_TILES, tiles_parsers, NUM_TILES_PARSERS, str_tiles) + +#undef MAKE_TYPED_ATTR_LIST_HANDLER + +/* Handler for #EXT-X-SESSION-KEY */ +static int handle_session_key(ParseContext *ctx, const char *line) { + PyObject *key = parse_attrs_unquoted(line, EXT_X_SESSION_KEY); + if (!key) return -1; + PyObject *session_keys = dict_get_interned(ctx->data, ctx->mod_state->str_session_keys); + int rc = PyList_Append(session_keys, key); + Py_DECREF(key); + return rc; +} + +MAKE_TYPED_ATTR_HANDLER(handle_preload_hint, EXT_X_PRELOAD_HINT, preload_hint_parsers, NUM_PRELOAD_HINT_PARSERS, str_preload_hint) + +/* Handler for #EXT-X-DATERANGE */ +static int +handle_daterange(ParseContext *ctx, const char *line) +{ + PyObject *daterange = parse_typed_attribute_list(line, EXT_X_DATERANGE, + daterange_parsers, NUM_DATERANGE_PARSERS); + if (daterange == NULL) { + return -1; + } + + PyObject *dateranges = dict_get_interned(ctx->state, ctx->mod_state->str_dateranges); + if (dateranges == NULL) { + dateranges = PyList_New(0); + if (dateranges == NULL) { + Py_DECREF(daterange); + return -1; + } + if (dict_set_interned(ctx->state, ctx->mod_state->str_dateranges, dateranges) < 0) { + Py_DECREF(dateranges); + Py_DECREF(daterange); + return -1; + } + Py_DECREF(dateranges); + dateranges = dict_get_interned(ctx->state, ctx->mod_state->str_dateranges); + } + + int rc = PyList_Append(dateranges, daterange); + Py_DECREF(daterange); + return rc; +} + +MAKE_TYPED_ATTR_HANDLER(handle_content_steering, EXT_X_CONTENT_STEERING, content_steering_parsers, NUM_CONTENT_STEERING_PARSERS, str_content_steering) + +#undef MAKE_TYPED_ATTR_HANDLER + +/* Handler for #EXT-X-BLACKOUT */ +static int +handle_blackout(ParseContext *ctx, const char *line) +{ + const char *colon = strchr(line, ':'); + if (colon != NULL && *(colon + 1) != '\0') { + PyObject *blackout_data = PyUnicode_FromString(colon + 1); + if (blackout_data == NULL) { + return -1; + } + int rc = dict_set_interned(ctx->state, ctx->mod_state->str_blackout, blackout_data); + Py_DECREF(blackout_data); + return rc < 0 ? -1 : 0; + } + return dict_set_interned(ctx->state, ctx->mod_state->str_blackout, Py_True); +} + +/* + * Tag dispatch table. + * + * This replaces the massive if/else strcmp chain with a data-driven approach. + * Linear scan for ~40 tags is negligible compared to Python object creation. + * The table is ordered roughly by frequency for marginally better cache behavior. + * + * Note: sizeof(TAG)-1 gives strlen at compile time (excluding null terminator). + */ +static const TagDispatch TAG_DISPATCH[] = { + /* High-frequency tags first */ + {EXTINF, sizeof(EXTINF)-1, handle_extinf}, + {EXT_X_KEY, sizeof(EXT_X_KEY)-1, handle_key}, + {EXT_X_BYTERANGE, sizeof(EXT_X_BYTERANGE)-1, handle_byterange}, + {EXT_X_PROGRAM_DATE_TIME, sizeof(EXT_X_PROGRAM_DATE_TIME)-1, handle_program_date_time}, + {EXT_X_DISCONTINUITY, sizeof(EXT_X_DISCONTINUITY)-1, handle_discontinuity}, + {EXT_X_MAP, sizeof(EXT_X_MAP)-1, handle_map}, + {EXT_X_PART, sizeof(EXT_X_PART)-1, handle_part}, + {EXT_X_BITRATE, sizeof(EXT_X_BITRATE)-1, handle_bitrate}, + {EXT_X_GAP, sizeof(EXT_X_GAP)-1, handle_gap}, + {EXT_X_DATERANGE, sizeof(EXT_X_DATERANGE)-1, handle_daterange}, + /* Variant playlist tags */ + {EXT_X_STREAM_INF, sizeof(EXT_X_STREAM_INF)-1, handle_stream_inf}, + {EXT_X_MEDIA, sizeof(EXT_X_MEDIA)-1, handle_media}, + {EXT_X_I_FRAME_STREAM_INF, sizeof(EXT_X_I_FRAME_STREAM_INF)-1, handle_i_frame_stream_inf}, + {EXT_X_IMAGE_STREAM_INF, sizeof(EXT_X_IMAGE_STREAM_INF)-1, handle_image_stream_inf}, + {EXT_X_SESSION_DATA, sizeof(EXT_X_SESSION_DATA)-1, handle_session_data}, + {EXT_X_SESSION_KEY, sizeof(EXT_X_SESSION_KEY)-1, handle_session_key}, + {EXT_X_CONTENT_STEERING, sizeof(EXT_X_CONTENT_STEERING)-1, handle_content_steering}, + /* Playlist metadata tags */ + {EXT_X_TARGETDURATION, sizeof(EXT_X_TARGETDURATION)-1, handle_targetduration}, + {EXT_X_MEDIA_SEQUENCE, sizeof(EXT_X_MEDIA_SEQUENCE)-1, handle_media_sequence}, + {EXT_X_DISCONTINUITY_SEQUENCE,sizeof(EXT_X_DISCONTINUITY_SEQUENCE)-1,handle_discontinuity_sequence}, + {EXT_X_PLAYLIST_TYPE, sizeof(EXT_X_PLAYLIST_TYPE)-1, handle_playlist_type}, + {EXT_X_VERSION, sizeof(EXT_X_VERSION)-1, handle_version}, + {EXT_X_ALLOW_CACHE, sizeof(EXT_X_ALLOW_CACHE)-1, handle_allow_cache}, + {EXT_X_ENDLIST, sizeof(EXT_X_ENDLIST)-1, handle_endlist}, + {EXT_I_FRAMES_ONLY, sizeof(EXT_I_FRAMES_ONLY)-1, handle_i_frames_only}, + {EXT_IS_INDEPENDENT_SEGMENTS, sizeof(EXT_IS_INDEPENDENT_SEGMENTS)-1, handle_independent_segments}, + {EXT_X_IMAGES_ONLY, sizeof(EXT_X_IMAGES_ONLY)-1, handle_images_only}, + /* Low-latency HLS tags */ + {EXT_X_SERVER_CONTROL, sizeof(EXT_X_SERVER_CONTROL)-1, handle_server_control}, + {EXT_X_PART_INF, sizeof(EXT_X_PART_INF)-1, handle_part_inf}, + {EXT_X_RENDITION_REPORT, sizeof(EXT_X_RENDITION_REPORT)-1, handle_rendition_report}, + {EXT_X_SKIP, sizeof(EXT_X_SKIP)-1, handle_skip}, + {EXT_X_PRELOAD_HINT, sizeof(EXT_X_PRELOAD_HINT)-1, handle_preload_hint}, + /* SCTE-35 / Ad insertion tags */ + {EXT_X_CUE_OUT_CONT, sizeof(EXT_X_CUE_OUT_CONT)-1, handle_cue_out_cont}, + {EXT_X_CUE_OUT, sizeof(EXT_X_CUE_OUT)-1, handle_cue_out}, + {EXT_X_CUE_IN, sizeof(EXT_X_CUE_IN)-1, handle_cue_in}, + {EXT_X_CUE_SPAN, sizeof(EXT_X_CUE_SPAN)-1, handle_cue_span}, + {EXT_OATCLS_SCTE35, sizeof(EXT_OATCLS_SCTE35)-1, handle_oatcls_scte35}, + {EXT_X_ASSET, sizeof(EXT_X_ASSET)-1, handle_asset}, + /* Miscellaneous tags */ + {EXT_X_START, sizeof(EXT_X_START)-1, handle_start}, + {EXT_X_TILES, sizeof(EXT_X_TILES)-1, handle_tiles}, + {EXT_X_BLACKOUT, sizeof(EXT_X_BLACKOUT)-1, handle_blackout}, + /* Sentinel */ + {NULL, 0, NULL} +}; + +/* + * Dispatch a tag to its handler using the dispatch table. + * + * Returns: + * 1 if handler was found and executed successfully + * 0 if no handler found (unknown tag) + * -1 if handler found but returned error (exception set) + */ +static int +dispatch_tag(ParseContext *ctx, const char *line, size_t line_len) +{ + /* Fast rejection: all M3U8 tags start with '#' */ + if (line_len < 4 || line[0] != '#') { + return 0; + } + + for (const TagDispatch *d = TAG_DISPATCH; d->tag != NULL; d++) { + /* Skip if line is shorter than tag */ + if (line_len < d->tag_len) continue; + + if (strncmp(line, d->tag, d->tag_len) == 0) { + /* Verify tag boundary: must end with ':' or be complete line */ + char next = line[d->tag_len]; + if (next == ':' || next == '\0') { + if (d->handler(ctx, line) < 0) { + return -1; + } + return 1; /* Handled */ + } + } + } + return 0; /* Not found */ +} + +/* + * Main parse function. + * + * Parse M3U8 playlist content and return a dictionary with all data found. + * + * Args: + * content: The M3U8 playlist content as a string. + * strict: If True, raise exceptions for syntax errors (default: False). + * custom_tags_parser: Optional callable for parsing custom tags. + * + * Returns: + * A dictionary containing the parsed playlist data. + */ +static PyObject * +m3u8_parse(PyObject *module, PyObject *args, PyObject *kwargs) +{ + const char *content; + Py_ssize_t content_len; /* Get size directly - enables zero-copy parsing */ + int strict = 0; + PyObject *custom_tags_parser = Py_None; + + static char *kwlist[] = {"content", "strict", "custom_tags_parser", NULL}; + + /* Use s# to get pointer AND size directly from Python string object */ + if (!PyArg_ParseTupleAndKeywords(args, kwargs, "s#|pO", kwlist, + &content, &content_len, &strict, &custom_tags_parser)) { + return NULL; + } + + /* + * Match parser.py's behavior: lines = content.strip().splitlines() + * + * The Python parser strips leading/trailing whitespace *before* splitting, + * which affects strict-mode error line numbers when the input has leading + * newlines (common with triple-quoted test fixtures). + */ + const char *trimmed = content; + const char *trimmed_end = content + content_len; + while (trimmed < trimmed_end && ascii_isspace((unsigned char)*trimmed)) { + trimmed++; + } + while (trimmed_end > trimmed && ascii_isspace((unsigned char)*(trimmed_end - 1))) { + trimmed_end--; + } + Py_ssize_t trimmed_len = (Py_ssize_t)(trimmed_end - trimmed); + + /* Get module state for cached objects */ + m3u8_state *mod_state = get_m3u8_state(module); + + /* Check strict mode validation */ + if (strict) { + /* Import and call version_matching.validate */ + PyObject *version_matching = PyImport_ImportModule("m3u8.version_matching"); + if (version_matching == NULL) { + return NULL; + } + PyObject *validate = PyObject_GetAttrString(version_matching, "validate"); + if (validate == NULL) { + Py_DECREF(version_matching); + return NULL; + } + /* Build list like parser.py: content.strip().splitlines() */ + PyObject *lines_list = build_stripped_splitlines(trimmed); + if (lines_list == NULL) { + Py_DECREF(validate); + Py_DECREF(version_matching); + return NULL; + } + + PyObject *errors = PyObject_CallFunctionObjArgs(validate, lines_list, NULL); + Py_DECREF(lines_list); + Py_DECREF(validate); + Py_DECREF(version_matching); + + if (errors == NULL) { + return NULL; + } + if (PyList_Size(errors) > 0) { + PyErr_SetObject(PyExc_Exception, errors); + Py_DECREF(errors); + return NULL; + } + Py_DECREF(errors); + } + + /* Initialize result data dict using interned strings */ + PyObject *data = init_parse_data(mod_state); + if (data == NULL) { + return NULL; + } + + /* Initialize parser state dict */ + PyObject *state = init_parse_state(mod_state); + if (state == NULL) { + Py_DECREF(data); + return NULL; + } + + /* + * Set up parse context with shadow state. + * Shadow state avoids dict lookups for hot flags in the main loop. + */ + ParseContext ctx = { + .mod_state = mod_state, + .data = data, + .state = state, + .strict = strict, + .lineno = 0, + .expect_segment = 0, /* Matches init_parse_state */ + .expect_playlist = 0, /* Matches init_parse_state */ + }; + + /* + * Zero-copy line parsing: Walk the buffer with pointers. + * We use a single reusable line buffer for the null-terminated stripped line. + * This avoids copying the entire content upfront (the strtok_r approach). + */ + const char *p = trimmed; + const char *end = trimmed + trimmed_len; + + /* Reusable line buffer - starts small, grows as needed */ + size_t line_buf_size = 256; + char *line_buf = PyMem_Malloc(line_buf_size); + if (line_buf == NULL) { + Py_DECREF(data); + Py_DECREF(state); + return PyErr_NoMemory(); + } + + while (p < end) { + ctx.lineno++; + + /* Find end of line using memchr (often hardware-optimized) */ + const char *line_start = p; + const char *eol = p; + while (eol < end && *eol != '\n' && *eol != '\r') { + eol++; + } + Py_ssize_t line_len = eol - line_start; + + /* Strip leading whitespace */ + while (line_len > 0 && ascii_isspace((unsigned char)*line_start)) { + line_start++; + line_len--; + } + /* Strip trailing whitespace */ + while (line_len > 0 && ascii_isspace((unsigned char)line_start[line_len - 1])) { + line_len--; + } + + /* Advance p past the newline(s) for next iteration */ + if (eol < end) { + if (*eol == '\r' && (eol + 1) < end && *(eol + 1) == '\n') { + p = eol + 2; /* Skip \r\n */ + } else { + p = eol + 1; /* Skip \n or \r */ + } + } else { + p = end; + } + + /* Skip empty lines */ + if (line_len == 0) { + continue; + } + + /* Grow line buffer if needed */ + if ((size_t)line_len + 1 > line_buf_size) { + line_buf_size = (size_t)line_len + 1; + char *new_buf = PyMem_Realloc(line_buf, line_buf_size); + if (new_buf == NULL) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return PyErr_NoMemory(); + } + line_buf = new_buf; + } + + /* Copy stripped line to null-terminated buffer */ + memcpy(line_buf, line_start, line_len); + line_buf[line_len] = '\0'; + char *stripped = line_buf; + + /* Call custom tags parser if provided */ + if (stripped[0] == '#' && custom_tags_parser != Py_None && PyCallable_Check(custom_tags_parser)) { + /* Sync shadow state to dict before callback (so it sees current state) */ + if (sync_shadow_to_dict(&ctx) < 0) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + PyObject *py_line = PyUnicode_FromString(stripped); + PyObject *py_lineno = PyLong_FromLong(ctx.lineno); + if (py_line == NULL || py_lineno == NULL) { + Py_XDECREF(py_line); + Py_XDECREF(py_lineno); + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + PyObject *call_args = PyTuple_Pack(4, py_line, py_lineno, data, state); + Py_DECREF(py_line); + Py_DECREF(py_lineno); + if (call_args == NULL) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + PyObject *result = PyObject_Call(custom_tags_parser, call_args, NULL); + Py_DECREF(call_args); + if (!result) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + /* Sync shadow state from dict (callback may have modified it) */ + sync_shadow_from_dict(&ctx); + int truth = PyObject_IsTrue(result); + Py_DECREF(result); + if (truth < 0) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + if (truth) { + /* p has already been advanced to the next line at the top of the loop */ + continue; + } + } + + if (stripped[0] == '#') { + /* + * Tag dispatch using data-driven table lookup. + * This replaces ~400 lines of if/else strcmp chain with a clean loop. + * See TAG_DISPATCH table for the tag-to-handler mappings. + */ + + /* Handle #EXTM3U - just ignore it */ + if (strncmp(stripped, EXT_M3U, sizeof(EXT_M3U)-1) == 0) { + continue; + } + + /* Dispatch to handler via table lookup */ + int dispatch_result = dispatch_tag(&ctx, stripped, line_len); + if (dispatch_result < 0) { + /* Handler returned error */ + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + if (dispatch_result == 0) { + /* Unknown tag - error in strict mode */ + if (ctx.strict) { + raise_parse_error(mod_state, ctx.lineno, stripped); + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + } + } else { + /* Non-comment line - segment or playlist URI */ + /* Use shadow state for hot path checks (no dict lookups) */ + if (ctx.expect_segment) { + if (parse_ts_chunk(mod_state, stripped, data, state) < 0) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + ctx.expect_segment = 0; /* parse_ts_chunk clears this */ + } else if (ctx.expect_playlist) { + if (parse_variant_playlist(mod_state, stripped, data, state) < 0) { + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + ctx.expect_playlist = 0; /* parse_variant_playlist clears this */ + } else if (strict) { + raise_parse_error(mod_state, ctx.lineno, stripped); + PyMem_Free(line_buf); + Py_DECREF(data); + Py_DECREF(state); + return NULL; + } + } + /* Loop continues with pointer already advanced */ + } + + PyMem_Free(line_buf); + + /* Handle remaining partial segment - use interned strings */ + PyObject *segment = dict_get_interned(state, mod_state->str_segment); + if (segment) { + PyObject *segments = dict_get_interned(data, mod_state->str_segments); + if (segments && PyList_Append(segments, segment) < 0) { + Py_DECREF(state); + Py_DECREF(data); + return NULL; + } + } + + Py_DECREF(state); + return data; +} + +/* Module methods */ +static PyMethodDef m3u8_parser_methods[] = { + {"parse", (PyCFunction)m3u8_parse, METH_VARARGS | METH_KEYWORDS, + PyDoc_STR( + "parse(content, strict=False, custom_tags_parser=None)\n" + "--\n\n" + "Parse M3U8 playlist content and return a dictionary with all data found.\n\n" + "This is an optimized C implementation that produces output identical to\n" + "the pure Python parser in m3u8.parser.parse().\n\n" + "Parameters\n" + "----------\n" + "content : str\n" + " The M3U8 playlist content as a string.\n" + "strict : bool, optional\n" + " If True, raise exceptions for syntax errors. Default is False.\n" + "custom_tags_parser : callable, optional\n" + " A function that receives (line, lineno, data, state) for custom tag\n" + " handling. Return True to skip default parsing for that line.\n\n" + "Returns\n" + "-------\n" + "dict\n" + " A dictionary containing the parsed playlist data with keys including:\n" + " 'segments', 'playlists', 'media', 'keys', 'is_variant', etc.\n\n" + "Raises\n" + "------\n" + "ParseError\n" + " If strict=True and a syntax error is encountered.\n" + "Exception\n" + " If strict=True and version validation fails.\n\n" + "Examples\n" + "--------\n" + ">>> from m3u8._m3u8_parser import parse\n" + ">>> result = parse('#EXTM3U\\n#EXTINF:10,\\nfoo.ts')\n" + ">>> len(result['segments'])\n" + "1\n" + )}, + {NULL, NULL, 0, NULL} +}; + +/* + * Module traverse function for GC - uses X-macro expansion. + */ +static int +m3u8_parser_traverse(PyObject *module, visitproc visit, void *arg) +{ + m3u8_state *state = get_m3u8_state(module); + Py_VISIT(state->ParseError); + Py_VISIT(state->datetime_cls); + Py_VISIT(state->timedelta_cls); + Py_VISIT(state->fromisoformat_meth); + #define VISIT_INTERNED(name, str) Py_VISIT(state->name); + INTERNED_STRINGS(VISIT_INTERNED) + #undef VISIT_INTERNED + return 0; +} + +/* + * Module clear function for GC - uses X-macro expansion. + */ +static int +m3u8_parser_clear(PyObject *module) +{ + m3u8_state *state = get_m3u8_state(module); + Py_CLEAR(state->ParseError); + Py_CLEAR(state->datetime_cls); + Py_CLEAR(state->timedelta_cls); + Py_CLEAR(state->fromisoformat_meth); + #define CLEAR_INTERNED(name, str) Py_CLEAR(state->name); + INTERNED_STRINGS(CLEAR_INTERNED) + #undef CLEAR_INTERNED + return 0; +} + +/* + * Module deallocation function. + */ +static void +m3u8_parser_free(void *module) +{ + m3u8_parser_clear((PyObject *)module); +} + +/* Module definition */ +static struct PyModuleDef m3u8_parser_module = { + PyModuleDef_HEAD_INIT, + .m_name = "_m3u8_parser", + .m_doc = "C extension for fast M3U8 playlist parsing.", + .m_size = sizeof(m3u8_state), + .m_methods = m3u8_parser_methods, + .m_traverse = m3u8_parser_traverse, + .m_clear = m3u8_parser_clear, + .m_free = m3u8_parser_free, +}; + +/* + * Module initialization. + * + * Creates the module, initializes module state, and sets up cached objects. + */ +PyMODINIT_FUNC +PyInit__m3u8_parser(void) +{ + PyObject *m = PyModule_Create(&m3u8_parser_module); + if (m == NULL) { + return NULL; + } + + m3u8_state *state = get_m3u8_state(m); + + /* Initialize module state to NULL for safe cleanup on error */ + state->ParseError = NULL; + state->datetime_cls = NULL; + state->timedelta_cls = NULL; + state->fromisoformat_meth = NULL; + #define NULL_INTERNED(name, str) state->name = NULL; + INTERNED_STRINGS(NULL_INTERNED) + #undef NULL_INTERNED + + /* Import ParseError from m3u8.parser to use the same exception class */ + PyObject *parser_module = PyImport_ImportModule("m3u8.parser"); + if (parser_module != NULL) { + state->ParseError = PyObject_GetAttrString(parser_module, "ParseError"); + Py_DECREF(parser_module); + } + + /* Fallback: create our own ParseError if import fails */ + if (state->ParseError == NULL) { + PyErr_Clear(); + state->ParseError = PyErr_NewException( + "m3u8._m3u8_parser.ParseError", PyExc_Exception, NULL); + if (state->ParseError == NULL) { + goto error; + } + } + + /* Add ParseError to module (PyModule_AddObject steals a reference on success) */ + Py_INCREF(state->ParseError); + if (PyModule_AddObject(m, "ParseError", state->ParseError) < 0) { + Py_DECREF(state->ParseError); + goto error; + } + + /* Initialize datetime cache */ + if (init_datetime_cache(state) < 0) { + goto error; + } + + /* Initialize interned strings for common dict keys */ + if (init_interned_strings(state) < 0) { + goto error; + } + + return m; + +error: + Py_DECREF(m); + return NULL; +} + diff --git a/m3u8/model.py b/m3u8/model.py index 1dc7a5e4..d33f39c6 100644 --- a/m3u8/model.py +++ b/m3u8/model.py @@ -5,7 +5,15 @@ import os from m3u8.mixins import BasePathMixin, GroupedBasePathMixin -from m3u8.parser import format_date_time, parse +from m3u8.parser import parse, format_date_time + +# Try to import the C extension for faster parsing, fall back to Python +if os.environ.get("M3U8_NO_C_EXTENSION", "") != "1": + try: + from m3u8._m3u8_parser import parse + except ImportError: + pass + from m3u8.protocol import ( ext_oatcls_scte35, ext_x_asset, @@ -518,6 +526,9 @@ class Segment(BasePathMixin): `gap_tag` GAP tag indicates that a Media Segment is missing + `blackout` + indicates program changes + `custom_parser_values` Additional values which custom_tags_parser might store per segment """ @@ -548,6 +559,7 @@ def __init__( init_section=None, dateranges=None, gap_tag=None, + blackout=None, media_sequence=None, custom_parser_values=None, ): @@ -584,6 +596,7 @@ def __init__( [DateRange(**daterange) for daterange in dateranges] if dateranges else [] ) self.gap_tag = gap_tag + self.blackout = blackout self.custom_parser_values = custom_parser_values or {} def add_part(self, part): @@ -645,8 +658,18 @@ def dumps(self, last_segment, timespec="milliseconds", infspec="auto"): else: cue_out_cont_suffix = "" output.append(f"#EXT-X-CUE-OUT-CONT{cue_out_cont_suffix}\n") + if self.oatcls_scte35: + if (not last_segment) or ( + last_segment.oatcls_scte35 != self.oatcls_scte35 + ): + output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n") elif self.cue_in: output.append("#EXT-X-CUE-IN\n") + if self.oatcls_scte35: + if (not last_segment) or ( + last_segment.oatcls_scte35 != self.oatcls_scte35 + ): + output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n") elif self.oatcls_scte35: output.append(f"{ext_oatcls_scte35}:{self.oatcls_scte35}\n") @@ -654,6 +677,14 @@ def dumps(self, last_segment, timespec="milliseconds", infspec="auto"): output.append(str(self.parts)) output.append("\n") + if self.blackout: + if self.blackout is True: + # tag without parameters + output.append("#EXT-X-BLACKOUT\n") + else: + # tag with parameters + output.append(f"#EXT-X-BLACKOUT:{self.blackout}\n") + if self.uri: if self.duration is not None: if infspec == "milliseconds": diff --git a/m3u8/parser.py b/m3u8/parser.py index 21c4d2c0..e8f4cb36 100644 --- a/m3u8/parser.py +++ b/m3u8/parser.py @@ -91,6 +91,10 @@ def parse(content, strict=False, custom_tags_parser=None): "strict": strict, } + # Blank lines are ignored. + if not line: + continue + # Call custom parser if needed if line.startswith("#") and callable(custom_tags_parser): go_to_next_line = custom_tags_parser(line, lineno, data, state) @@ -100,143 +104,27 @@ def parse(content, strict=False, custom_tags_parser=None): if go_to_next_line: continue - if line.startswith(protocol.ext_x_byterange): - _parse_byterange(**parse_kwargs) + # Fast-path: dispatch based on tag token up to first ':' (or full tag if none) + if line.startswith("#"): + tag = line.split(":", 1)[0] + handler = DISPATCH.get(tag) + if handler is not None: + handler(**parse_kwargs) + continue + # #EXTM3U should be present; ignore if seen + if tag == protocol.ext_m3u: + continue + # In strict mode, unrecognized tags are illegal + if strict: + raise ParseError(lineno, line) continue - elif line.startswith(protocol.ext_x_bitrate): - _parse_bitrate(**parse_kwargs) - - elif line.startswith(protocol.ext_x_targetduration): - _parse_targetduration(**parse_kwargs) - - elif line.startswith(protocol.ext_x_media_sequence): - _parse_media_sequence(**parse_kwargs) - - elif line.startswith(protocol.ext_x_discontinuity_sequence): - _parse_discontinuity_sequence(**parse_kwargs) - - elif line.startswith(protocol.ext_x_program_date_time): - _parse_program_date_time(**parse_kwargs) - - elif line.startswith(protocol.ext_x_discontinuity): - _parse_discontinuity(**parse_kwargs) - - elif line.startswith(protocol.ext_x_cue_out_cont): - _parse_cueout_cont(**parse_kwargs) - - elif line.startswith(protocol.ext_x_cue_out): - _parse_cueout(**parse_kwargs) - - elif line.startswith(f"{protocol.ext_oatcls_scte35}:"): - _parse_oatcls_scte35(**parse_kwargs) - - elif line.startswith(f"{protocol.ext_x_asset}:"): - _parse_asset(**parse_kwargs) - - elif line.startswith(protocol.ext_x_cue_in): - _parse_cue_in(**parse_kwargs) - - elif line.startswith(protocol.ext_x_cue_span): - _parse_cue_span(**parse_kwargs) - - elif line.startswith(protocol.ext_x_version): - _parse_version(**parse_kwargs) - - elif line.startswith(protocol.ext_x_allow_cache): - _parse_allow_cache(**parse_kwargs) - - elif line.startswith(protocol.ext_x_key): - _parse_key(**parse_kwargs) - - elif line.startswith(protocol.extinf): - _parse_extinf(**parse_kwargs) - - elif line.startswith(protocol.ext_x_stream_inf): - _parse_stream_inf(**parse_kwargs) - - elif line.startswith(protocol.ext_x_i_frame_stream_inf): - _parse_i_frame_stream_inf(**parse_kwargs) - - elif line.startswith(protocol.ext_x_media): - _parse_media(**parse_kwargs) - - elif line.startswith(protocol.ext_x_playlist_type): - _parse_playlist_type(**parse_kwargs) - - elif line.startswith(protocol.ext_i_frames_only): - _parse_i_frames_only(**parse_kwargs) - - elif line.startswith(protocol.ext_is_independent_segments): - _parse_is_independent_segments(**parse_kwargs) - - elif line.startswith(protocol.ext_x_endlist): - _parse_endlist(**parse_kwargs) - - elif line.startswith(protocol.ext_x_map): - _parse_x_map(**parse_kwargs) - - elif line.startswith(protocol.ext_x_start): - _parse_start(**parse_kwargs) - - elif line.startswith(protocol.ext_x_server_control): - _parse_server_control(**parse_kwargs) - - elif line.startswith(protocol.ext_x_part_inf): - _parse_part_inf(**parse_kwargs) - - elif line.startswith(protocol.ext_x_rendition_report): - _parse_rendition_report(**parse_kwargs) - - elif line.startswith(protocol.ext_x_part): - _parse_part(**parse_kwargs) - - elif line.startswith(protocol.ext_x_skip): - _parse_skip(**parse_kwargs) - - elif line.startswith(protocol.ext_x_session_data): - _parse_session_data(**parse_kwargs) - - elif line.startswith(protocol.ext_x_session_key): - _parse_session_key(**parse_kwargs) - - elif line.startswith(protocol.ext_x_preload_hint): - _parse_preload_hint(**parse_kwargs) - - elif line.startswith(protocol.ext_x_daterange): - _parse_daterange(**parse_kwargs) - - elif line.startswith(protocol.ext_x_gap): - _parse_gap(**parse_kwargs) - - elif line.startswith(protocol.ext_x_content_steering): - _parse_content_steering(**parse_kwargs) - - elif line.startswith(protocol.ext_x_image_stream_inf): - _parse_image_stream_inf(**parse_kwargs) - - elif line.startswith(protocol.ext_x_images_only): - _parse_is_images_only(**parse_kwargs) - elif line.startswith(protocol.ext_x_tiles): - _parse_tiles(**parse_kwargs) - - # #EXTM3U should be present. - elif line.startswith(protocol.ext_m3u): - pass - - # Blank lines are ignored. - elif line.strip() == "": - pass - # Lines that don't start with # are either segments or playlists. - elif (not line.startswith("#")) and (state["expect_segment"]): + if state["expect_segment"]: _parse_ts_chunk(**parse_kwargs) - - elif (not line.startswith("#")) and (state["expect_playlist"]): + elif state["expect_playlist"]: _parse_variant_playlist(**parse_kwargs) - - # Lines that haven't been recognized by any of the parsers above are illegal - # in strict mode. + # In strict mode, any other content is illegal elif strict: raise ParseError(lineno, line) @@ -311,6 +199,7 @@ def _parse_ts_chunk(line, data, state, **kwargs): segment["init_section"] = state["current_segment_map"] segment["dateranges"] = state.pop("dateranges", None) segment["gap_tag"] = state.pop("gap", None) + segment["blackout"] = state.pop("blackout", None) data["segments"].append(segment) state["expect_segment"] = False @@ -345,35 +234,14 @@ def _parse_stream_inf(line, data, state, **kwargs): state["expect_playlist"] = True data["is_variant"] = True data["media_sequence"] = None - attribute_parser = remove_quotes_parser( - "codecs", - "audio", - "video", - "video_range", - "subtitles", - "pathway_id", - "stable_variant_id", - ) - attribute_parser["program_id"] = int - attribute_parser["bandwidth"] = lambda x: int(float(x)) - attribute_parser["average_bandwidth"] = int - attribute_parser["frame_rate"] = float - attribute_parser["hdcp_level"] = str state["stream_info"] = _parse_attribute_list( - protocol.ext_x_stream_inf, line, attribute_parser + protocol.ext_x_stream_inf, line, STREAM_INF_ATTRIBUTE_PARSER ) def _parse_i_frame_stream_inf(line, data, **kwargs): - attribute_parser = remove_quotes_parser( - "codecs", "uri", "pathway_id", "stable_variant_id" - ) - attribute_parser["program_id"] = int - attribute_parser["bandwidth"] = int - attribute_parser["average_bandwidth"] = int - attribute_parser["hdcp_level"] = str iframe_stream_info = _parse_attribute_list( - protocol.ext_x_i_frame_stream_inf, line, attribute_parser + protocol.ext_x_i_frame_stream_inf, line, IFRAME_STREAM_INF_ATTRIBUTE_PARSER ) iframe_playlist = { "uri": iframe_stream_info.pop("uri"), @@ -384,15 +252,8 @@ def _parse_i_frame_stream_inf(line, data, **kwargs): def _parse_image_stream_inf(line, data, **kwargs): - attribute_parser = remove_quotes_parser( - "codecs", "uri", "pathway_id", "stable_variant_id" - ) - attribute_parser["program_id"] = int - attribute_parser["bandwidth"] = int - attribute_parser["average_bandwidth"] = int - attribute_parser["resolution"] = str image_stream_info = _parse_attribute_list( - protocol.ext_x_image_stream_inf, line, attribute_parser + protocol.ext_x_image_stream_inf, line, IMAGE_STREAM_INF_ATTRIBUTE_PARSER ) image_playlist = { "uri": image_stream_info.pop("uri"), @@ -407,29 +268,14 @@ def _parse_is_images_only(line, data, **kwargs): def _parse_tiles(line, data, state, **kwargs): - attribute_parser = remove_quotes_parser("uri") - attribute_parser["resolution"] = str - attribute_parser["layout"] = str - attribute_parser["duration"] = float - tiles_info = _parse_attribute_list(protocol.ext_x_tiles, line, attribute_parser) + tiles_info = _parse_attribute_list( + protocol.ext_x_tiles, line, TILES_ATTRIBUTE_PARSER + ) data["tiles"].append(tiles_info) def _parse_media(line, data, **kwargs): - quoted = remove_quotes_parser( - "uri", - "group_id", - "language", - "assoc_language", - "name", - "instream_id", - "characteristics", - "channels", - "stable_rendition_id", - "thumbnails", - "image", - ) - media = _parse_attribute_list(protocol.ext_x_media, line, quoted) + media = _parse_attribute_list(protocol.ext_x_media, line, MEDIA_ATTRIBUTE_PARSER) data["media"].append(media) @@ -499,15 +345,17 @@ def _parse_playlist_type(line, data, **kwargs): def _parse_x_map(line, data, state, **kwargs): - quoted_parser = remove_quotes_parser("uri", "byterange") - segment_map_info = _parse_attribute_list(protocol.ext_x_map, line, quoted_parser) + segment_map_info = _parse_attribute_list( + protocol.ext_x_map, line, X_MAP_ATTRIBUTE_PARSER + ) state["current_segment_map"] = segment_map_info data["segment_map"].append(segment_map_info) def _parse_start(line, data, **kwargs): - attribute_parser = {"time_offset": lambda x: float(x)} - start_info = _parse_attribute_list(protocol.ext_x_start, line, attribute_parser) + start_info = _parse_attribute_list( + protocol.ext_x_start, line, START_ATTRIBUTE_PARSER + ) data["start"] = start_info @@ -515,6 +363,18 @@ def _parse_gap(state, **kwargs): state["gap"] = True +def _parse_blackout(line, state, **kwargs): + # Store the full tag content to pass through unmodified + # Extract everything after "#EXT-X-BLACKOUT" + if ":" in line: + # Tag has parameters: #EXT-X-BLACKOUT:params + blackout_data = line.split(":", 1)[1] + else: + # Tag has no parameters, just store True + blackout_data = True + state["blackout"] = blackout_data + + def _parse_simple_parameter_raw_value(line, cast_to=str, normalize=False, **kwargs): param, value = line.split(":", 1) param = normalize_attribute(param.replace("#EXT-X-", "")) @@ -558,7 +418,7 @@ def _parse_cueout_cont(line, state, **kwargs): cue_info = _parse_attribute_list( protocol.ext_x_cue_out_cont, line, - remove_quotes_parser("duration", "elapsedtime", "scte35"), + CUEOUT_CONT_ATTRIBUTE_PARSER, ) # EXT-X-CUE-OUT-CONT:2.436/120 style @@ -597,7 +457,7 @@ def _parse_cueout(line, state, **kwargs): cue_info = _parse_attribute_list( protocol.ext_x_cue_out, line, - remove_quotes_parser("cue"), + CUEOUT_ATTRIBUTE_PARSER, ) cue_out_scte35 = cue_info.get("cue") cue_out_duration = cue_info.get("duration") or cue_info.get("") @@ -608,47 +468,27 @@ def _parse_cueout(line, state, **kwargs): def _parse_server_control(line, data, **kwargs): - attribute_parser = { - "can_block_reload": str, - "hold_back": lambda x: float(x), - "part_hold_back": lambda x: float(x), - "can_skip_until": lambda x: float(x), - "can_skip_dateranges": str, - } - data["server_control"] = _parse_attribute_list( - protocol.ext_x_server_control, line, attribute_parser + protocol.ext_x_server_control, line, SERVER_CONTROL_ATTRIBUTE_PARSER ) def _parse_part_inf(line, data, **kwargs): - attribute_parser = {"part_target": lambda x: float(x)} - data["part_inf"] = _parse_attribute_list( - protocol.ext_x_part_inf, line, attribute_parser + protocol.ext_x_part_inf, line, PART_INF_ATTRIBUTE_PARSER ) def _parse_rendition_report(line, data, **kwargs): - attribute_parser = remove_quotes_parser("uri") - attribute_parser["last_msn"] = int - attribute_parser["last_part"] = int - rendition_report = _parse_attribute_list( - protocol.ext_x_rendition_report, line, attribute_parser + protocol.ext_x_rendition_report, line, RENDITION_REPORT_ATTRIBUTE_PARSER ) data["rendition_reports"].append(rendition_report) def _parse_part(line, state, **kwargs): - attribute_parser = remove_quotes_parser("uri") - attribute_parser["duration"] = lambda x: float(x) - attribute_parser["independent"] = str - attribute_parser["gap"] = str - attribute_parser["byterange"] = str - - part = _parse_attribute_list(protocol.ext_x_part, line, attribute_parser) + part = _parse_attribute_list(protocol.ext_x_part, line, PART_ATTRIBUTE_PARSER) # this should always be true according to spec if state.get("current_program_date_time"): @@ -668,15 +508,15 @@ def _parse_part(line, state, **kwargs): def _parse_skip(line, data, **parse_kwargs): - attribute_parser = remove_quotes_parser("recently_removed_dateranges") - attribute_parser["skipped_segments"] = int - - data["skip"] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser) + data["skip"] = _parse_attribute_list( + protocol.ext_x_skip, line, SKIP_ATTRIBUTE_PARSER + ) def _parse_session_data(line, data, **kwargs): - quoted = remove_quotes_parser("data_id", "value", "uri", "language") - session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted) + session_data = _parse_attribute_list( + protocol.ext_x_session_data, line, SESSION_DATA_ATTRIBUTE_PARSER + ) data["session_data"].append(session_data) @@ -692,26 +532,15 @@ def _parse_session_key(line, data, **kwargs): def _parse_preload_hint(line, data, **kwargs): - attribute_parser = remove_quotes_parser("uri") - attribute_parser["type"] = str - attribute_parser["byterange_start"] = int - attribute_parser["byterange_length"] = int - data["preload_hint"] = _parse_attribute_list( - protocol.ext_x_preload_hint, line, attribute_parser + protocol.ext_x_preload_hint, line, PRELOAD_HINT_ATTRIBUTE_PARSER ) def _parse_daterange(line, state, **kwargs): - attribute_parser = remove_quotes_parser("id", "class", "start_date", "end_date") - attribute_parser["duration"] = float - attribute_parser["planned_duration"] = float - attribute_parser["end_on_next"] = str - attribute_parser["scte35_cmd"] = str - attribute_parser["scte35_out"] = str - attribute_parser["scte35_in"] = str - - parsed = _parse_attribute_list(protocol.ext_x_daterange, line, attribute_parser) + parsed = _parse_attribute_list( + protocol.ext_x_daterange, line, DATERANGE_ATTRIBUTE_PARSER + ) if "dateranges" not in state: state["dateranges"] = [] @@ -720,17 +549,16 @@ def _parse_daterange(line, state, **kwargs): def _parse_content_steering(line, data, **kwargs): - attribute_parser = remove_quotes_parser("server_uri", "pathway_id") - data["content_steering"] = _parse_attribute_list( - protocol.ext_x_content_steering, line, attribute_parser + protocol.ext_x_content_steering, line, CONTENT_STEERING_ATTRIBUTE_PARSER ) def _parse_oatcls_scte35(line, state, **kwargs): scte35_cue = line.split(":", 1)[1] state["current_cue_out_oatcls_scte35"] = scte35_cue - state["current_cue_out_scte35"] = scte35_cue + if not state.get("current_cue_out_scte35"): + state["current_cue_out_scte35"] = scte35_cue def _parse_asset(line, state, **kwargs): @@ -793,3 +621,165 @@ def save_segment_custom_value(state, key, value): state["segment"]["custom_parser_values"] = {} state["segment"]["custom_parser_values"][key] = value + + +# Attribute parser constants (built once) +STREAM_INF_ATTRIBUTE_PARSER = remove_quotes_parser( + "codecs", + "audio", + "video", + "video_range", + "subtitles", + "pathway_id", + "stable_variant_id", +) +STREAM_INF_ATTRIBUTE_PARSER.update( + { + "program_id": int, + "bandwidth": lambda x: int(float(x)), + "average_bandwidth": int, + "frame_rate": float, + "hdcp_level": str, + } +) + +IFRAME_STREAM_INF_ATTRIBUTE_PARSER = remove_quotes_parser( + "codecs", "uri", "pathway_id", "stable_variant_id" +) +IFRAME_STREAM_INF_ATTRIBUTE_PARSER.update( + { + "program_id": int, + "bandwidth": int, + "average_bandwidth": int, + "hdcp_level": str, + } +) + +IMAGE_STREAM_INF_ATTRIBUTE_PARSER = remove_quotes_parser( + "codecs", "uri", "pathway_id", "stable_variant_id" +) +IMAGE_STREAM_INF_ATTRIBUTE_PARSER.update( + { + "program_id": int, + "bandwidth": int, + "average_bandwidth": int, + "resolution": str, + } +) + +MEDIA_ATTRIBUTE_PARSER = remove_quotes_parser( + "uri", + "group_id", + "language", + "assoc_language", + "name", + "instream_id", + "characteristics", + "channels", + "stable_rendition_id", + "thumbnails", + "image", +) + +X_MAP_ATTRIBUTE_PARSER = remove_quotes_parser("uri", "byterange") + +START_ATTRIBUTE_PARSER = {"time_offset": lambda x: float(x)} + +SERVER_CONTROL_ATTRIBUTE_PARSER = { + "can_block_reload": str, + "hold_back": lambda x: float(x), + "part_hold_back": lambda x: float(x), + "can_skip_until": lambda x: float(x), + "can_skip_dateranges": str, +} + +PART_INF_ATTRIBUTE_PARSER = {"part_target": lambda x: float(x)} + +RENDITION_REPORT_ATTRIBUTE_PARSER = remove_quotes_parser("uri") +RENDITION_REPORT_ATTRIBUTE_PARSER.update({"last_msn": int, "last_part": int}) + +PART_ATTRIBUTE_PARSER = remove_quotes_parser("uri") +PART_ATTRIBUTE_PARSER.update( + {"duration": lambda x: float(x), "independent": str, "gap": str, "byterange": str} +) + +SKIP_ATTRIBUTE_PARSER = remove_quotes_parser("recently_removed_dateranges") +SKIP_ATTRIBUTE_PARSER.update({"skipped_segments": int}) + +SESSION_DATA_ATTRIBUTE_PARSER = remove_quotes_parser( + "data_id", "value", "uri", "language" +) + +PRELOAD_HINT_ATTRIBUTE_PARSER = remove_quotes_parser("uri") +PRELOAD_HINT_ATTRIBUTE_PARSER.update( + {"type": str, "byterange_start": int, "byterange_length": int} +) + +DATERANGE_ATTRIBUTE_PARSER = remove_quotes_parser( + "id", "class", "start_date", "end_date" +) +DATERANGE_ATTRIBUTE_PARSER.update( + { + "duration": float, + "planned_duration": float, + "end_on_next": str, + "scte35_cmd": str, + "scte35_out": str, + "scte35_in": str, + } +) + +CONTENT_STEERING_ATTRIBUTE_PARSER = remove_quotes_parser("server_uri", "pathway_id") + +CUEOUT_CONT_ATTRIBUTE_PARSER = remove_quotes_parser("duration", "elapsedtime", "scte35") + +CUEOUT_ATTRIBUTE_PARSER = remove_quotes_parser("cue") + +TILES_ATTRIBUTE_PARSER = remove_quotes_parser("uri") +TILES_ATTRIBUTE_PARSER.update({"resolution": str, "layout": str, "duration": float}) + + +# Single token-to-handler dispatch to avoid a long startswith chain +DISPATCH = { + protocol.ext_x_byterange: _parse_byterange, + protocol.ext_x_bitrate: _parse_bitrate, + protocol.ext_x_targetduration: _parse_targetduration, + protocol.ext_x_media_sequence: _parse_media_sequence, + protocol.ext_x_discontinuity_sequence: _parse_discontinuity_sequence, + protocol.ext_x_program_date_time: _parse_program_date_time, + protocol.ext_x_discontinuity: _parse_discontinuity, + protocol.ext_x_cue_out_cont: _parse_cueout_cont, + protocol.ext_x_cue_out: _parse_cueout, + protocol.ext_oatcls_scte35: _parse_oatcls_scte35, + protocol.ext_x_asset: _parse_asset, + protocol.ext_x_cue_in: _parse_cue_in, + protocol.ext_x_cue_span: _parse_cue_span, + protocol.ext_x_version: _parse_version, + protocol.ext_x_allow_cache: _parse_allow_cache, + protocol.ext_x_key: _parse_key, + protocol.extinf: _parse_extinf, + protocol.ext_x_stream_inf: _parse_stream_inf, + protocol.ext_x_i_frame_stream_inf: _parse_i_frame_stream_inf, + protocol.ext_x_media: _parse_media, + protocol.ext_x_playlist_type: _parse_playlist_type, + protocol.ext_i_frames_only: _parse_i_frames_only, + protocol.ext_is_independent_segments: _parse_is_independent_segments, + protocol.ext_x_endlist: _parse_endlist, + protocol.ext_x_map: _parse_x_map, + protocol.ext_x_start: _parse_start, + protocol.ext_x_server_control: _parse_server_control, + protocol.ext_x_part_inf: _parse_part_inf, + protocol.ext_x_rendition_report: _parse_rendition_report, + protocol.ext_x_part: _parse_part, + protocol.ext_x_skip: _parse_skip, + protocol.ext_x_session_data: _parse_session_data, + protocol.ext_x_session_key: _parse_session_key, + protocol.ext_x_preload_hint: _parse_preload_hint, + protocol.ext_x_daterange: _parse_daterange, + protocol.ext_x_gap: _parse_gap, + protocol.ext_x_content_steering: _parse_content_steering, + protocol.ext_x_image_stream_inf: _parse_image_stream_inf, + protocol.ext_x_images_only: _parse_is_images_only, + protocol.ext_x_tiles: _parse_tiles, + protocol.ext_x_blackout: _parse_blackout, +} diff --git a/m3u8/protocol.py b/m3u8/protocol.py index 29c53d93..cf697f8d 100644 --- a/m3u8/protocol.py +++ b/m3u8/protocol.py @@ -43,3 +43,4 @@ ext_x_image_stream_inf = "#EXT-X-IMAGE-STREAM-INF" ext_x_images_only = "#EXT-X-IMAGES-ONLY" ext_x_tiles = "#EXT-X-TILES" +ext_x_blackout = "#EXT-X-BLACKOUT" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..b13e4da7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "m3u8" +version = "6.3.0" +description = "Python m3u8 parser" +readme = "README.md" +license = {text = "MIT"} +authors = [ + { name = "Globo.com" } +] +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", +] +dependencies = [ + "backports-datetime-fromisoformat; python_version < '3.11'", +] + +[project.urls] +Homepage = "https://github.com/openwurl/m3u8" +Repository = "https://github.com/openwurl/m3u8" + +[project.optional-dependencies] +dev = [ + "bottle", + "pytest>=9.0.1", + "pytest-cov>=7.0.0", +] + +[tool.setuptools] +packages = ["m3u8"] +zip-safe = false + +[tool.setuptools.package-data] +"*" = ["*.c"] + +[tool.cibuildwheel] +# Build only cp310 - the abi3 wheel (from py_limited_api=True in setup.py) +# works for all Python 3.10+ versions +build = "cp310-*" +# Skip 32-bit and musllinux +skip = "*-win32 *-manylinux_i686 *musllinux*" +# Archs are specified per-runner in the GitHub Actions workflow + +[tool.cibuildwheel.linux] +manylinux-x86_64-image = "manylinux2014" +manylinux-aarch64-image = "manylinux2014" diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 56806d35..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements.txt -bottle -pytest -# pytest-cov 2.6.0 has increased the version requirement -# for the coverage package from >=3.7.1 to >=4.4, -# which is in conflict with the version requirement -# defined by the python-coveralls package for coverage==4.0.3 -pytest-cov>=2.4.0,<2.6 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c785d132..00000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -backports-datetime-fromisoformat; python_version < '3.11' diff --git a/runtests b/runtests index 7d55bd76..1f3fa948 100755 --- a/runtests +++ b/runtests @@ -3,7 +3,7 @@ test_server_stdout=tests/server.stdout function install_deps { - pip install -r requirements-dev.txt + pip install -e ".[dev]" } function start_server { @@ -17,7 +17,7 @@ function stop_server { } function run { - PYTHONPATH=. py.test -vv --cov-report term-missing --cov m3u8 tests/ + py.test -vv --cov-report term-missing --cov m3u8 tests/ } function main { diff --git a/setup.py b/setup.py index 8ec1ff96..ab1ef2c4 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,41 @@ -from os.path import abspath, dirname, exists, join - -from setuptools import setup - -long_description = None -if exists("README.md"): - with open("README.md") as file: - long_description = file.read() - -install_reqs = [ - req for req in open(abspath(join(dirname(__file__), "requirements.txt"))) -] - -setup( - name="m3u8", - author="Globo.com", - version="6.0.0", - license="MIT", - zip_safe=False, - include_package_data=True, - install_requires=install_reqs, - packages=["m3u8"], - url="https://github.com/globocom/m3u8", - description="Python m3u8 parser", - long_description=long_description, - long_description_content_type="text/markdown", - python_requires=">=3.9", -) +""" +Minimal setup.py for C extension configuration. + +All package metadata is in pyproject.toml. This file only provides the +C extension definition, which requires conditional logic that can't be +expressed declaratively. +""" + +import os +import sys + +from setuptools import Extension, setup + +# Optional C extension for faster parsing +# Only build on Mac and Linux, and allow skipping via environment variable +ext_modules = [] + +# For a single wheel across CPython minor versions, build against the stable ABI +# (abi3). Since python_requires is >=3.10, we can target the 3.10 limited API. +PY_LIMITED_API = 0x030A0000 + +# Check if we should build the C extension +if ( + sys.platform in ("darwin", "linux") + and os.environ.get("M3U8_NO_C_EXTENSION", "") != "1" +): + # When building wheels (cibuildwheel sets this), require the extension + # For local editable installs, keep it optional so pure-Python fallback works + is_wheel_build = "CIBUILDWHEEL" in os.environ + + ext_modules.append( + Extension( + "m3u8._m3u8_parser", + sources=["m3u8/_m3u8_parser.c"], + optional=not is_wheel_build, # Required for wheels, optional otherwise + py_limited_api=True, + define_macros=[("Py_LIMITED_API", PY_LIMITED_API)], + ) + ) + +setup(ext_modules=ext_modules) diff --git a/tests/playlists.py b/tests/playlists.py index b2435d2e..551f70be 100755 --- a/tests/playlists.py +++ b/tests/playlists.py @@ -746,6 +746,7 @@ #EXTINF:10.000, master2500_47231.ts #EXT-X-CUE-OUT-CONT:ElapsedTime=47.960,Duration=50,SCTE35=/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg== +#EXT-OATCLS-SCTE35:/DA2AAAAAAAAAP/wBQb//vOoWQAgAh5DVUVJbpFcxn+/DA9XVVJMRDAwMDAwMzU4NTkQAAA5VeXi #EXTINF:2.040, master2500_47232.ts #EXT-X-CUE-IN @@ -773,6 +774,32 @@ playlist_192k_266922.ts """ +OATCLS_EXTENDED_PLAYLIST = """ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:8 +#EXT-X-MEDIA-SEQUENCE:483692 +#EXTINF:4.47113, +segment_483692.ts +#EXT-OATCLS-SCTE35:/DAlAAAAAAAAAP/wFAUbus/gf+/+UsdkQv4ApPWwAAEBAQAAj5YQOw== +#EXT-X-CUE-OUT:120.120 +#EXTINF:1.53487, +segment_483693.ts +#EXT-X-CUE-OUT-CONT:ElapsedTime=1.535,Duration=120.120,SCTE35=/DAlAAAAAAAAAP/wFAUbus/gf+/+UsdkQv4ApPWwAAEBAQAAj5YQOw== +#EXTINF:6.00600, +segment_483694.ts +#EXT-X-CUE-OUT-CONT:ElapsedTime=115.649,Duration=120.120,SCTE35=/DAlAAAAAAAAAP/wFAUbus/gf+/+UsdkQv4ApPWwAAEBAQAAj5YQOw== +#EXTINF:4.47113, +segment_483695.ts +#EXT-X-CUE-IN +#EXT-OATCLS-SCTE35:/DA7AAAAAAAAAP/wBQb+U2xZ8gAlAiNDVUVJ92lrqX+/DBRBTUNOQU1DTlZSMDAwMDA2NjYwMhAAAJi60Tw= +#EXTINF:1.53487, +segment_483695.ts +#EXT-X-PROGRAM-DATE-TIME:2025-08-26T21:13:21.920Z +#EXTINF:6.00600, +segment_483696.ts +""" + CUE_OUT_CONT_ALT_PLAYLIST = """ #EXTM3U #EXT-X-VERSION:3 @@ -960,6 +987,16 @@ segment3.mp4 """ +EMPTY_MAP_TAG_PLAYLIST = """#EXTM3U +#EXT-X-TARGETDURATION:6 +#EXT-X-VERSION:7 +#EXT-X-MAP: +#EXTINF:5, +segment1.mp4 +#EXTINF:5, +segment2.mp4 +""" + MEDIA_WITHOUT_URI_PLAYLIST = """#EXTM3U #EXT-X-VERSION:4 #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-aacl-312",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2" @@ -1587,4 +1624,32 @@ C:\HLS Video\test1.ts """ +BLACKOUT_SIMPLE_PLAYLIST = """ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXTINF:9.9, +segment0.ts +#EXT-X-BLACKOUT +#EXTINF:9.9, +segment1.ts +#EXTINF:9.9, +segment2.ts +#EXT-X-ENDLIST +""" + +BLACKOUT_WITH_PARAMS_PLAYLIST = """ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:10 +#EXTINF:9.9, +segment0.ts +#EXT-X-BLACKOUT:ID=12345,DURATION=30.0 +#EXTINF:9.9, +segment1.ts +#EXTINF:9.9, +segment2.ts +#EXT-X-ENDLIST +""" + del abspath, dirname, join diff --git a/tests/test_c_extension_parity.py b/tests/test_c_extension_parity.py new file mode 100644 index 00000000..e3bc6135 --- /dev/null +++ b/tests/test_c_extension_parity.py @@ -0,0 +1,107 @@ +import pytest + +import m3u8.parser as py_parser + + +try: + import m3u8._m3u8_parser as c_parser +except ImportError: # pragma: no cover + c_parser = None + + +pytestmark = pytest.mark.skipif(c_parser is None, reason="C extension not built") + + +def test_custom_tags_parser_truthiness_in_strict_mode(): + content = "\n".join( + [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:8", + "#EXT-X-CUSTOM-TAG:foo=bar", + "#EXTINF:8,", + "file.ts", + "#EXT-X-ENDLIST", + ] + ) + + def custom_tags_parser(line, lineno, data, state): + if line.startswith("#EXT-X-CUSTOM-TAG"): + # Truthy but not literally True; parser.py treats this as handled. + return 1 + return 0 + + assert py_parser.parse(content, strict=True, custom_tags_parser=custom_tags_parser) + assert c_parser.parse(content, strict=True, custom_tags_parser=custom_tags_parser) + + +def test_custom_tags_parser_exceptions_propagate(): + content = "\n".join( + [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:8", + "#EXT-X-CUSTOM-TAG:foo=bar", + "#EXTINF:8,", + "file.ts", + "#EXT-X-ENDLIST", + ] + ) + + def custom_tags_parser(line, lineno, data, state): + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + py_parser.parse(content, strict=False, custom_tags_parser=custom_tags_parser) + + with pytest.raises(RuntimeError): + c_parser.parse(content, strict=False, custom_tags_parser=custom_tags_parser) + + +def test_single_quoted_attribute_values_match_python(): + content = "\n".join( + [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:8", + "#EXT-X-KEY:METHOD=AES-128,URI='https://example.com/key'", + "#EXTINF:8,", + "file.ts", + "#EXT-X-ENDLIST", + ] + ) + + py = py_parser.parse(content) + c = c_parser.parse(content) + + assert py["keys"][0]["uri"] == "https://example.com/key" + assert c["keys"][0]["uri"] == "https://example.com/key" + + +def test_strict_version_matching_line_numbers_preserve_blank_lines(): + # Version 2 + floating-point EXTINF should error under version matching rules. + # The blank line here must be preserved for consistent line_number reporting. + content = "\n".join( + [ + "#EXTM3U", + "#EXT-X-VERSION:2", + "", + "#EXTINF:2.5,", + "file.ts", + "#EXT-X-ENDLIST", + ] + ) + + with pytest.raises(Exception) as py_exc: + py_parser.parse(content, strict=True) + with pytest.raises(Exception) as c_exc: + c_parser.parse(content, strict=True) + + py_errors = py_exc.value.args[0] + c_errors = c_exc.value.args[0] + + assert isinstance(py_errors, list) and py_errors + assert isinstance(c_errors, list) and c_errors + + assert py_errors[0].line_number == c_errors[0].line_number + assert py_errors[0].line == c_errors[0].line diff --git a/tests/test_model.py b/tests/test_model.py index da47a483..f34b7205 100755 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -180,17 +180,6 @@ def test_segment_cue_out_cont_attributes_dumps(): assert expected in result -def test_segment_oatcls_scte35_cue_out_dumps(): - obj = m3u8.M3U8(playlists.CUE_OUT_ELEMENTAL_PLAYLIST) - result = obj.dumps() - - # Check OATCLS-SCTE35 for CUE-OUT lines - cue_out_line = ( - "#EXT-OATCLS-SCTE35:/DAlAAAAAAAAAP/wFAUAAAABf+//wpiQkv4ARKogAAEBAQAAQ6sodg==\n" - ) - assert result.count(cue_out_line) == 1 - - def test_segment_oatcls_scte35_non_cue_out_dumps(): obj = m3u8.M3U8(playlists.OATCLS_ELEMENTAL_PLAYLIST) result = obj.dumps() @@ -200,6 +189,24 @@ def test_segment_oatcls_scte35_non_cue_out_dumps(): assert result.count(cue_out_line) == 1 +def test_oatcls_extended(): + obj = m3u8.M3U8(playlists.OATCLS_EXTENDED_PLAYLIST) + + actual = [] + for i, line in enumerate(obj.dumps().splitlines()): + if line.startswith("#EXT-OATCLS-SCTE35:"): + actual.append((i, line[19:])) + + expected = [ + (6, "/DAlAAAAAAAAAP/wFAUbus/gf+/+UsdkQv4ApPWwAAEBAQAAj5YQOw=="), + ( + 17, + "/DA7AAAAAAAAAP/wBQb+U2xZ8gAlAiNDVUVJ92lrqX+/DBRBTUNOQU1DTlZSMDAwMDA2NjYwMhAAAJi60Tw=", + ), + ] + assert actual == expected + + def test_segment_cue_out_start_dumps(): obj = m3u8.M3U8(playlists.CUE_OUT_WITH_DURATION_PLAYLIST) @@ -890,6 +897,14 @@ def test_multiple_map_attributes(): assert obj.segments[2].init_section.uri == "init3.mp4" +def test_empty_map_tag(): + obj = m3u8.M3U8(playlists.EMPTY_MAP_TAG_PLAYLIST) + + # Empty MAP tag should not cause errors and segments should have no init_section + assert obj.segments[0].init_section is None + assert obj.segments[1].init_section is None + + def test_dump_should_include_multiple_map_attributes(tmpdir): obj = m3u8.M3U8(playlists.MULTIPLE_MAP_URI_PLAYLIST) @@ -1564,6 +1579,42 @@ def test_gap_in_parts(): assert result == expected +def test_add_blackout_simple(): + obj = m3u8.Segment(uri="segment.ts", duration=4, blackout=True) + + result = str(obj) + expected = "#EXT-X-BLACKOUT\n#EXTINF:4,\nsegment.ts" + + assert result == expected + + +def test_add_blackout_with_params(): + obj = m3u8.Segment(uri="segment.ts", duration=4, blackout="ID=12345,DURATION=30.0") + + result = str(obj) + expected = "#EXT-X-BLACKOUT:ID=12345,DURATION=30.0\n#EXTINF:4,\nsegment.ts" + + assert result == expected + + +def test_blackout_simple(): + obj = m3u8.M3U8(playlists.BLACKOUT_SIMPLE_PLAYLIST) + + result = obj.dumps().strip() + expected = playlists.BLACKOUT_SIMPLE_PLAYLIST.strip() + + assert result == expected + + +def test_blackout_with_params(): + obj = m3u8.M3U8(playlists.BLACKOUT_WITH_PARAMS_PLAYLIST) + + result = obj.dumps().strip() + expected = playlists.BLACKOUT_WITH_PARAMS_PLAYLIST.strip() + + assert result == expected + + def test_skip_dateranges(): obj = m3u8.M3U8(playlists.DELTA_UPDATE_SKIP_DATERANGES_PLAYLIST) diff --git a/tests/test_parser.py b/tests/test_parser.py index dae6f64b..2bb796f8 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -407,9 +407,10 @@ def test_should_parse_scte35_from_playlist(): expected_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] assert actual_scte35 == expected_scte35 - # oatcls_scte35 should be maintained from [EXT-X-CUE-OUT, EXT-X-CUE-IN] + # oatcls_scte35 should be maintained until it changes + oat = "/DA2AAAAAAAAAP/wBQb//vOoWQAgAh5DVUVJbpFcxn+/DA9XVVJMRDAwMDAwMzU4NTkQAAA5VeXi" actual_oatcls_scte35 = [s["oatcls_scte35"] for s in data["segments"]] - expected_oatcls_scte35 = [None, None, None, cue, cue, cue, cue, cue, cue, cue, None] + expected_oatcls_scte35 = [None, None, None, cue, cue, cue, cue, cue, oat, oat, None] assert actual_oatcls_scte35 == expected_oatcls_scte35 # durations should be maintained from from [EXT-X-CUE-OUT, EXT-X-CUE-IN] @@ -506,6 +507,16 @@ def test_should_parse_multiple_map_attributes(): assert data["segments"][2]["init_section"]["uri"] == "init3.mp4" +def test_should_parse_empty_map_tag(): + data = m3u8.parse(playlists.EMPTY_MAP_TAG_PLAYLIST) + + # Empty MAP tag should result in an empty dict in segment_map + assert data["segment_map"] == [{}] + # But segments should not have init_section set (empty dict is falsy) + assert "init_section" not in data["segments"][0] + assert "init_section" not in data["segments"][1] + + def test_should_parse_empty_uri_with_base_path(): data = m3u8.M3U8( playlists.MEDIA_WITHOUT_URI_PLAYLIST, base_path="base_path", base_uri="base_uri" @@ -789,6 +800,22 @@ def test_gap_in_parts(): assert data["segments"][0]["parts"][2].get("gap", None) is None +def test_blackout_simple(): + data = m3u8.parse(playlists.BLACKOUT_SIMPLE_PLAYLIST) + + assert data["segments"][0]["blackout"] is None + assert data["segments"][1]["blackout"] is True + assert data["segments"][2]["blackout"] is None + + +def test_blackout_with_params(): + data = m3u8.parse(playlists.BLACKOUT_WITH_PARAMS_PLAYLIST) + + assert data["segments"][0]["blackout"] is None + assert data["segments"][1]["blackout"] == "ID=12345,DURATION=30.0" + assert data["segments"][2]["blackout"] is None + + def test_should_parse_variant_playlist_with_iframe_with_average_bandwidth(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH) iframe_playlists = list(data["iframe_playlists"])