├── .dockerignore
├── .editorconfig
├── .github
├── FUNDING.yml
└── workflows
│ └── release.yml
├── .gitignore
├── .gitmodules
├── Dockerfile
├── Dockerfile.dev
├── LICENSE
├── README.md
├── assets
└── help.svg
├── bin
└── m4b-tool.php
├── box.json
├── build
├── build.cmd
├── composer-env.nix
├── composer.json
├── composer.lock
├── composer.nix
├── data
└── .gitkeep
├── default.nix
├── deploy-homepage.sh
├── dist
└── .gitkeep
├── doc
├── dev-notes.md
└── release
│ ├── release-notes-v0.5.0.md
│ ├── release-notes-v0.5.1.md
│ └── release-notes-v0.5.2.md
├── docker-compose.yml
├── flake.lock
├── flake.nix
├── homepage
├── favicon.ico
└── index.html
├── php-packages.nix
├── phpunit.xml
├── release-latest.sh
├── release.sh
├── src
└── library
│ ├── Audio
│ ├── AbstractPart.php
│ ├── BinaryWrapper.php
│ ├── Chapter.php
│ ├── ChapterCollection.php
│ ├── ChapterGroup.php
│ ├── CueSheet.php
│ ├── EmbeddedCover.php
│ ├── EpubChapter.php
│ ├── ItunesMediaType.php
│ ├── OptionNameTagPropertyMapper.php
│ ├── Silence.php
│ ├── Tag.php
│ ├── Tag
│ │ ├── AbstractJsonTagImprover.php
│ │ ├── AbstractTagImprover.php
│ │ ├── AdjustChaptersByGroupLogic.php
│ │ ├── AdjustTooLongChapters.php
│ │ ├── AdjustTooShortChapters.php
│ │ ├── AudibleChaptersJson.php
│ │ ├── AudibleJson.php
│ │ ├── AudibleTxt.php
│ │ ├── BookBeatJson.php
│ │ ├── BuchhandelJson.php
│ │ ├── BuecherHtml.php
│ │ ├── ChaptersFromEpub.php
│ │ ├── ChaptersFromFileTracks.php
│ │ ├── ChaptersFromMusicBrainz.php
│ │ ├── ChaptersFromOverdrive.php
│ │ ├── ChaptersTxt.php
│ │ ├── ContentMetadataJson.php
│ │ ├── Cover.php
│ │ ├── Description.php
│ │ ├── Equate.php
│ │ ├── Ffmetadata.php
│ │ ├── GuessChaptersBySilence.php
│ │ ├── InputOptions.php
│ │ ├── IntroOutroChapters.php
│ │ ├── M4bToolJson.php
│ │ ├── MergeSubChapters.php
│ │ ├── MetadataJson.php
│ │ ├── OpenPackagingFormat.php
│ │ ├── RemoveDuplicateFollowUpChapters.php
│ │ ├── TagImproverComposite.php
│ │ ├── TagImproverInterface.php
│ │ ├── TagInterface.php
│ │ ├── TagReaderInterface.php
│ │ └── TagWriterInterface.php
│ └── Traits
│ │ ├── CacheAdapterTrait.php
│ │ └── LogTrait.php
│ ├── Chapter
│ ├── ChapterGroup
│ │ ├── ChapterGroupBuilder.php
│ │ ├── ChapterIndexer.php
│ │ └── ChapterLengthCalculator.php
│ ├── ChapterHandler.php
│ ├── ChapterMarker.php
│ ├── ChapterShifter.php
│ ├── ChapterTitleBuilder.php
│ └── MetaReaderInterface.php
│ ├── Command
│ ├── AbstractCommand.php
│ ├── AbstractConversionCommand.php
│ ├── AbstractMetadataCommand.php
│ ├── ChaptersCommand.php
│ ├── MergeCommand.php
│ ├── MetaCommand.php
│ ├── Plugins
│ │ └── ExtraCommand.php
│ └── SplitCommand.php
│ ├── Common
│ ├── AbstractTagDateTime.php
│ ├── ConditionalFlags.php
│ ├── Flags.php
│ ├── PurchaseDateTime.php
│ ├── ReleaseDate.php
│ └── TaggingFlags.php
│ ├── Executables
│ ├── AbstractExecutable.php
│ ├── AbstractFfmpegBasedExecutable.php
│ ├── AbstractMp4v2Executable.php
│ ├── DurationDetectorInterface.php
│ ├── Fdkaac.php
│ ├── Ffmpeg.php
│ ├── FileConverterInterface.php
│ ├── FileConverterOptions.php
│ ├── Mp4art.php
│ ├── Mp4chaps.php
│ ├── Mp4info.php
│ ├── Mp4tags.php
│ ├── Mp4v2Wrapper.php
│ ├── Process.php
│ ├── Tasks
│ │ ├── AbstractTask.php
│ │ ├── ConversionTask.php
│ │ ├── Pool.php
│ │ └── RunnableInterface.php
│ └── Tone.php
│ ├── Filesystem
│ ├── DirectoryLoader.php
│ └── FileLoader.php
│ ├── Parser
│ ├── EpubParser.php
│ ├── FfmetaDataParser.php
│ ├── IndexStringParser.php
│ ├── MusicBrainzChapterParser.php
│ └── SilenceParser.php
│ ├── StringUtilities
│ └── Scanner.php
│ └── Tags
│ └── StringBuffer.php
├── tests
└── M4bTool
│ ├── Audio
│ ├── ItunesMediaTypeTest.php
│ └── Tag
│ │ ├── AdjustChaptersByGroupLogicTest.php
│ │ ├── BookBeatJsonTest.json
│ │ ├── BookBeatJsonTest.php
│ │ ├── BuchhandelJsonTest.php
│ │ ├── BuecherHtmlTest.php
│ │ ├── ContentMetadataJsonTest.php
│ │ ├── EquateTest.php
│ │ └── OpenPackagingFormatTest.php
│ ├── Chapter
│ ├── ChapterHandlerTest.php
│ ├── ChapterMarkerTest.php
│ └── ChapterShifterTest.php
│ ├── Common
│ └── FlagsTest.php
│ ├── Executables
│ ├── AbstractMp4v2ExecutableTest.php
│ ├── FfmpegTest.php
│ ├── Mp4chapsTest.php
│ └── Mp4infoTest.php
│ ├── Filesystem
│ └── DirectoryLoaderTest.php
│ ├── Parser
│ ├── FfmetaDataParserTest.php
│ ├── IndexStringParserTest.php
│ ├── MusicBrainzChapterParserTest.php
│ └── SilenceParserTest.php
│ ├── StringUtilities
│ └── ScannerTest.php
│ └── Tags
│ └── StringBufferTest.php
├── tools
├── build-homepage-template.html
└── build-homepage.php
└── unrelease.sh
/.dockerignore:
--------------------------------------------------------------------------------
1 | *
2 | !Dockerfile
3 | !dist/m4b-tool.phar
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | # Unix-style newlines with a newline ending every file
7 | [*]
8 | charset = utf-8
9 | end_of_line = lf
10 | indent_style = space
11 | indent_size = 4
12 | insert_final_newline = true
13 | trim_trailing_whitespace = true
14 |
15 | # ReST-Files
16 | [*.rst]
17 | indent_size = 3
18 |
19 | # YAML-Files
20 | [*.{yaml,yml}]
21 | indent_size = 2
22 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: sandreas
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by .ignore support plugin (hsz.mobi)
2 | nbproject/
3 | .idea/
4 | .env
5 | .composer-alias
6 | .credentials
7 | .phpunit.result.cache
8 | _experiments
9 | dist/*
10 | !dist/.gitkeep
11 | data/*
12 | !data/.gitkeep
13 | vendor/
14 | vendor-bin/
15 | src/library/M4bTool/Command/Plugins/
16 | .DS_Store
17 | fummel.php
18 | m4b-tool_debug.log
19 | nbproject/private/
20 |
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "homebrew-tap"]
2 | path = homebrew-tap
3 | url = https://github.com/sandreas/homebrew-tap.git
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM sandreas/ffmpeg:5.0.1-3 as ffmpeg
2 | FROM sandreas/tone:v0.2.5 as tone
3 | FROM sandreas/mp4v2:2.1.1 as mp4v2
4 | FROM sandreas/fdkaac:2.0.1 as fdkaac
5 | FROM alpine:3.20.3
6 | ENV WORKDIR /mnt/
7 | ENV M4BTOOL_TMP_DIR /tmp/m4b-tool/
8 |
9 |
10 | RUN echo "---- INSTALL RUNTIME PACKAGES ----" && \
11 | apk add --no-cache --update --upgrade \
12 | # mp4v2: required libraries
13 | libstdc++ \
14 | # m4b-tool: php cli, required extensions and php settings
15 | php83-cli \
16 | php83-curl \
17 | php83-dom \
18 | php83-xml \
19 | php83-mbstring \
20 | php83-openssl \
21 | php83-phar \
22 | php83-simplexml \
23 | php83-tokenizer \
24 | php83-xmlwriter \
25 | php83-zip \
26 | && echo "date.timezone = UTC" >> /etc/php83/php.ini \
27 | && ln -s /usr/bin/php83 /bin/php
28 |
29 |
30 |
31 | COPY --from=ffmpeg /usr/local/bin/ff* /usr/local/bin/
32 | COPY --from=tone /usr/local/bin/tone /usr/local/bin/
33 | COPY --from=mp4v2 /usr/local/bin/mp4* /usr/local/bin/
34 | COPY --from=mp4v2 /usr/local/lib/libmp4v2* /usr/local/lib/
35 | COPY --from=fdkaac /usr/local/bin/fdkaac /usr/local/bin/
36 |
37 | ARG M4B_TOOL_DOWNLOAD_LINK="https://github.com/sandreas/m4b-tool/releases/latest/download/m4b-tool.tar.gz"
38 |
39 | # workaround to copy a local m4b-tool.phar IF it exists
40 | ADD ./Dockerfile ./dist/m4b-tool.phar* /tmp/
41 | RUN echo "---- INSTALL M4B-TOOL ----" \
42 | && if [ ! -f /tmp/m4b-tool.phar ]; then \
43 | echo "!!! DOWNLOADING ${M4B_TOOL_DOWNLOAD_LINK} !!!" && wget "${M4B_TOOL_DOWNLOAD_LINK}" -O /tmp/m4b-tool.tar.gz && \
44 | if [ ! -f /tmp/m4b-tool.phar ]; then \
45 | tar xzf /tmp/m4b-tool.tar.gz -C /tmp/ && rm /tmp/m4b-tool.tar.gz ;\
46 | fi \
47 | fi \
48 | && mv /tmp/m4b-tool.phar /usr/local/bin/m4b-tool \
49 | && chmod +x /usr/local/bin/m4b-tool
50 |
51 | WORKDIR ${WORKDIR}
52 | CMD ["list"]
53 | ENTRYPOINT ["m4b-tool"]
54 |
--------------------------------------------------------------------------------
/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | ##############################
2 | #
3 | # m4b-tool build image
4 | #
5 | ##############################
6 | FROM alpine:3.9.2 as build
7 | RUN echo "---- INSTALL BUILD DEPENDENCIES ----" \
8 | && echo http://dl-cdn.alpinelinux.org/alpine/edge/testing >> /etc/apk/repositories \
9 | && apk add --no-cache --update --upgrade --virtual=build-dependencies \
10 | autoconf \
11 | automake \
12 | boost-dev \
13 | build-base \
14 | gcc \
15 | git \
16 | tar \
17 | fdk-aac-dev \
18 | wget && \
19 | echo "---- COMPILE SANDREAS MP4V2 (for sort-title, sort-album and sort-author) ----" \
20 | && cd /tmp/ \
21 | && wget https://github.com/sandreas/mp4v2/archive/master.zip \
22 | && unzip master.zip \
23 | && cd mp4v2-master \
24 | && ./configure && \
25 | make -j4 && \
26 | make install && make distclean && \
27 | echo "---- COMPILE FDKAAC ENCODER (executable binary for usage of --audio-profile) ----" \
28 | && cd /tmp/ \
29 | && wget https://github.com/nu774/fdkaac/archive/1.0.0.tar.gz \
30 | && tar xzf 1.0.0.tar.gz \
31 | && cd fdkaac-1.0.0 \
32 | && autoreconf -i && ./configure && make -j4 && make install && rm -rf /tmp/* && \
33 | echo "---- REMOVE BUILD DEPENDENCIES (to keep image small) ----" \
34 | && apk del --purge build-dependencies && rm -rf /tmp/*
35 |
36 | ##############################
37 | #
38 | # m4b-tool development image
39 | #
40 | ##############################
41 | FROM alpine:3.9.2
42 |
43 | ENV WORKDIR /m4b-tool
44 | ARG M4B_TOOL_DOWNLOAD_LINK="https://github.com/sandreas/m4b-tool/releases/latest/download/m4b-tool.phar"
45 |
46 | RUN echo "---- INSTALL RUNTIME PACKAGES ----" && \
47 | apk add --no-cache --update --upgrade \
48 | # mp4v2: required libraries
49 | libstdc++ \
50 | # m4b-tool: php cli, required extensions and php settings
51 | php7-cli \
52 | php7-dom \
53 | php7-json \
54 | php7-xml \
55 | php7-mbstring \
56 | php7-phar \
57 | php7-tokenizer \
58 | php7-xmlwriter \
59 | && echo "date.timezone = UTC" >> /etc/php7/php.ini \
60 | # fdkaac: required libaries
61 | && echo http://dl-cdn.alpinelinux.org/alpine/edge/testing >> /etc/apk/repositories \
62 | && apk add --no-cache --update --upgrade fdk-aac-dev
63 |
64 |
65 | # copy ffmpeg static with libfdk from mwader docker image
66 | COPY --from=mwader/static-ffmpeg:4.1.3-1 /ffmpeg /usr/local/bin/
67 |
68 | # copy compiled mp4v2 binaries, libs and fdkaac encoder to runtime image
69 | COPY --from=build /usr/local/bin/mp4* /usr/local/bin/fdkaac /usr/local/bin/
70 | COPY --from=build /usr/local/lib/libmp4v2* /usr/local/lib/
71 |
72 |
73 |
74 | WORKDIR ${WORKDIR}
75 | # CMD ["list"]
76 | ENTRYPOINT ["/bin/ash"]
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 sandreas
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/bin/m4b-tool.php:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | addCommands($commands);
57 |
58 | $application->run();
59 | } catch (Exception $e) {
60 | echo "uncaught exception: " . $e->getMessage();
61 | }
62 |
--------------------------------------------------------------------------------
/box.json:
--------------------------------------------------------------------------------
1 | {
2 | "chmod": "0755",
3 | "directories": [
4 | "src"
5 | ],
6 | "files": [
7 | "LICENSE",
8 | "vendor/autoload.php"
9 | ],
10 | "directories-bin": [
11 | "vendor/symfony/console/Resources/bin"
12 | ],
13 | "finder": [
14 | {
15 | "name": "*.php",
16 | "exclude": [
17 | ".git",
18 | "tests",
19 | "Tests",
20 | "examples",
21 | "docs",
22 | ".github",
23 | "*.md",
24 | "*.txt",
25 | "*.json",
26 | "lywzx/php-epub/example"
27 | ],
28 | "in": [
29 | "vendor/bluemoehre",
30 | "vendor/composer",
31 | "vendor/doctrine",
32 | "vendor/lywzx",
33 | "vendor/monolog",
34 | "vendor/psr",
35 | "vendor/sandreas",
36 | "vendor/symfony",
37 | "vendor/twig",
38 | "vendor/wapmorgan"
39 | ]
40 | }
41 | ],
42 | "git-version": "package_version",
43 | "main": "bin/m4b-tool.php",
44 | "output": "dist/m4b-tool.phar",
45 | "stub": true
46 | }
47 |
--------------------------------------------------------------------------------
/build:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | rm -f dist/m4b-tool* bin/*.log
3 |
4 | if [ "$1" = "--with-plugins" ]; then
5 | echo "!!! WITH PLUGINS !!!";
6 | PHP_CODE="echo '';"
7 | else
8 | PHP_CODE="try {\
9 | \$p = 'phar://'.realpath(__DIR__.'/dist/m4b-tool.phar').'/src/library/Command/Plugins/'; \
10 | \$f = new Phar(\$p);\
11 | foreach(\$f as \$file) {\
12 | unlink(\$file);\
13 | }\
14 | } catch(Throwable \$e) {}";
15 | fi
16 |
17 | [ -e ".composer-alias" ] && COMPOSER_ALIAS=$(xargs < .composer-alias)
18 | [ "$COMPOSER_ALIAS" != "" ] && COMPOSER="$COMPOSER_ALIAS" || COMPOSER="$(which composer)"
19 | if [ "$COMPOSER" = "" ]; then
20 | echo "composer required, but not installed"
21 | exit 1;
22 | fi
23 |
24 | $COMPOSER install --no-dev --optimize-autoloader --ignore-platform-reqs
25 |
26 | # shellcheck disable=SC2164
27 | php -d phar.readonly=off vendor/bin/box compile \
28 | && php -d phar.readonly=off -r "${PHP_CODE}" \
29 | && chmod +x dist/*.phar \
30 | && tar -C dist -czf dist/m4b-tool.tar.gz m4b-tool.phar \
31 | && cd dist \
32 | && zip m4b-tool.zip m4b-tool.phar \
33 | && cd -
34 | $COMPOSER install
35 |
--------------------------------------------------------------------------------
/build.cmd:
--------------------------------------------------------------------------------
1 | @echo off
2 | php -d phar.readonly=off vendor\bin\box compile
3 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sandreas/m4b-tool",
3 | "minimum-stability": "dev",
4 | "prefer-stable": true,
5 | "platform":{"php":"8.2"},
6 | "require": {
7 | "monolog/monolog": "^1.22",
8 | "symfony/console": "^7.0",
9 | "symfony/process": "^7.0",
10 | "symfony/cache": "^7.0",
11 | "twig/twig": "^3.0",
12 | "sandreas/php-time": "dev-master",
13 | "sandreas/php-strings": "dev-master",
14 | "ext-simplexml": "*",
15 | "ext-json": "*",
16 | "ext-mbstring": "*",
17 | "ext-dom": "*",
18 | "ext-libxml": "*",
19 | "ext-zip": "*",
20 | "ext-curl": "*",
21 | "lywzx/php-epub": "^0.1.2",
22 | "erusev/parsedown": "dev-master#cb17b6477dfff935958ba01325f2e8a2bfa6dab3 as 1.7.1",
23 | "doctrine/collections": "^2.0",
24 | "wapmorgan/media-file": "^0.1.4",
25 | "psr/http-message": "^2.0"
26 | },
27 | "autoload": {
28 | "psr-4": {
29 | "M4bTool\\": "src/library/"
30 | }
31 | },
32 | "config": {
33 | "optimize-autoloader": true,
34 | "allow-plugins": {
35 | "bamarni/composer-bin-plugin": true
36 | }
37 | },
38 | "require-dev": {
39 | "mikey179/vfsstream": "^1.6",
40 | "phpunit/phpunit": "^9.5",
41 | "mockery/mockery": "^1.4",
42 | "bamarni/composer-bin-plugin": "^1.8"
43 | },
44 | "repositories": [
45 | {
46 | "type": "vcs",
47 | "url": "git@github.com:sandreas/php-strings.git"
48 | },
49 | {
50 | "type": "vcs",
51 | "url": "git@github.com:sandreas/php-time.git"
52 | }
53 | ]
54 | }
55 |
--------------------------------------------------------------------------------
/composer.nix:
--------------------------------------------------------------------------------
1 | {pkgs ? import {
2 | inherit system;
3 | }, system ? builtins.currentSystem, noDev ? false, php ? pkgs.php, phpPackages ? pkgs.phpPackages}:
4 |
5 | let
6 | composerEnv = import ./composer-env.nix {
7 | inherit (pkgs) stdenv lib writeTextFile fetchurl unzip;
8 | inherit php phpPackages;
9 | };
10 | in
11 | import ./php-packages.nix {
12 | inherit composerEnv noDev;
13 | inherit (pkgs) fetchurl fetchgit fetchhg fetchsvn;
14 | }
15 |
--------------------------------------------------------------------------------
/data/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sandreas/m4b-tool/68f12111e37a589b74ee1b3e2aa73248f4003da3/data/.gitkeep
--------------------------------------------------------------------------------
/default.nix:
--------------------------------------------------------------------------------
1 | { pkgs, lib, stdenv, fetchFromGitHub, fetchurl
2 | , runtimeShell
3 | , php82, php82Packages
4 | , ffmpeg-headless, mp4v2, fdk_aac, fdk-aac-encoder
5 | , useLibfdkFfmpeg ? false
6 | }:
7 |
8 | let
9 | m4bToolPhp = php82.buildEnv {
10 | extensions = ({ enabled, all }: enabled ++ (with all; [
11 | dom mbstring tokenizer xmlwriter openssl
12 | ]));
13 |
14 | extraConfig = ''
15 | date.timezone = UTC
16 | error_reporting = E_ALL & ~E_STRICT & ~E_NOTICE & ~E_DEPRECATED
17 | '';
18 | };
19 |
20 | m4bToolPhpPackages = php82Packages;
21 |
22 | m4bToolComposer = pkgs.callPackage ./composer.nix {
23 | php = m4bToolPhp;
24 | phpPackages = m4bToolPhpPackages;
25 | };
26 |
27 | m4bToolFfmpeg = if useLibfdkFfmpeg then ffmpeg-headless.overrideAttrs (prev: rec {
28 | configureFlags = prev.configureFlags ++ [
29 | "--enable-libfdk-aac"
30 | "--enable-nonfree"
31 | ];
32 | buildInputs = prev.buildInputs ++ [
33 | fdk_aac
34 | ];
35 | }) else ffmpeg-headless;
36 | in
37 | m4bToolComposer.overrideAttrs (prev: rec {
38 | pname = "m4b-tool";
39 | version = "0.5.2";
40 |
41 | buildInputs = [
42 | m4bToolPhp m4bToolFfmpeg mp4v2 fdk-aac-encoder
43 | ];
44 |
45 | nativeBuildInputs = [
46 | m4bToolPhp m4bToolPhpPackages.composer
47 | ];
48 |
49 | postInstall = ''
50 | # Fix the version
51 | sed -i 's!@package_version@!${version}!g' bin/m4b-tool.php
52 | '';
53 |
54 | postFixup = ''
55 | # Wrap it
56 | rm -rf $out/bin
57 | mkdir -p $out/bin
58 |
59 | # makeWrapper fails for this on macOS
60 | cat >$out/bin/m4b-tool </dev/null | wc -c
15 | ```
16 |
17 | ### detect silence
18 |
19 | Detect 3 seconds of silence with -30dB noise tolerance:
20 | ```
21 | ffmpeg -i "input.mov" -af silencedetect=noise=-30dB:d=3 -f null - 2> vol.txt
22 | ```
23 |
24 | ### extract part of m4b without losing quality
25 |
26 | #### skip first 10 seconds
27 | ```
28 | ffmpeg -i data/src.m4b -ss 10 -acodec copy -vn -f mp4 data/dest.m4b
29 | ```
30 |
31 | #### from second 10 to 40
32 | ```
33 | ffmpeg -i "data/src.m4b" -ss 10 -t 30 -acodec copy -vn -f mp4 data/dest.m4b
34 | ffmpeg -i sample.avi -ss 00:03:05 -t 00:00:45.0 -q:a 0 -map a sample.mp3
35 | ```
36 |
37 |
38 | ### Merge files
39 |
40 | ```
41 | ffmpeg -i 01.mp3 -i 02.mp3 -i 03.mp3 -filter_complex "[0:0] [1:0] [2:0] concat=n=3:v=0:a=1 [a]" -map [a] -ab 64k -f mp4 x.m4b
42 | ```
43 |
44 | ### extract metadata
45 | ```
46 | ffmpeg -i data/src.m4b -f ffmetadata metadata.txt
47 | ```
48 |
49 | ### write chapters
50 |
51 | #### chapter file format
52 | Chapter file must contain following format:
53 |
54 | Sample 1:
55 | ```
56 | ;FFMETADATA1
57 | [CHAPTER]
58 | TIMEBASE=1/1000
59 | START=0
60 | #chapter ends at 0:01:00
61 | END=60000
62 | title=chapter \#1
63 | ```
64 |
65 | Sample 2:
66 | ```
67 | ;FFMETADATA1
68 | major_brand=isom
69 | minor_version=512
70 | compatible_brands=isomiso2mp41
71 | title=A title
72 | artist=An Artist
73 | composer=A composer
74 | album=An Album
75 | date=2011
76 | description=A description
77 | comment=A command
78 | encoder=Lavf56.40.101
79 | [CHAPTER]
80 | TIMEBASE=1/1000
81 | START=0
82 | END=264034
83 | title=001
84 | [CHAPTER]
85 | TIMEBASE=1/1000
86 | START=264034
87 | END=568958
88 | title=002
89 | [CHAPTER]
90 | TIMEBASE=1/1000
91 | START=568958
92 | END=879455
93 | title=003
94 | ```
95 | #### Nero AND Quicktime
96 | ```
97 | ffmpeg -i title01.mp4 -i title01.txt -c copy -map_metadata 1 title01m.mp4
98 | ffmpeg -i title01.mp4 -i title01.txt -c copy -map_chapters 1 title01c.mp4
99 | ```
100 |
101 | #### Quicktime only (-movflags disable_chpl)
102 | ```
103 | ffmpeg -i title01.mp4 -i title01.txt -c copy -map_metadata 1 -movflags disable_chpl title01m1.mp4
104 | ffmpeg -i title01.mp4 -i title01.txt -c copy -map_chapters 1 -movflags disable_chpl title01c1.mp4
105 | ```
106 |
107 |
108 |
109 | ### Possible fixes for merge issue
110 | #### auto_convert 1 with mp3 sources
111 | https://trac.ffmpeg.org/ticket/4498
112 |
113 | ffmpeg -auto_convert 1 -f concat -i mylist.txt -c copy out.mp4
114 |
115 |
116 | #### pad audio
117 | -af apad -shortest -avoid_negative_ts make_zero -fflags +genpts
118 | https://stackoverflow.com/questions/35416110/ffmpeg-concat-video-and-audio-out-of-sync
119 |
120 | #### using filter instead of demuxer
121 | ffmpeg -i -i -i ...
122 |
123 | https://video.stackexchange.com/questions/19237/ffmpeg-concat-introduces-a-v-sync-problem
124 |
--------------------------------------------------------------------------------
/doc/release/release-notes-v0.5.0.md:
--------------------------------------------------------------------------------
1 | # Release Notes
2 |
3 | This is the first `m4b-tool` release after 5 years, so expect lots of changes and improvements. The release process is now automated via github actions and therefore versions can be published more regularly.
4 |
5 | The purpose of this `0.5.0` release is to bring `m4b-tool` up to the latest improvements, dependencies and make it compatible with more recent PHP versions.
6 |
7 |
--------------------------------------------------------------------------------
/doc/release/release-notes-v0.5.1.md:
--------------------------------------------------------------------------------
1 | # Release Notes
2 |
3 | Bugfix release.
4 |
5 | ## Fixed
6 |
7 | - #268 - invalid `metadata.json` led to crash
8 |
9 |
--------------------------------------------------------------------------------
/doc/release/release-notes-v0.5.2.md:
--------------------------------------------------------------------------------
1 | # Release Notes
2 |
3 | Update release.
4 |
5 | ## Updates
6 |
7 | - Bumped `tone` to version `0.2.5` in docker image.
8 |
9 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # todo: line 21 change to variable
2 | version: "3"
3 | services:
4 | m4b-tool-dev:
5 | build:
6 | dockerfile: Dockerfile.dev
7 | context: .
8 | volumes:
9 | - .:/m4b-tool
10 | # - ~/.ssh/my-private-key:/root/.ssh/id_rsa:ro
11 | #environment:
12 | #- "SSH_PUBLIC_KEY=${SSH_PUBLIC_KEY}"
13 |
14 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "flake-utils": {
4 | "inputs": {
5 | "systems": "systems"
6 | },
7 | "locked": {
8 | "lastModified": 1731533236,
9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10 | "owner": "numtide",
11 | "repo": "flake-utils",
12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13 | "type": "github"
14 | },
15 | "original": {
16 | "owner": "numtide",
17 | "repo": "flake-utils",
18 | "type": "github"
19 | }
20 | },
21 | "nixpkgs": {
22 | "locked": {
23 | "lastModified": 1746461020,
24 | "narHash": "sha256-7+pG1I9jvxNlmln4YgnlW4o+w0TZX24k688mibiFDUE=",
25 | "owner": "NixOS",
26 | "repo": "nixpkgs",
27 | "rev": "3730d8a308f94996a9ba7c7138ede69c1b9ac4ae",
28 | "type": "github"
29 | },
30 | "original": {
31 | "owner": "NixOS",
32 | "ref": "nixos-unstable",
33 | "repo": "nixpkgs",
34 | "type": "github"
35 | }
36 | },
37 | "root": {
38 | "inputs": {
39 | "flake-utils": "flake-utils",
40 | "nixpkgs": "nixpkgs"
41 | }
42 | },
43 | "systems": {
44 | "locked": {
45 | "lastModified": 1681028828,
46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47 | "owner": "nix-systems",
48 | "repo": "default",
49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50 | "type": "github"
51 | },
52 | "original": {
53 | "owner": "nix-systems",
54 | "repo": "default",
55 | "type": "github"
56 | }
57 | }
58 | },
59 | "root": "root",
60 | "version": 7
61 | }
62 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "A wrapper for ffmpeg and mp4v2 to merge, split, and manipulate audiobooks";
3 |
4 | inputs = {
5 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6 | flake-utils.url = "github:numtide/flake-utils";
7 | };
8 |
9 | outputs = { self, nixpkgs, flake-utils }:
10 | {
11 | overlay = nixpkgs.lib.composeManyExtensions [
12 | (final: prev: {
13 | m4b-tool = final.callPackage ./default.nix {};
14 | })
15 | ];
16 | } // (flake-utils.lib.eachDefaultSystem (system:
17 | let
18 | pkgs = import nixpkgs {
19 | inherit system;
20 | overlays = [ self.overlay ];
21 | };
22 |
23 | composer2NixSrc = pkgs.fetchFromGitHub {
24 | owner = "svanderburg";
25 | repo = "composer2nix";
26 | rev = "v0.0.6";
27 | sha256 = "sha256-P3acfGwHYjjZQcviPiOT7T7qzzP/drc2mibzrsrNP18=";
28 | };
29 |
30 | composer2Nix = import composer2NixSrc {
31 | inherit pkgs system;
32 | };
33 | in
34 | rec {
35 | packages = rec {
36 | m4b-tool = pkgs.m4b-tool;
37 | m4b-tool-libfdk = m4b-tool.override {
38 | useLibfdkFfmpeg = true;
39 | };
40 | default = m4b-tool;
41 | };
42 |
43 | apps = rec {
44 | m4b-tool = flake-utils.lib.mkApp { drv = self.packages.${system}.m4b-tool; };
45 | default = m4b-tool;
46 | };
47 |
48 | devShell = pkgs.mkShell {
49 | buildInputs = [
50 | composer2Nix
51 | ] ++ pkgs.m4b-tool.dependencies ++ pkgs.m4b-tool.devDependencies;
52 | };
53 | }));
54 | }
55 |
--------------------------------------------------------------------------------
/homepage/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sandreas/m4b-tool/68f12111e37a589b74ee1b3e2aa73248f4003da3/homepage/favicon.ico
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | tests/
11 |
12 |
13 |
14 |
15 | tests
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/release-latest.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # https://stackoverflow.com/questions/8044583/how-can-i-move-a-tag-on-a-git-branch-to-a-different-commit
3 | # https://gist.github.com/stefanbuck/ce788fee19ab6eb0b4447a85fc99f447
4 |
5 | git push origin :refs/tags/latest && \
6 | git tag -fa -m 'updated latest tag' latest && \
7 | git push origin master --tags && \
8 | ./build
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | VERSION="$1"
3 | if [ "$VERSION" = "" ]; then
4 | echo "please provide a version as first parameter (e.g. 1.0.0)"
5 | exit 1
6 | fi
7 | git tag -a "v$VERSION" -m "release $VERSION" && git push origin --tags
8 |
--------------------------------------------------------------------------------
/src/library/Audio/AbstractPart.php:
--------------------------------------------------------------------------------
1 | start = $start;
22 | $this->length = $length;
23 | }
24 |
25 | /**
26 | * @return TimeUnit
27 | */
28 | public function getStart()
29 | {
30 | return $this->start;
31 | }
32 |
33 | /**
34 | * @param TimeUnit $start
35 | */
36 | public function setStart(TimeUnit $start)
37 | {
38 | $this->start = $start;
39 | }
40 |
41 | /**
42 | * @return TimeUnit
43 | */
44 | public function getLength()
45 | {
46 | return $this->length;
47 | }
48 |
49 | /**
50 | * @param TimeUnit $length
51 | */
52 | public function setLength(TimeUnit $length)
53 | {
54 | $this->length = $length;
55 | }
56 |
57 | public function setEnd(TimeUnit $end)
58 | {
59 | $this->length = new TimeUnit($end->milliseconds() - $this->start->milliseconds());
60 | }
61 |
62 | /**
63 | * @return TimeUnit
64 | */
65 | public function getEnd()
66 | {
67 | return new TimeUnit($this->getStart()->milliseconds() + $this->getLength()->milliseconds(), TimeUnit::MILLISECOND);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/library/Audio/Chapter.php:
--------------------------------------------------------------------------------
1 | name = $name;
31 | $this->tag = $tag;
32 | }
33 |
34 | /**
35 | * @return string
36 | */
37 | public function getName() {
38 | return $this->name;
39 | }
40 |
41 | /**
42 | * @param string $name
43 | */
44 | public function setName($name) {
45 | $this->name = $name;
46 | }
47 |
48 | /**
49 | * @return string
50 | */
51 | public function getIntroduction()
52 | {
53 | return $this->introduction;
54 | }
55 |
56 | /**
57 | * @param string $introduction
58 | */
59 | public function setIntroduction($introduction)
60 | {
61 | $this->introduction = $introduction;
62 | }
63 |
64 | public function isIgnored()
65 | {
66 | return false;
67 | }
68 |
69 | /**
70 | * Specify data which should be serialized to JSON
71 | * @link https://php.net/manual/en/jsonserializable.jsonserialize.php
72 | * @return mixed data which can be serialized by json_encode ,
73 | * which is a value of any type other than a resource.
74 | * @since 5.4.0
75 | */
76 | public function jsonSerialize(): array
77 | {
78 |
79 | return array_filter([
80 | "name" => $this->getName(),
81 | "introduction" => $this->getIntroduction(),
82 | "start" => ($this->getStart() instanceof TimeUnit) ? $this->getStart()->milliseconds() : null,
83 | "length" => ($this->getLength() instanceof TimeUnit) ? $this->getLength()->milliseconds() : null,
84 | ], function ($value) {
85 | return $value !== "" && $value !== null;
86 | });
87 | }
88 |
89 | public static function jsonDeserialize(array $chapterAsArray)
90 | {
91 | $chapter = new static(new TimeUnit((int)($chapterAsArray["start"] ?? 0)), new TimeUnit((int)($chapterAsArray["length"] ?? 0)), $chapterAsArray["name"] ?? "");
92 | $chapter->introduction = $chapterAsArray["introduction"] ?? null;
93 | return $chapter;
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/library/Audio/ChapterCollection.php:
--------------------------------------------------------------------------------
1 | unit = static::UNIT_MS;
33 | }
34 |
35 | public function setUnit($unit)
36 | {
37 | $this->unit = $unit;
38 | }
39 |
40 | public function getUnit()
41 | {
42 | return $this->unit;
43 | }
44 |
45 | public function setEan($ean)
46 | {
47 | $this->ean = $ean;
48 | }
49 |
50 | public function getEan()
51 | {
52 | return $this->ean;
53 | }
54 |
55 | public function setAudibleID($audibleID)
56 | {
57 | $this->audibleID = $audibleID;
58 | }
59 |
60 | public function getAudibleID()
61 | {
62 | return $this->audibleID;
63 | }
64 |
65 |
66 | public function setAsin($asin)
67 | {
68 | $this->asin = $asin;
69 | }
70 |
71 | public function getAsin()
72 | {
73 | return $this->asin;
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/library/Audio/ChapterGroup.php:
--------------------------------------------------------------------------------
1 | name = $name;
29 | $this->chapters = $chapters;
30 | parent::__construct(new TimeUnit(), new TimeUnit());
31 | }
32 |
33 | public function addChapter(Chapter $chapter)
34 | {
35 | $this->chapters[] = $chapter;
36 | }
37 |
38 | public function getStart()
39 | {
40 | return isset($this->chapters[0]) ? $this->chapters[0]->getStart() : new TimeUnit();
41 | }
42 |
43 | public function getLength()
44 | {
45 | $lastChapter = end($this->chapters);
46 | $lastEnd = $lastChapter ? $lastChapter->getEnd() : new TimeUnit();
47 | return new TimeUnit($lastEnd->milliseconds() - $this->getStart()->milliseconds());
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/src/library/Audio/EmbeddedCover.php:
--------------------------------------------------------------------------------
1 | "jpeg",
15 | self::FORMAT_PNG => "png",
16 | ];
17 |
18 | /**
19 | * @var int
20 | */
21 | public $imageFormat;
22 | /**
23 | * @var int
24 | */
25 | public $width;
26 | /**
27 | * @var int
28 | */
29 | public $height;
30 |
31 |
32 | /**
33 | *
34 | * @param int $compressionFormat
35 | * @param int $width
36 | * @param int $height
37 | */
38 | public function __construct($compressionFormat = self::FORMAT_UNKNOWN, $width = 0, $height = 0)
39 | {
40 | $this->imageFormat = $compressionFormat;
41 | $this->width = $width;
42 | $this->height = $height;
43 | }
44 |
45 | public function __toString()
46 | {
47 | if ($this->imageFormat === static::FORMAT_UNKNOWN) {
48 | return "";
49 | }
50 | $parts = ["embedded " . static::COMPRESSION_OUTPUT_MAPPING[$this->imageFormat]];
51 | if ($this->width > 0 && $this->height > 0) {
52 | $parts[] = $this->width . "x" . $this->height;
53 | }
54 | return implode(", ", $parts);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/library/Audio/EpubChapter.php:
--------------------------------------------------------------------------------
1 | sizeInBytes;
24 | }
25 |
26 | /**
27 | * @param int $sizeInBytes
28 | */
29 | public function setSizeInBytes($sizeInBytes)
30 | {
31 | $this->sizeInBytes = $sizeInBytes;
32 | }
33 |
34 | /**
35 | * @return string
36 | */
37 | public function getContents()
38 | {
39 | return $this->contents;
40 | }
41 |
42 | /**
43 | * @param string $contents
44 | */
45 | public function setContents($contents)
46 | {
47 | $this->contents = $contents;
48 | }
49 |
50 | /**
51 | * @return bool
52 | */
53 | public function isIgnored()
54 | {
55 | return $this->ignored;
56 | }
57 |
58 | /**
59 | * @param bool $ignored
60 | */
61 | public function setIgnored($ignored)
62 | {
63 | $this->ignored = (bool)$ignored;
64 | }
65 |
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/src/library/Audio/ItunesMediaType.php:
--------------------------------------------------------------------------------
1 | 0,
12 | "Normal" => 1,
13 | "Audiobook" => 2,
14 | "MusicVideo" => 6,
15 | "Movie" => 9,
16 | "TvShow" => 10,
17 | "Booklet"=> 11,
18 | "Ringtone" => 14,
19 | "ItunesU"=> 23];
20 |
21 | public static function parseInt($value): ?int
22 | {
23 | $stringValue = (string)$value;
24 | $intValue = (int)$value;
25 | foreach(static::TYPES as $constantName => $constantValue) {
26 | if(strtolower($stringValue) === strtolower($constantName)) {
27 | return $constantValue;
28 | }
29 |
30 | if(preg_match("/^\d+$/", $stringValue) && $intValue === $constantValue) {
31 | return $constantValue;
32 | }
33 | }
34 | return null;
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/src/library/Audio/OptionNameTagPropertyMapper.php:
--------------------------------------------------------------------------------
1 | "encoder",
11 | "title" => "name",
12 | "sortTitle" => "sortname",
13 | "artist" => "artist",
14 | "sortArtist" => "sortartist",
15 | "genre" => "genre",
16 | "writer" => "writer",
17 | "album" => "album",
18 | "sortAlbum" => "sortalbum",
19 | "disk" => "disk",
20 | "disks" => "disks",
21 | "albumArtist" => "albumartist",
22 | "year" => "year",
23 | // "track" => "",
24 | // "tracks" => "",
25 | "cover" => "cover",
26 | "description" => "description",
27 | "longDescription" => "longdesc",
28 | "comment" => "comment",
29 | "copyright" => "copyright",
30 | "encodedBy" => "encoded-by",
31 | // "type" => "",
32 | // "performer" => "",
33 | // "language" => "",
34 | // "publisher" => "",
35 | // "lyrics" => "",
36 | "series" => "series",
37 | "seriesPart" => "series-part",
38 | ];
39 |
40 |
41 | public function mapOptionToTagProperty(string $optionName): string
42 | {
43 | $propertyName = array_search($optionName, static::TAG_PROPERTY_TO_OPTION_MAPPING, true);
44 | return $propertyName === false ? $optionName : $propertyName;
45 | }
46 |
47 | public function mapTagPropertyToOption(string $propertyName): string
48 | {
49 | return static::TAG_PROPERTY_TO_OPTION_MAPPING[$propertyName] ?? $propertyName;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/library/Audio/Silence.php:
--------------------------------------------------------------------------------
1 | isChapterStart = $chapterStart;
22 | }
23 |
24 | public function isChapterStart(): bool
25 | {
26 | return $this->isChapterStart;
27 | }
28 |
29 |
30 | public function jsonSerialize(): array
31 | {
32 |
33 | return array_filter([
34 | "start" => ($this->getStart() instanceof TimeUnit) ? $this->getStart()->milliseconds() : null,
35 | "length" => ($this->getLength() instanceof TimeUnit) ? $this->getLength()->milliseconds() : null,
36 | ], function ($value) {
37 | return $value !== "" && $value !== null;
38 | });
39 | }
40 |
41 | public static function jsonDeserialize(array $chapterAsArray)
42 | {
43 | return new static(new TimeUnit((int)($chapterAsArray["start"] ?? 0)), new TimeUnit((int)($chapterAsArray["length"] ?? 0)));
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AbstractJsonTagImprover.php:
--------------------------------------------------------------------------------
1 | fileContent = static::stripBOM($fileContents);
13 | }
14 |
15 |
16 | protected function decodeJson($fileContent)
17 | {
18 | if (trim($fileContent) === "") {
19 | $this->info(sprintf("no %s found - tags not improved", static::$defaultFileName));
20 | return null;
21 | }
22 | $decoded = @json_decode($fileContent, true);
23 | if ($decoded === false) {
24 | $this->warning(sprintf("could not decode %s:%s", static::$defaultFileName, json_last_error_msg()));
25 | return null;
26 | }
27 | return $decoded;
28 | }
29 |
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AdjustChaptersByGroupLogic.php:
--------------------------------------------------------------------------------
1 | metaDataHandler = $metaDataHandler;
48 | $this->file = $file instanceof SplFileInfo ? $file : new SplFileInfo($file);
49 | $this->groupBuilder = new ChapterGroupBuilder();
50 | $this->lengthCalc = $lengthCalc;
51 | $this->indexer = new ChapterIndexer($this->groupBuilder, $this->lengthCalc);
52 |
53 | }
54 |
55 | public static function deserialize(array $tagAsArray)
56 | {
57 | $tag = new Tag();
58 | foreach ($tagAsArray as $property => $tagValue) {
59 | if ($property === "chapters") {
60 | $tag->chapters = [];
61 | foreach ($tagValue as $chapterAsArray) {
62 | $tag->chapters[] = Chapter::jsonDeserialize($chapterAsArray);
63 | }
64 | continue;
65 | }
66 | $tag->$property = $tagValue;
67 | }
68 | return $tag;
69 | }
70 |
71 | public static function serialize(Tag $tag)
72 | {
73 | $tagAsArray = [];
74 | foreach ($tag as $property => $value) {
75 | if ($property === "chapters") {
76 | $tagAsArray["chapters"] = [];
77 |
78 | /** @var Chapter $chapter */
79 | foreach ($value as $chapter) {
80 | $tagAsArray["chapters"][] = $chapter->jsonSerialize();
81 | }
82 | continue;
83 | }
84 | $tagAsArray[$property] = $value;
85 | }
86 | return $tagAsArray;
87 | }
88 |
89 | /**
90 | * @param Tag $tag
91 | * @return Tag
92 | * @throws Exception
93 | */
94 | public function improve(Tag $tag): Tag
95 | {
96 | $chapterGroups = $this->groupBuilder->groupByNormalizedName(...$tag->chapters);
97 | $chapterGroups = $this->lengthCalc->recalculateGroups(...$chapterGroups);
98 | $this->indexer->reindex($tag->chapters, ...$chapterGroups);
99 | $chapters = $this->groupBuilder->mergeGroupsToChapters(...$chapterGroups);
100 |
101 | if (count($chapters) > 0) {
102 | $tag->chapters = $chapters;
103 | }
104 | return $tag;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AdjustTooLongChapters.php:
--------------------------------------------------------------------------------
1 | metaDataHandler = $metaDataHandler;
46 | $this->chapterHandler = $chapterHandler;
47 | $this->file = $file;
48 | $this->maxChapterLength = new TimeUnit((int)$maxChapterLengthSeconds, TimeUnit::SECOND);
49 | $this->desiredChapterLength = new TimeUnit((int)$desiredChapterLengthSeconds, TimeUnit::SECOND);
50 | $this->silenceLength = $silenceLength;
51 |
52 | }
53 |
54 | /**
55 | * @param Tag $tag
56 | * @return Tag
57 | * @throws Exception
58 | * @throws InvalidArgumentException
59 | */
60 | public function improve(Tag $tag): Tag
61 | {
62 | // at least one option has to be defined to adjust too long chapters
63 | if ($this->maxChapterLength->milliseconds() === 0 || !is_array($tag->chapters) || count($tag->chapters) === 0) {
64 | $this->info("no chapter length adjustment required (max chapter length not provided or empty chapter list)");
65 | return $tag;
66 | }
67 |
68 | if ($this->maxChapterLength->milliseconds() > 0) {
69 | $this->chapterHandler->setMaxLength($this->maxChapterLength);
70 | $this->chapterHandler->setDesiredLength($this->desiredChapterLength);
71 | }
72 |
73 |
74 | if (!$this->isAdjustmentRequired($tag)) {
75 | $this->info("no chapter length adjustment required (no too long chapters found)");
76 | return $tag;
77 | }
78 | $this->info(sprintf("adjusting %s chapters with max length %s and desired length %s", count($tag->chapters), $this->maxChapterLength->format(), $this->desiredChapterLength->format()));
79 | $totalLength = $this->metaDataHandler->inspectExactDuration($this->file);
80 |
81 | // fix last chapter length (e.g. when using a chapters.txt format where last chapter length is not given)
82 | if ($totalLength !== null) {
83 | $lastChapter = end($tag->chapters);
84 | $lastChapter->setEnd(new TimeUnit($totalLength->milliseconds()));
85 | }
86 |
87 | $silences = $this->metaDataHandler->detectSilences($this->file, $this->silenceLength);
88 | $tag->chapters = $this->chapterHandler->adjustChapters($tag->chapters, $silences);
89 | return $tag;
90 | }
91 |
92 | protected function isAdjustmentRequired(Tag $tag)
93 | {
94 | foreach ($tag->chapters as $chapter) {
95 | if ($chapter->getLength()->milliseconds() > $this->maxChapterLength->milliseconds()) {
96 | return true;
97 | }
98 | }
99 | return false;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AdjustTooShortChapters.php:
--------------------------------------------------------------------------------
1 | minChapterLength = $minChapterLength ?? new TimeUnit(static::DEFAULT_MINLENGTH_MS);
25 | $this->keepIndexes = $keepIndexes ?? [0, -1];
26 | }
27 |
28 | /**
29 | * @param Tag $tag
30 | * @return Tag
31 | * @throws Exception
32 | */
33 | public function improve(Tag $tag): Tag
34 | {
35 | // at least one option has to be defined to adjust too long chapters
36 | if ($this->minChapterLength->milliseconds() === 0 || !is_array($tag->chapters) || count($tag->chapters) === 0) {
37 | $this->info("no too short chapter length adjustment required (max chapter length not provided or empty chapter list)");
38 | return $tag;
39 | }
40 |
41 | if ($this->minChapterLength->milliseconds() < 0) {
42 | $this->minChapterLength = new TimeUnit(3000);
43 | }
44 |
45 |
46 | if (!$this->isAdjustmentRequired($tag)) {
47 | $this->info("no too short chapter length adjustment required (no too long chapters found)");
48 | return $tag;
49 | }
50 | $this->info(sprintf("adjusting too short chapters with min length %s", $this->minChapterLength->format()));
51 |
52 |
53 | $chapters = array_values($tag->chapters);
54 | $chapterCount = count($chapters);
55 | $keepIndexes = [];
56 | foreach ($this->keepIndexes as $key => $value) {
57 | if ($value < 0) {
58 | $keepIndexes [$key] = $chapterCount + $value;
59 | } else {
60 | $keepIndexes [$key] = $value;
61 | }
62 | }
63 |
64 | for ($i = 0; $i < count($chapters); $i++) {
65 | if(in_array($i, $keepIndexes, true)) {
66 | $this->info(sprintf(" -> keep chapter %s (%s)", $chapters[$i]->getName(), $i));
67 | continue;
68 | }
69 |
70 | if ($chapters[$i]->getLength()->milliseconds() > $this->minChapterLength->milliseconds()) {
71 | continue;
72 | }
73 |
74 | if (isset($chapters[$i + 1])) {
75 | $mergedName = $chapters[$i]->getName() . "," . $chapters[$i + 1]->getName();
76 | if (mb_strlen($mergedName > 250)) {
77 | $mergedName = $chapters[$i]->getName();
78 | }
79 | $this->info(sprintf(" -> merged %s (%s) with next chapter %s (%s)", $chapters[$i]->getName(), $i, $chapters[$i + 1]->getName(), $i + 1));
80 |
81 | $chapters[$i]->setName($mergedName);
82 | $chapters[$i]->setEnd($chapters[$i + 1]->getEnd());
83 | unset($chapters[$i + 1]);
84 | $i++;
85 | } else if (isset($chapters[$i - 1])) {
86 | $mergedName = $chapters[$i - 1]->getName() . "," . $chapters[$i]->getName();
87 | if (mb_strlen($mergedName > 250)) {
88 | $mergedName = $chapters[$i - 1]->getName();
89 | }
90 |
91 | $this->info(sprintf(" -> merged %s (%s) with previous chapter %s (%s)", $chapters[$i]->getName(), $i, $chapters[$i - 1]->getName(), $i - 1));
92 |
93 |
94 | $chapters[$i - 1]->setName($mergedName);
95 | $chapters[$i - 1]->setEnd($chapters[$i]->getEnd());
96 | unset($chapters[$i + 1]);
97 |
98 | } else {
99 | $this->warning(sprintf(" -> not enough chapters to merge for min length %s", $this->minChapterLength->format()));
100 | }
101 | }
102 |
103 | $difference = count($tag->chapters) - count($chapters);
104 |
105 | if($difference > 0) {
106 | $this->info(sprintf(" => merged %s chapters that were to short", $difference));
107 | }
108 |
109 | // remove all chapters that have been merged to others
110 | foreach ($tag->chapters as $key => $chapter) {
111 | if (!in_array($chapter, $chapters)) {
112 | unset($tag->chapters[$key]);
113 | }
114 | }
115 |
116 |
117 | return $tag;
118 | }
119 |
120 | protected function isAdjustmentRequired(Tag $tag)
121 | {
122 | foreach ($tag->chapters as $chapter) {
123 | if ($chapter->getLength()->milliseconds() < $this->minChapterLength->milliseconds()) {
124 | return true;
125 | }
126 | }
127 | return false;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AudibleChaptersJson.php:
--------------------------------------------------------------------------------
1 | lengthCalc = $lengthCalc;
25 | }
26 |
27 | public static function fromFile(SplFileInfo $reference, string $fileName = null, Flags $flags = null): static
28 | {
29 | $fileContents = static::loadFileContents($reference, "audible_chapters.json");
30 | return new static($fileContents, $flags, null);
31 | }
32 | public static function fromFileWithChapterLengthCalc(SplFileInfo $reference, string $fileName = null, Flags $flags = null, ChapterLengthCalculator $lengthCalc = null): static
33 | {
34 | $fileContents = static::loadFileContents($reference, "audible_chapters.json");
35 | return new static($fileContents, $flags, $lengthCalc);
36 | }
37 |
38 | /**
39 | * @param Tag $tag
40 | * @return Tag
41 | */
42 | public function improve(Tag $tag): Tag
43 | {
44 | // If chapters are already named, don't touch them
45 | if (!$this->lengthCalc->hasPredominantChapterGroups(new ChapterGroupBuilder(), ...$tag->chapters)) {
46 | return $tag;
47 | }
48 |
49 | // load audible chapters from file
50 | $audibleChapters = parent::improve(new Tag());
51 |
52 | // match audible chapters
53 | $matchedTracks = $this->lengthCalc->matchNamedChaptersWithTracks($audibleChapters->chapters, $tag->chapters);
54 |
55 | if (count($matchedTracks) > 0) {
56 | $tag->chapters = $matchedTracks;
57 | }
58 |
59 | return $tag;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AudibleJson.php:
--------------------------------------------------------------------------------
1 | decodeJson($this->fileContent);
25 | if ($decoded === null) {
26 | return $tag;
27 | }
28 |
29 | $this->notice(sprintf("%s loaded for tagging", self::$defaultFileName));
30 | $product = $decoded["product"] ?? null;
31 |
32 |
33 | $mergeTag = new Tag();
34 |
35 | if (isset($product["asin"])) {
36 | $mergeTag->extraProperties["audibleAsin"] = $product["asin"];
37 | }
38 |
39 | if (isset($product["authors"])) {
40 | $mergeTag->artist = static::implodeSortedArrayOrNull(array_map(function ($author) {
41 | return $author["name"];
42 | }, $product["authors"]));
43 | }
44 |
45 | if (isset($product["narrators"])) {
46 | $mergeTag->writer = static::implodeSortedArrayOrNull(array_map(function ($narrator) {
47 | return $narrator["name"];
48 | }, $product["narrators"]));
49 | }
50 |
51 | if (isset($product["product_images"]) && is_array($product["product_images"])) {
52 | $maxKey = max(array_keys($product["product_images"]));
53 | $mergeTag->cover = $product["product_images"][$maxKey];
54 | }
55 |
56 | $mergeTag->album = $product["title"] ?? null;
57 | $mergeTag->year = ReleaseDate::createFromValidString($product["release_date"] ?? null);
58 | $mergeTag->language = $product["language"] ?? null;
59 | $mergeTag->copyright = $product["publisher_name"] ?? null;
60 | $mergeTag->publisher = $product["publisher_name"] ?? null;
61 |
62 |
63 | $htmlDescription = $product["publisher_summary"] ?? null;
64 | $mergeTag->description = $htmlDescription ? $this->stripHtml($htmlDescription) : null;
65 |
66 | $mergeTag->series = $product["series"][0]["title"] ?? null;
67 | $mergeTag->seriesPart = $product["series"][0]["sequence"] ?? null;
68 |
69 | $subtitle = $product["subtitle"] ?? "";
70 | if($this->shouldUseSeriesFromSubtitle
71 | && $mergeTag->series == null
72 | && preg_match("/^(.*)\s+([0-9]+)$/isU", $subtitle, $matches)
73 | && isset($matches[2])) {
74 | $mergeTag->series = $matches[1];
75 | $mergeTag->seriesPart = (int)$matches[2];
76 | }
77 |
78 | // todo: add mappingGenres and map Fantasy if in $ladders
79 | if (isset($product["category_ladders"]) && is_array($product["category_ladders"])) {
80 | foreach ($product["category_ladders"] as $ladder) {
81 | if ($ladder["root"] === "Genres") {
82 | foreach ($ladder["ladder"] as $genre) {
83 | // todo: genreMapping
84 | $mergeTag->genre = $genre["name"] ?? null;
85 | break;
86 | }
87 | }
88 | }
89 | }
90 |
91 | $this->copyDefaultProperties($mergeTag);
92 |
93 | // $tag->albumArtist = $this->getProperty("album_artist");
94 | // $tag->performer = $this->getProperty("performer");
95 | // $tag->disk = $this->getProperty("disc");
96 | // $tag->track = $this->getProperty("track");
97 | // $tag->encoder = $this->getProperty("encoder");
98 | // $tag->lyrics = $this->getProperty("lyrics");
99 | // cover is only a link, so skip it
100 | // $tag->cover = $decoded["cover"] ?? null;
101 | $tag->mergeOverwrite($mergeTag);
102 | return $tag;
103 | }
104 |
105 |
106 | }
107 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/AudibleTxt.php:
--------------------------------------------------------------------------------
1 | fileContent = $fileContents;
20 | }
21 |
22 | /**
23 | * Cover constructor.
24 | * @param SplFileInfo $reference
25 | * @param null $fileName
26 | * @return AudibleTxt
27 | */
28 | public static function fromFile(SplFileInfo $reference, $fileName = null, Flags $flags = null): static
29 | {
30 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
31 | return $fileToLoad ? new static(file_get_contents($fileToLoad)) : new static();
32 | }
33 |
34 |
35 | /**
36 | * @param Tag $tag
37 | * @return Tag
38 | */
39 | public function improve(Tag $tag): Tag
40 | {
41 | if (trim($this->fileContent) === "") {
42 | $this->info(sprintf("no %s found - tags not improved", static::DEFAULT_FILENAME));
43 | return $tag;
44 | }
45 | $decoded = @json_decode($this->fileContent, true);
46 | if ($decoded === false) {
47 | $this->warning(sprintf("could not decode %s", static::DEFAULT_FILENAME));
48 | return $tag;
49 | }
50 | $this->notice(sprintf("%s loaded for tagging", static::DEFAULT_FILENAME));
51 | $mergeTag = new Tag();
52 | $mergeTag->album = $decoded["name"] ?? null;
53 | // $tag->sortAlbum = $this->getProperty("sort_album") ?? $this->getProperty("album-sort");
54 | // $tag->sortTitle = $this->getProperty("sort_name") ?? $this->getProperty("title-sort");
55 | // $tag->sortArtist = $this->getProperty("sort_artist") ?? $this->getProperty("artist-sort");
56 | $narrators = $decoded["narrators"] ?? [];
57 | $mergeTag->writer = count($narrators) ? implode(", ", $narrators) : null;
58 | $mergeTag->genre = $decoded["genre"] ?? null;
59 |
60 | $mergeTag->copyright = $decoded["audibleMeta"]["publisher"] ?? null;
61 | $mergeTag->title = $decoded["name"] ?? null;
62 | $mergeTag->language = $decoded["audibleMeta"]["inLanguage"] ?? null;
63 | $mergeTag->artist = static::implodeSortedArrayOrNull($decoded["authors"]);
64 |
65 | // $tag->albumArtist = $this->getProperty("album_artist");
66 | // $tag->performer = $this->getProperty("performer");
67 | // $tag->disk = $this->getProperty("disc");
68 | $mergeTag->publisher = $decoded["audibleMeta"]["publisher"] ?? null;
69 | // $tag->track = $this->getProperty("track");
70 | // $tag->encoder = $this->getProperty("encoder");
71 | // $tag->lyrics = $this->getProperty("lyrics");
72 | $mergeTag->year = ReleaseDate::createFromValidString($decoded["audibleMeta"]["datePublished"] ?? "");
73 | $mergeTag->description = $decoded["description"] ?? null;
74 | $mergeTag->longDescription = $decoded["description"] ?? null;
75 | // cover is only a link, so skip it
76 | // $tag->cover = $decoded["cover"] ?? null;
77 | $tag->mergeOverwrite($mergeTag);
78 | return $tag;
79 | }
80 |
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/BookBeatJson.php:
--------------------------------------------------------------------------------
1 | decodeJson($this->fileContent);
15 | if ($decoded === null) {
16 | return $tag;
17 | }
18 | $this->notice(sprintf("%s loaded for tagging", static::$defaultFileName));
19 | $product = [];
20 | if(isset($decoded["id"])){
21 | $product = $decoded;
22 | }
23 | if(isset($decoded["data"])){
24 | $product = $decoded["data"];
25 | }
26 |
27 | if(!is_array($product) || count($product) === 0){
28 | return $tag;
29 | }
30 |
31 | $mergeTag = new Tag();
32 |
33 | $mergeTag->series = $product["series"]["name"] ?? "";
34 | $mergeTag->seriesPart = $product["series"]["partnumber"] ?? "";
35 |
36 | $mergeTag->album = $product["title"] ?? null;
37 |
38 | // strip series name from title if present
39 | $seriesSuffix = $mergeTag->series === "" ? "" : " - " . $mergeTag->series;
40 | $pos = strrpos($mergeTag->album, $seriesSuffix);
41 | if ($seriesSuffix !== "" && $pos !== false) {
42 | $mergeTag->album = substr($mergeTag->album, 0, $pos);
43 | }
44 |
45 | $mergeTag->language = $product["language"] ?? "";
46 |
47 |
48 | $mergeTag->description = $this->stripHtml($product["summary"] ?? "");
49 | $mergeTag->cover = $this->coverToSplFileOrNull($product["cover"] ?? null);
50 | // $mergeTag->publisher = $product["publisher"] ?? "";
51 | $mergeTag->year = ReleaseDate::createFromValidString($product["published"] ?? null);
52 | $mergeTag->artist = $product["author"] ?? null;
53 | $mergeTag->writer = $product["narrator"] ?? null;
54 |
55 | $this->copyDefaultProperties($mergeTag);
56 | $tag->mergeOverwrite($mergeTag);
57 | return $tag;
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/BuchhandelJson.php:
--------------------------------------------------------------------------------
1 | "artist",
13 | // narrator
14 | "E07" => "writer",
15 | // translator
16 | // "B06" => "",
17 | ];
18 | protected static string $defaultFileName = "buchhandel.json";
19 |
20 | public function improve(Tag $tag): Tag
21 | {
22 | $decoded = $this->decodeJson($this->fileContent);
23 | if ($decoded === null) {
24 | return $tag;
25 | }
26 | $this->notice(sprintf("%s loaded for tagging", static::$defaultFileName));
27 | $product = $decoded["data"]["attributes"] ?? null;
28 |
29 | $mergeTag = new Tag();
30 | $mergeTag->album = $product["title"] ?? null;
31 | $mergeTag->language = $product["mainLanguages"][0] ?? "";
32 | $mergeTag->series = $product["collections"][0]["name"] ?? "";
33 | $mergeTag->seriesPart = $product["collections"][0]["sequence"] ?? "";
34 | $mergeTag->description = $this->stripHtml($product["mainDescriptions"][0]["description"] ?? "");
35 | $mergeTag->cover = $this->coverToSplFileOrNull($product["coverUrl"] ?? null);
36 | $mergeTag->publisher = $product["publisher"] ?? "";
37 | $mergeTag->year = ReleaseDate::createFromValidString($product["publicationDate"] ?? null);
38 |
39 |
40 | $contributors = $product["contributors"] ?? [];
41 | $contributorGroups = [];
42 | foreach ($contributors as $contributor) {
43 | $type = $contributor["type"] ?? "";
44 | $name = $contributor["name"] ?? "";
45 | if ($type === "" || $name === "") {
46 | continue;
47 | }
48 | $property = static::CONTRIBUTOR_TYPE_TO_PROPERTY[$type] ?? null;
49 | if ($property == null || !property_exists($mergeTag, $property)) {
50 | continue;
51 | }
52 | $nameParts = explode(", ", $name);
53 | if (count($nameParts) > 1) {
54 | array_unshift($nameParts, array_pop($nameParts));
55 | }
56 | $contributorGroups[$property][] = implode(" ", $nameParts);
57 | }
58 |
59 | foreach ($contributorGroups as $property => $contributorNames) {
60 | $mergeTag->$property = static::implodeSortedArrayOrNull($contributorNames);
61 | }
62 | $this->copyDefaultProperties($mergeTag);
63 |
64 | $tag->mergeOverwrite($mergeTag);
65 | return $tag;
66 | }
67 |
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/ChaptersFromEpub.php:
--------------------------------------------------------------------------------
1 | chapterCollection = $chapterCollection ?? new ChapterCollection();
25 | $this->chapterHandler = $chapterHandler;
26 | }
27 |
28 | public function getChapterCollection(): ChapterCollection
29 | {
30 | return $this->chapterCollection;
31 | }
32 |
33 | public static function fromFileWithParams(SplFileInfo $reference = null, $fileName = null, ChapterHandler $chapterHandler = null, TimeUnit $totalDuration = null, array $chapterIndexesToRemove = []): ChaptersFromEpub|static
34 | {
35 | try {
36 | if($chapterHandler === null) {
37 | return new static();
38 | }
39 | if ($fileName === null || !file_exists($fileName)) {
40 | $path = $reference->isDir() ? $reference : new SplFileInfo($reference->getPath());
41 | $fileName = $fileName ?: $reference->getBasename($reference->getExtension()) . "epub";
42 | $globPattern = $path . "/" . $fileName;
43 | $files = glob($globPattern);
44 | if (!is_array($files) || count($files) === 0) {
45 | return new static();
46 | }
47 |
48 | $fileToLoad = new SplFileInfo($files[0]);
49 | } else {
50 | $fileToLoad = new SplFileInfo($fileName);
51 | }
52 | if ($fileToLoad->isFile()) {
53 | $epubParser = new EpubParser($fileToLoad);
54 | $chapterCollection = $epubParser->parseChapterCollection($totalDuration, $chapterIndexesToRemove);
55 | return new static($chapterCollection, $chapterHandler);
56 | }
57 | } catch (Throwable $e) {
58 | // ignore
59 | }
60 | return new static();
61 | }
62 |
63 | /**
64 | * @param Tag $tag
65 | * @return Tag
66 | * @throws Exception
67 | */
68 | public function improve(Tag $tag): Tag
69 | {
70 | if ($this->chapterHandler === null) {
71 | $this->info("no epub files found - tags not improved");
72 | return $tag;
73 | }
74 |
75 | $this->improveExtraProperty($tag, Tag::EXTRA_PROPERTY_ISBN, $this->chapterCollection->getEan());
76 | $this->improveExtraProperty($tag, Tag::EXTRA_PROPERTY_ASIN, $this->chapterCollection->getAsin());
77 | $this->improveExtraProperty($tag, Tag::EXTRA_PROPERTY_AUDIBLE_ID, $this->chapterCollection->getAudibleID());
78 |
79 | $chapters = $this->chapterCollection->toArray();
80 |
81 | $chaptersWithoutIgnored = array_filter($chapters, function (EpubChapter $chapter) {
82 | return !$chapter->isIgnored();
83 | });
84 |
85 | if (count($chaptersWithoutIgnored) === 0) {
86 | return $tag;
87 | }
88 |
89 | if (count($tag->chapters) > 0) {
90 | $tag->chapters = $this->chapterHandler->overloadTrackChaptersKeepUnique($chaptersWithoutIgnored, $tag->chapters);
91 | } else {
92 | $tag->chapters = $chaptersWithoutIgnored;
93 | }
94 |
95 | return $tag;
96 | }
97 |
98 | private function improveExtraProperty(Tag $tag, $extraPropertyName, $value): void
99 | {
100 |
101 | if (!isset($tag->extraProperties[$extraPropertyName]) && $value) {
102 | $tag->extraProperties[$extraPropertyName] = $value;
103 | }
104 | }
105 |
106 | }
107 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/ChaptersFromFileTracks.php:
--------------------------------------------------------------------------------
1 | chapterHandler = $chapterHandler;
34 | $this->filesToMerge = $filesToMerge;
35 | $this->filesToConvert = $filesToConvert;
36 | }
37 |
38 | /**
39 | * @param Tag $tag
40 | * @return Tag
41 | * @throws Exception
42 | */
43 | public function improve(Tag $tag): Tag
44 | {
45 | if (count($tag->chapters) === 0) {
46 | $tag->chapters = $this->chapterHandler->buildChaptersFromFiles($this->filesToMerge, $this->filesToConvert, $this->enableAdjustments);
47 | } else {
48 | $this->info("chapters are already present, chapters from file tracks are not required - tags not improved");
49 | }
50 | return $tag;
51 | }
52 |
53 | public function disableAdjustments()
54 | {
55 | $this->enableAdjustments = false;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/ChaptersFromMusicBrainz.php:
--------------------------------------------------------------------------------
1 | 0,
26 | 'last-chapter-offset' => 0,
27 | 'merge-similar' => false,
28 | 'no-chapter-numbering' => false,
29 | 'chapter-pattern' => "/^[^:]+[1-9][0-9]*:[\s]*(.*),.*[1-9][0-9]*[\s]*$/i",
30 | 'chapter-remove-chars' => "„“”",
31 | ];
32 | private ?TimeUnit $totalDuration;
33 |
34 | public function __construct(ChapterMarker $marker, ChapterHandler $chapterHandler, MusicBrainzChapterParser $musicBrainsChapterParser = null, ?TimeUnit $totalDuration = null)
35 | {
36 | $this->marker = $marker;
37 | $this->chapterParser = $musicBrainsChapterParser;
38 | $this->chapterHandler = $chapterHandler;
39 | $this->totalDuration = $totalDuration;
40 | }
41 |
42 | /**
43 | * @param Tag $tag
44 | * @return Tag
45 | * @throws InvalidArgumentException
46 | * @throws Exception
47 | */
48 | public function improve(Tag $tag): Tag
49 | {
50 | if (!$this->chapterParser || !$this->chapterHandler || !$this->marker) {
51 | return $tag;
52 | }
53 | $mbXml = $this->chapterParser->loadRecordings();
54 | $mbChapters = $this->chapterParser->parseRecordings($mbXml);
55 |
56 | $chapters = [];
57 | if (count($tag->chapters) > 0) {
58 | $chapters = $this->chapterHandler->overloadTrackChapters($mbChapters, $tag->chapters);
59 | } else if ($this->totalDuration !== null && $this->totalDuration->milliseconds() > 0) {
60 | foreach ($mbChapters as $mbChapter) {
61 | if ($mbChapter->getStart()->milliseconds() < $this->totalDuration->milliseconds()) {
62 | $chapters[] = $mbChapter;
63 | }
64 | }
65 |
66 | } else {
67 | $this->info("did neither find existing chapters to match nor a total duration to embed matching musicbrainz chapters");
68 | }
69 | $tag->chapters = $this->marker->normalizeChapters($chapters, static::NORMALIZE_CHAPTER_OPTIONS);
70 |
71 | return $tag;
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/ChaptersTxt.php:
--------------------------------------------------------------------------------
1 | mp4chaps = $mp4chaps;
28 | $this->chaptersContent = $chaptersContent ?? "";
29 | $this->totalLength = $totalLength;
30 | }
31 |
32 | public static function fromFile(SplFileInfo $reference, string $fileName = null, Flags $flags = null): static
33 | {
34 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
35 | return $fileToLoad ? new static(new Mp4chaps(), file_get_contents($fileToLoad), null) : new static();
36 | }
37 |
38 | public static function fromFileTotalDuration(SplFileInfo $reference, string $fileName = null, TimeUnit $totalLength = null): static
39 | {
40 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
41 | return $fileToLoad ? new static(new Mp4chaps(), file_get_contents($fileToLoad), $totalLength) : new static();
42 | }
43 |
44 |
45 | /**
46 | * @throws Exception
47 | */
48 | public function improve(Tag $tag): Tag
49 | {
50 | if ($this->mp4chaps !== null && trim($this->chaptersContent) !== "") {
51 | $tag->chapters = $this->mp4chaps->parseChaptersTxt($this->chaptersContent);
52 |
53 | // fix last chapter length, because length is not always stored in chapters.txt-Format
54 | $lastChapter = end($tag->chapters);
55 | if ($this->totalLength instanceof TimeUnit && $lastChapter instanceof Chapter) {
56 | $lastChapter->setEnd(clone $this->totalLength);
57 | }
58 | } else {
59 | $this->info("chapters.txt not found - tags not improved");
60 | }
61 |
62 | return $tag;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/ContentMetadataJson.php:
--------------------------------------------------------------------------------
1 | chaptersContent = $fileContents;
25 | $this->flags = $flags ?? new Flags();
26 | $this->chapterIndex = 1;
27 | }
28 |
29 | /**
30 | * Cover constructor.
31 | * @param SplFileInfo $reference
32 | * @param string|null $fileName
33 | * @param Flags|null $flags
34 | * @return ContentMetadataJson
35 | */
36 | public static function fromFile(SplFileInfo $reference, ?string $fileName = null, Flags $flags = null): static
37 | {
38 | $fileContents = static::loadFileContents($reference, $fileName);
39 | return new static($fileContents, $flags);
40 | }
41 |
42 | protected static function loadFileContents(SplFileInfo $reference, $fileName = null): string
43 | {
44 | $path = $reference->isDir() ? $reference : new SplFileInfo($reference->getPath());
45 | $fileName = $fileName ?: "content_metadata_*.json";
46 |
47 | $globPattern = $path . "/" . $fileName;
48 | $files = glob($globPattern);
49 | if (!is_array($files) || count($files) === 0) {
50 | return "";
51 | }
52 |
53 | $fileToLoad = new SplFileInfo($files[0]);
54 | if ($fileToLoad->isFile()) {
55 | return static::stripBOM(file_get_contents($fileToLoad));
56 | }
57 | return "";
58 | }
59 |
60 |
61 | /**
62 | * @param Tag $tag
63 | * @return Tag
64 | */
65 | public function improve(Tag $tag): Tag
66 | {
67 | if (count($this->overloadChapters) === 0) {
68 | if (trim($this->chaptersContent) === "") {
69 | $this->info("content_metadata_*.json not found - tags not improved");
70 | return $tag;
71 | }
72 | $decoded = @json_decode($this->chaptersContent, true, 512, JSON_BIGINT_AS_STRING);
73 | $decodedChapters = $decoded["content_metadata"]["chapter_info"]["chapters"] ?? [];
74 | if (count($decodedChapters) === 0) {
75 | return $tag;
76 | }
77 | /** @var Chapter[] $chapters */
78 | $chapters = [];
79 | if (isset($decoded["content_metadata"]["chapter_info"]["brandIntroDurationMs"])) {
80 | $chapters[] = new Chapter(new TimeUnit(0), new TimeUnit($decoded["content_metadata"]["chapter_info"]["brandIntroDurationMs"]), Chapter::DEFAULT_INTRO_NAME);
81 | }
82 | $this->jsonArrayToChapters($chapters, $this->chapterIndex, $decodedChapters);
83 |
84 | $lastChapter = end($chapters);
85 |
86 |
87 | if ($lastChapter instanceof Chapter && isset($decoded["content_metadata"]["chapter_info"]["brandOutroDurationMs"])) {
88 | $chapters[] = new Chapter(new TimeUnit($lastChapter->getEnd()->milliseconds()), new TimeUnit($decoded["content_metadata"]["chapter_info"]["brandOutroDurationMs"]), Chapter::DEFAULT_OUTRO_NAME);
89 | }
90 | $tag->chapters = $chapters;
91 | } else {
92 | $tag->chapters = $this->overloadChapters;
93 | }
94 |
95 | $audibleId = $decoded["content_metadata"]["content_reference"]["asin"] ?? null;
96 | if ($audibleId !== null) {
97 | $tag->extraProperties["audible_id"] = $audibleId;
98 | }
99 | return $tag;
100 | }
101 |
102 |
103 |
104 | }
105 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/Cover.php:
--------------------------------------------------------------------------------
1 | coverLoader = $coverLoader;
34 | $this->coverDir = $coverDir->isDir() ? $coverDir : new SplFileInfo($coverDir->getPath());
35 | $this->preferredFileName = $preferredFileName ?? "cover.jpg";
36 | }
37 |
38 | /**
39 | * @param Tag $tag
40 | * @return Tag
41 | */
42 | public function improve(Tag $tag): Tag
43 | {
44 | $tag->cover = new SplFileInfo($this->coverDir . DIRECTORY_SEPARATOR . $this->preferredFileName);
45 | if (!$tag->cover->isFile()) {
46 | $this->coverLoader->addNonRecursive($this->coverDir);
47 | $this->coverLoader->setIncludeExtensions(static::COVER_EXTENSIONS);
48 | $this->coverLoader->addNonRecursive($this->coverDir);
49 | $tag->cover = $this->coverLoader->current() ?: null;
50 | }
51 |
52 | return $tag;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/Description.php:
--------------------------------------------------------------------------------
1 | descriptionContent = $descriptionContent;
23 | }
24 |
25 | /**
26 | * Cover constructor.
27 | * @param SplFileInfo $reference
28 | * @param null $fileName
29 | * @return Description
30 | */
31 | public static function fromFile(SplFileInfo $reference, $fileName = null, Flags $flags = null): static
32 | {
33 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
34 | return $fileToLoad ? new static(file_get_contents($fileToLoad)) : new static();
35 | }
36 |
37 |
38 | /**
39 | * @param Tag $tag
40 | * @return Tag
41 | */
42 | public function improve(Tag $tag): Tag
43 | {
44 | if (trim($this->descriptionContent) !== "") {
45 | $tag->description = $this->descriptionContent;
46 | $tag->longDescription = $this->descriptionContent;
47 | } else {
48 | $this->info(sprintf("%s not found - tags not improved", static::DEFAULT_FILENAME));
49 | }
50 | return $tag;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/Equate.php:
--------------------------------------------------------------------------------
1 | equateInstructions = $this->parseRawEquateInstructions($keyMapper, $rawEquateInstructions);
18 | }
19 |
20 | private function parseRawEquateInstructions(OptionNameTagPropertyMapper $keyMapper, array $rawEquateInstructions)
21 | {
22 | $equateInstructions = [];
23 | foreach ($rawEquateInstructions as $rawInstruction) {
24 | $fields = explode(",", $rawInstruction);
25 | $fieldCount = count($fields);
26 | if ($fieldCount < 2) {
27 | $this->warning(sprintf("equate instructions must contain at least two tag fields separated by , - %s contains %s", $rawInstruction, $fieldCount));
28 | continue;
29 | }
30 | $sourceField = $keyMapper->mapOptionToTagProperty(trim(array_shift($fields)));
31 |
32 | foreach ($fields as $field) {
33 | $equateInstructions[] = [
34 | "source" => $sourceField,
35 | "destination" => $keyMapper->mapOptionToTagProperty(trim($field)),
36 | ];
37 | }
38 | }
39 | return $equateInstructions;
40 | }
41 |
42 |
43 | /**
44 | * @param Tag $tag
45 | * @return Tag
46 | */
47 | public function improve(Tag $tag): Tag
48 | {
49 | $improvedProperties = [];
50 | foreach ($this->equateInstructions as $instruction) {
51 | $sourceProperty = $instruction["source"];
52 | $destinationProperty = $instruction["destination"];
53 |
54 | if (!property_exists($tag, $sourceProperty)) {
55 | $this->warning(sprintf("source property %s does not exist on tag", $sourceProperty));
56 | continue;
57 | }
58 |
59 | if (!property_exists($tag, $destinationProperty)) {
60 | $this->warning(sprintf("destination property %s does not exist on tag", $destinationProperty));
61 | continue;
62 | }
63 |
64 | if ($tag->$sourceProperty == null || !is_scalar($tag->$sourceProperty)) {
65 | $this->warning(sprintf("source property %s is not a scalar value or null and cannot be equated", $sourceProperty));
66 | continue;
67 | }
68 |
69 | if ($tag->$destinationProperty != null && !is_scalar($tag->$destinationProperty)) {
70 | $this->warning(sprintf("destination property %s is not a scalar value and cannot be equated", $sourceProperty));
71 | continue;
72 | }
73 | $improvedProperties[$destinationProperty] = [
74 | "before" => $tag->$destinationProperty,
75 | "after" => $tag->$sourceProperty,
76 | ];
77 | $tag->$destinationProperty = $tag->$sourceProperty;
78 | }
79 | $this->info(sprintf("equate changed %s tag properties:", count($improvedProperties)));
80 | $this->dumpTagDifference($improvedProperties);
81 |
82 | return $tag;
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/Ffmetadata.php:
--------------------------------------------------------------------------------
1 | ffparser = $ffparser;
25 | }
26 |
27 | /**
28 | * Cover constructor.
29 | * @param SplFileInfo $reference
30 | * @param null $fileName
31 | * @return Ffmetadata
32 | * @throws Exception
33 | */
34 | public static function fromFile(SplFileInfo $reference, $fileName = null, Flags $flags = null): static
35 | {
36 |
37 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
38 |
39 | if ($fileToLoad) {
40 | $parser = new FfmetaDataParser();
41 | $parser->parse(file_get_contents($fileToLoad));
42 | return new static($parser);
43 | }
44 |
45 | return new static();
46 | }
47 |
48 | public function improve(Tag $tag): Tag
49 | {
50 | if ($this->ffparser === null) {
51 | $this->info("ffmetadata.txt not found - tags not improved");
52 | return $tag;
53 | }
54 |
55 | $improvedProperties = $tag->mergeOverwrite($this->ffparser->toTag());
56 |
57 | $this->info(sprintf("ffmetadata.txt improved the following %s properties:", count($improvedProperties)));
58 | $this->dumpTagDifference($improvedProperties);
59 |
60 | return $tag;
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/GuessChaptersBySilence.php:
--------------------------------------------------------------------------------
1 | chapterMarker = $chapterMarker;
24 | $this->totalDuration = $totalDuration;
25 | $this->silenceDetectionCallback = $silenceDetectionCallback;
26 | }
27 |
28 | /**
29 | * @param Tag $tag
30 | * @return Tag
31 | * @throws Exception
32 | * @throws Exception
33 | */
34 | public function improve(Tag $tag): Tag
35 | {
36 | if (count($tag->chapters) > 0) {
37 | $silences = ($this->silenceDetectionCallback)();
38 | $tag->chapters = $this->chapterMarker->guessChaptersBySilences($tag->chapters, $silences, $this->totalDuration);
39 | } else {
40 | $this->info("tag does not contain chapters, that could be adjusted by silences - no improvements required");
41 | }
42 | return $tag;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/IntroOutroChapters.php:
--------------------------------------------------------------------------------
1 | chapters) === 0) {
17 | $this->info("no chapters found - tags not improved");
18 | return $tag;
19 | }
20 | reset($tag->chapters);
21 | $firstChapter = current($tag->chapters);
22 | if ($firstChapter && $firstChapter->getName() !== Chapter::DEFAULT_INTRO_NAME) {
23 | $introChapter = new Chapter(new TimeUnit(0), new TimeUnit(10, TimeUnit::SECOND), Chapter::DEFAULT_INTRO_NAME);
24 | array_unshift($tag->chapters, $introChapter);
25 | $firstChapter->setStart(clone $introChapter->getEnd());
26 | }
27 |
28 |
29 | $lastChapter = end($tag->chapters);
30 | if ($lastChapter && $lastChapter->getName() !== Chapter::DEFAULT_OUTRO_NAME) {
31 | $outroLength = new TimeUnit(10, TimeUnit::SECOND);
32 | $outroStart = new TimeUnit($lastChapter->getEnd()->milliseconds() - $outroLength->milliseconds());
33 | $outroChapter = new Chapter($outroStart, $outroLength, Chapter::DEFAULT_OUTRO_NAME);
34 | $tag->chapters[] = $outroChapter;
35 | $lastChapter->setEnd(clone $outroStart);
36 | }
37 | return $tag;
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/M4bToolJson.php:
--------------------------------------------------------------------------------
1 | fileContents = $fileContents;
25 | $this->flags = $flags ?? new Flags();
26 | }
27 |
28 | /**
29 | * Cover constructor.
30 | * @param SplFileInfo $reference
31 | * @param null $fileName
32 | * @param Flags $flags
33 | * @return M4bToolJson
34 | */
35 | public static function fromFile(SplFileInfo $reference, $fileName = null, Flags $flags = null): static
36 | {
37 | $fileToLoad = static::searchExistingMetaFile($reference, static::DEFAULT_FILENAME, $fileName);
38 | return $fileToLoad ? new static(file_get_contents($fileToLoad), $flags) : new static();
39 | }
40 |
41 |
42 | /**
43 | * @param Tag $tag
44 | * @return Tag
45 | * @throws Exception
46 | */
47 | public function improve(Tag $tag): Tag
48 | {
49 | if (!$this->fileContents) {
50 | $this->info("m4b-tool.json not found - tags not improved");
51 | return $tag;
52 | }
53 | try {
54 | $loadedTag = new Tag();
55 | $properties = json_decode($this->fileContents, true);
56 | foreach ($properties as $key => $value) {
57 | if ($key === "purchaseDate") {
58 | $loadedTag->$key = PurchaseDateTime::createFromValidString($value);
59 | } else if ($key === "year") {
60 | $loadedTag->$key = ReleaseDate::createFromValidString($value);
61 | } else {
62 | $loadedTag->$key = $value;
63 | }
64 | $this->info(sprintf("purchaseDate set to %s (debug: %s)", $tag->purchaseDate, (int)$this->flags->contains(static::FLAG_DEBUG)));
65 | }
66 | $tag->mergeOverwrite($loadedTag);
67 | } catch (Throwable $t) {
68 | $this->warning(sprintf("could not decode m4b-tool.json: %s", $t->getMessage()));
69 | $this->debug($t->getTraceAsString());
70 | }
71 |
72 | return $tag;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/MergeSubChapters.php:
--------------------------------------------------------------------------------
1 | chapterHandler = $chapterHandler;
18 | }
19 |
20 | /**
21 | * @param Tag $tag
22 | * @return Tag
23 | * @throws Exception
24 | * @throws Exception
25 | */
26 | public function improve(Tag $tag): Tag
27 | {
28 | if (count($tag->chapters) > 0) {
29 | $tag->chapters = $this->chapterHandler->mergeSubChapters($tag->chapters);
30 | } else {
31 | $this->info("no chapters found - tags not improved");
32 | }
33 | return $tag;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/MetadataJson.php:
--------------------------------------------------------------------------------
1 | decodeJson($this->fileContent);
24 | if ($decoded === null) {
25 | return $tag;
26 | }
27 |
28 | $this->notice(sprintf("%s loaded for tagging", self::$defaultFileName));
29 |
30 | foreach ($decoded as $propertyName => $propertyValue) {
31 | if (!property_exists($tag, $propertyName) || $tag->isTransientProperty($propertyName)) {
32 | continue;
33 | }
34 | if ($propertyName === "year") {
35 | $tag->year = ReleaseDate::createFromValidString($propertyValue);
36 | continue;
37 | }
38 |
39 | if ($propertyName === "purchaseDate") {
40 | $tag->purchaseDate = PurchaseDateTime::createFromValidString($propertyValue);
41 | continue;
42 | }
43 | $tag->$propertyName = $propertyValue;
44 | }
45 |
46 | if(isset($decoded["series"])) {
47 | $tag->series = is_scalar($decoded["series"]) ? $decoded["series"] : implode(", ", $decoded["series"]);
48 | }
49 | if(isset($decoded["seriesPart"])) {
50 | $tag->seriesPart = is_scalar($decoded["seriesPart"]) ? $decoded["seriesPart"] : implode(", ", $decoded["seriesPart"]);
51 | }
52 |
53 | if(isset($decoded["chapters"]) && is_array($decoded["chapters"])) {
54 | if(count($tag->chapters) < count($decoded["chapters"])) {
55 | $tag->chapters = [];
56 | }
57 | $chapterIndex = 1;
58 | $this->jsonArrayToChapters($tag->chapters, $chapterIndex, $decoded["chapters"]);
59 | }
60 |
61 |
62 | return $tag;
63 | }
64 |
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/RemoveDuplicateFollowUpChapters.php:
--------------------------------------------------------------------------------
1 | chapterHandler = $chapterHandler;
18 | }
19 |
20 | /**
21 | * @param Tag $tag
22 | * @return Tag
23 | * @throws Exception
24 | * @throws Exception
25 | */
26 | public function improve(Tag $tag): Tag
27 | {
28 | if (count($tag->chapters) > 0) {
29 | $tag->chapters = $this->chapterHandler->removeDuplicateFollowUps($tag->chapters);
30 | } else {
31 | $this->info("no chapters found - tags not improved");
32 | }
33 | return $tag;
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/library/Audio/Tag/TagImproverInterface.php:
--------------------------------------------------------------------------------
1 | cacheAdapter = $cache;
22 | }
23 |
24 | /**
25 | * @param $cacheKey
26 | * @param callable $expensiveFunction
27 | * @param int $expiresAfter
28 | * @return mixed
29 | * @throws Exception
30 | * @throws InvalidArgumentException
31 | */
32 | public function cacheAdapterGet($cacheKey, callable $expensiveFunction, $expiresAfter = null)
33 | {
34 | if (!($this->cacheAdapter instanceof AdapterInterface)) {
35 | throw new Exception("cacheAdapterGet cannot be used without a cacheAdapter");
36 | }
37 | $cacheItem = $this->cacheAdapter->getItem($cacheKey);
38 | if (!$cacheItem->isHit()) {
39 | $cacheItem->set($expensiveFunction());
40 | $cacheItem->expiresAfter($expiresAfter);
41 | $this->cacheAdapter->save($cacheItem);
42 | }
43 | return $cacheItem->get();
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/library/Audio/Traits/LogTrait.php:
--------------------------------------------------------------------------------
1 | logger instanceof LoggerInterface) {
18 | $this->logger->log($level, $message, $context);
19 | }
20 | }
21 |
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/src/library/Chapter/ChapterGroup/ChapterGroupBuilder.php:
--------------------------------------------------------------------------------
1 | buildNormalizedGroups(function (Chapter $chapter) {
19 | return $chapter->getName();
20 | }, ...$chapters);
21 | }
22 |
23 | /**
24 | * @param callable $normalizer
25 | * @param Chapter ...$chapters
26 | * @return ChapterGroup[]
27 | */
28 | private function buildNormalizedGroups(callable $normalizer, Chapter ...$chapters)
29 | {
30 |
31 | $lastNormalizedName = null;
32 | $chapterGroups = [];
33 | $currentChapterGroup = new ChapterGroup();
34 | foreach ($chapters as $chapter) {
35 | $normalizedName = $normalizer($chapter);
36 | if ($normalizedName !== $lastNormalizedName) {
37 | $lastNormalizedName = $normalizedName;
38 | if (count($currentChapterGroup->chapters) > 0) {
39 | $chapterGroups[] = $currentChapterGroup;
40 | }
41 | $currentChapterGroup = new ChapterGroup($normalizedName, [$chapter]);
42 | } else {
43 | $currentChapterGroup->addChapter($chapter);
44 | }
45 | }
46 |
47 | if (count($currentChapterGroup->chapters) > 0) {
48 | $chapterGroups[] = $currentChapterGroup;
49 | }
50 | return $chapterGroups;
51 | }
52 |
53 | /**
54 | * @param ChapterGroup ...$chapterGroups
55 | * @return Chapter[]
56 | */
57 | public function mergeGroupsToChapters(ChapterGroup ...$chapterGroups)
58 | {
59 | $chapters = [];
60 | foreach ($chapterGroups as $group) {
61 | foreach ($group->chapters as $chapter) {
62 | $chapters[] = $chapter;
63 |
64 | }
65 | }
66 | return $chapters;
67 | }
68 |
69 | /**
70 | * @param Chapter ...$chapters
71 | * @return ChapterGroup[]
72 | */
73 | public function groupByNormalizedName(Chapter ...$chapters)
74 | {
75 | return $this->buildNormalizedGroups(function (Chapter $chapter) {
76 | return $this->normalizeChapterName($chapter->getName());
77 | }, ...$chapters);
78 | }
79 |
80 | private function normalizeChapterName($name)
81 | {
82 | return preg_replace("/[0-9. ]+/i", "", $name);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/library/Chapter/ChapterShifter.php:
--------------------------------------------------------------------------------
1 | = 0 && nextChapter.length < $shiftMs, don't shift current chapter
22 | // if shiftMs < 0 && chapter.Length < $shiftMs, don't shift next chapter
23 | $chapters = array_values($chapters);
24 | $indexes ??= array_keys($chapters);
25 |
26 | $lastIndex = count($chapters) - 1;
27 |
28 | foreach($indexes as $key => $value) {
29 | if($value < 0) {
30 | $indexes[$key] = $lastIndex + $value;
31 | }
32 | }
33 |
34 | foreach($indexes as $index) {
35 | $currentChapter = $chapters[$index] ?? null;
36 | if($currentChapter === null) {
37 | continue;
38 | }
39 |
40 | $startMs = $currentChapter->getStart()->milliseconds();
41 | $endMs = $currentChapter->getEnd()->milliseconds();
42 | $newStartMs = $startMs + $shiftMs;
43 | $newEndMs = $endMs;
44 |
45 | if($index > 0) {
46 | $currentChapter->setStart(new TimeUnit($newStartMs));
47 | }
48 | if($index < $lastIndex) {
49 | $newEndMs += $shiftMs;
50 | }
51 |
52 | $currentChapter->setEnd(new TimeUnit($newEndMs));
53 |
54 |
55 | // check if shifting does something not allowed and revert the change then
56 | foreach($chapters as $chapter) {
57 | if($chapter->getLength()->milliseconds() < 0) {
58 | $currentChapter->setStart(new TimeUnit($startMs));
59 | $currentChapter->setEnd(new TimeUnit($endMs));
60 | break;
61 | }
62 | }
63 |
64 | }
65 | }
66 |
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/src/library/Chapter/ChapterTitleBuilder.php:
--------------------------------------------------------------------------------
1 | metaReader = $metaReader;
20 | }
21 |
22 | /**
23 | * @param $files
24 | * @param $autoSplitMilliSeconds
25 | * @return array
26 | * @throws Exception
27 | */
28 | public function buildChapters($files, $autoSplitMilliSeconds) {
29 | $this->totalDuration = new TimeUnit();
30 | $lastTitle = null;
31 | $chapters = [];
32 |
33 | $metaDataContainer = [];
34 | $durationContainer = [];
35 | $titleContainer = [];
36 | /**
37 | * @var int $fileIndex
38 | * @var SplFileInfo $file
39 | */
40 | $index = 1;
41 | foreach ($files as $fileIndex => $file) {
42 | /** @var FfmetaDataParser $metaData */
43 | $metaDataContainer[$fileIndex] = $this->metaReader->readFileMetaData($file);
44 | $durationContainer[$fileIndex] = $this->metaReader->readDuration($file);
45 |
46 |
47 | if (!$durationContainer[$fileIndex]) {
48 | throw new Exception(sprintf("could not get duration for file %s: metareader class: %s", $file, get_class($this->metaReader)));
49 | }
50 |
51 | $titleContainer[$fileIndex] = [
52 | "index" => $index++,
53 | "meta" => $metaDataContainer[$fileIndex]->getProperty("title"),
54 | "filename" => $file->getBasename(".".$file->getExtension()),
55 | ];
56 | }
57 |
58 | $titleKey = "index";
59 | $lastTitles = [];
60 |
61 | foreach($titleContainer as $fileIndex => $titleCandidates) {
62 | foreach($titleCandidates as $key => $value) {
63 | $normalizedValue = trim(mb_strtolower(preg_replace("/[0-9]/iU", "", $value)));
64 |
65 | if($key === "meta" && $normalizedValue == "") {
66 | continue;
67 | }
68 | if(!isset($lastTitles[$key][$normalizedValue])) {
69 | $lastTitles[$key][$normalizedValue] = 0;
70 | }
71 |
72 | $lastTitles[$key][$normalizedValue]++;
73 | }
74 | }
75 |
76 |
77 | $highestCount = 0;
78 | $fileCount = count($files);
79 | foreach($lastTitles as $key => $value) {
80 | $count = count($value);
81 | if($highestCount < $count && $count > $fileCount / 4) {
82 | $highestCount = count($value);
83 | $titleKey = $key;
84 | }
85 | }
86 |
87 | foreach($files as $fileIndex => $file) {
88 | $duration = $durationContainer[$fileIndex];
89 | $start = $this->totalDuration->milliseconds();
90 | $this->totalDuration->add($duration->milliseconds());
91 |
92 |
93 | $title = $titleContainer[$fileIndex][$titleKey];
94 | $indexedTitle = $title;
95 | if ($title == $lastTitle) {
96 | $indexedTitle = $title . " (" . ($fileIndex + 1) . ")";
97 | if ($fileIndex == 1 && isset($chapters[0])) {
98 | $chapters[0]->setName($chapters[0]->getName() . " (1)");
99 | }
100 | }
101 | $chapterIndex = 1;
102 | while ($start < $this->totalDuration->milliseconds()) {
103 | $chapterTitle = $indexedTitle;
104 | if ($autoSplitMilliSeconds > 0 && $autoSplitMilliSeconds < $duration->milliseconds()) {
105 | $chapterTitle = $indexedTitle . " - (" . ($chapterIndex++) . ")";
106 | }
107 |
108 | $chapters[$start] = new Chapter(new TimeUnit($start), new TimeUnit($duration->milliseconds()), $chapterTitle);
109 |
110 | if ($autoSplitMilliSeconds <= 0 || $autoSplitMilliSeconds > $duration->milliseconds()) {
111 | break;
112 | }
113 | $start += $autoSplitMilliSeconds;
114 | }
115 | $lastTitle = $title;
116 | }
117 |
118 | return $chapters;
119 | }
120 |
121 |
122 |
123 | }
124 |
--------------------------------------------------------------------------------
/src/library/Chapter/MetaReaderInterface.php:
--------------------------------------------------------------------------------
1 | setFormatString("Y");
33 | return $return;
34 | } catch (Throwable $t) {
35 | return null;
36 | }
37 | }
38 |
39 | public function setFormatString($formatString): void
40 | {
41 | $this->formatString = $formatString;
42 | }
43 |
44 | public function __toString(): string
45 | {
46 | return $this->format($this->formatString ?? static::$defaultFormatString);
47 | }
48 |
49 | public function jsonSerialize(): string
50 | {
51 | return $this->__toString();
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/library/Common/ConditionalFlags.php:
--------------------------------------------------------------------------------
1 | rawValue = $rawValue;
14 | }
15 |
16 | public function equal($flag): bool
17 | {
18 | return $this->rawValue === $flag;
19 | }
20 |
21 | public function notEqual($flag): bool
22 | {
23 | return $this->rawValue !== $flag;
24 | }
25 |
26 | public function insert($flag): void
27 | {
28 | $this->rawValue |= $flag;
29 | }
30 |
31 | public function remove($flag): void
32 | {
33 | $this->rawValue &= ~$flag;
34 | }
35 |
36 | public function contains($flag): bool
37 | {
38 | return (bool)($this->rawValue & $flag);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/library/Common/PurchaseDateTime.php:
--------------------------------------------------------------------------------
1 | 24000,
14 | 11025 => 32000,
15 | 12000 => 32000,
16 | 16000 => 48000,
17 | 22050 => 64000,
18 | 32000 => 96000,
19 | 44100 => 128000,
20 | ];
21 |
22 | const VBR_QUALITY_TO_SAMPLING_RATE_MAPPING = [
23 | 0 => 8000,
24 | 20 => 12000,
25 | 40 => 16000,
26 | 60 => 22050,
27 | 80 => 44100
28 | ];
29 |
30 |
31 | protected function percentToValue($percent, $min, $max, $decimals = 0)
32 | {
33 | $value = round((($percent * ($max - $min)) / 100) + $min, $decimals);
34 | if ($value < $min) {
35 | $value = $min;
36 | } else if ($value > $max) {
37 | $value = $max;
38 | }
39 |
40 | return $value;
41 | }
42 |
43 | protected function appendTrimSilenceOptionsToCommand(&$command, FileConverterOptions $options)
44 | {
45 | // https://ffmpeg.org/ffmpeg-filters.html#silenceremove
46 | if ($options->trimSilenceStart || $options->trimSilenceEnd) {
47 | $command[] = "-af";
48 | $command[] = sprintf("silenceremove=start_periods=%s:start_threshold=%s:stop_periods=%s", (int)$options->trimSilenceStart, static::SILENCE_DEFAULT_DB, (int)$options->trimSilenceEnd);
49 | }
50 |
51 | }
52 |
53 | protected function setEncodingQualityIfUndefined(FileConverterOptions $options)
54 | {
55 | // all options are already set
56 | if ($options->bitRate && $options->sampleRate) {
57 | return $options;
58 | }
59 |
60 | $desiredSampleRate = static::DEFAULT_SAMPLING_RATE;
61 |
62 |
63 | if ($options->vbrQuality <= 0) {
64 | // only sample rate is set => bitrate has to be determined
65 | if ($options->sampleRate) {
66 | if (isset(static::SAMPLING_RATE_TO_BITRATE_MAPPING[$options->sampleRate])) {
67 | $options->bitRate = $this->bitrateToString(static::SAMPLING_RATE_TO_BITRATE_MAPPING[$options->sampleRate]);
68 | } else if ($options->sampleRate < 8000) {
69 | $options->bitRate = $this->bitrateToString(24000);
70 | } else {
71 | $options->bitRate = $this->bitrateToString(128000);
72 | }
73 | return $options;
74 | }
75 |
76 | // neither bitrate nor sample rate is set, seek default for desired bitrate
77 | $desiredBitrate = $options->bitRate ? $this->bitrateToInt($options->bitRate) : static::DEFAULT_BITRATE;
78 | foreach (static::SAMPLING_RATE_TO_BITRATE_MAPPING as $sampleRate => $bitrate) {
79 | if ($bitrate <= $desiredBitrate) {
80 | $desiredSampleRate = $sampleRate;
81 | } else {
82 | break;
83 | }
84 | }
85 |
86 | $options->bitRate = $this->bitrateToString($desiredBitrate);
87 | $options->sampleRate = $desiredSampleRate;
88 | return $options;
89 | }
90 |
91 | // vbr mode, sample rate is already set
92 | if ($options->sampleRate) {
93 | return $options;
94 | }
95 |
96 | // determine according sample rate for vbrQuality value
97 | foreach (static::VBR_QUALITY_TO_SAMPLING_RATE_MAPPING as $vbrQualityValue => $sampleRate) {
98 | if ($vbrQualityValue <= $options->vbrQuality) {
99 | $desiredSampleRate = $sampleRate;
100 | } else {
101 | break;
102 | }
103 | }
104 | $options->sampleRate = $desiredSampleRate;
105 | return $options;
106 | }
107 |
108 | private function bitrateToInt($value)
109 | {
110 | if (stripos($value, "k") !== false) {
111 | return (int)(rtrim($value, "k") * 1000);
112 | }
113 | return (int)$value;
114 | }
115 |
116 | private function bitrateToString($integerValue)
117 | {
118 | return ceil($integerValue / 1000) . 'k';
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/library/Executables/AbstractMp4v2Executable.php:
--------------------------------------------------------------------------------
1 | platformCharset = $charset;
23 | }
24 |
25 | public function runProcess(array $arguments, $messageInCaseOfError = null)
26 | {
27 | if ($this->platformCharset !== null && strtolower($this->platformCharset) !== static::CHARSET_UTF_8) {
28 | $arguments = array_map(function ($argument) {
29 | return mb_convert_encoding($argument, static::CHARSET_UTF_8, $this->platformCharset);
30 | }, $arguments);
31 | }
32 | return parent::runProcess($arguments, $messageInCaseOfError);
33 | }
34 |
35 | public static function buildConventionalFileName(SplFileInfo $audioFile, $suffix, $extension, $index = null)
36 | {
37 | $dirName = $audioFile->getPath();
38 | if ($dirName !== "") {
39 | $dirName .= DIRECTORY_SEPARATOR;
40 | }
41 | $fileName = $audioFile->getBasename("." . $audioFile->getExtension());
42 | $conventionalFile = $dirName . $fileName . "." . $suffix;
43 | if ($index !== null) {
44 | $conventionalFile .= "[" . (int)$index . "]";
45 | }
46 | $conventionalFile .= "." . $extension;
47 | return new SplFileInfo($conventionalFile);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/library/Executables/DurationDetectorInterface.php:
--------------------------------------------------------------------------------
1 | self::FORMAT_ADTS,
50 | self::EXTENSION_AAX => self::FORMAT_UNSPECIFIED,
51 | self::EXTENSION_AIF => self::FORMAT_UNSPECIFIED,
52 | self::EXTENSION_AIFF => self::FORMAT_UNSPECIFIED,
53 | self::EXTENSION_ALAC => self::FORMAT_UNSPECIFIED,
54 | self::EXTENSION_APE => self::FORMAT_UNSPECIFIED,
55 | self::EXTENSION_AU => self::FORMAT_UNSPECIFIED,
56 | self::EXTENSION_CAF => self::FORMAT_UNSPECIFIED,
57 | self::EXTENSION_FLAC => self::FORMAT_FLAC,
58 | self::EXTENSION_M4A => self::FORMAT_MP4,
59 | self::EXTENSION_M4B => self::FORMAT_MP4,
60 | self::EXTENSION_M4P => self::FORMAT_MP4,
61 | self::EXTENSION_M4R => self::FORMAT_MP4,
62 | self::EXTENSION_MKA => self::FORMAT_UNSPECIFIED,
63 | self::EXTENSION_MP2 => self::FORMAT_UNSPECIFIED,
64 | self::EXTENSION_MP3 => self::FORMAT_MP3,
65 | self::EXTENSION_MP4 => self::FORMAT_MP4,
66 | self::EXTENSION_MPA => self::FORMAT_UNSPECIFIED,
67 | self::EXTENSION_RIF => self::FORMAT_UNSPECIFIED,
68 | self::EXTENSION_OGA => self::FORMAT_UNSPECIFIED,
69 | self::EXTENSION_OGG => self::FORMAT_UNSPECIFIED,
70 | self::EXTENSION_OPUS => self::FORMAT_UNSPECIFIED,
71 | self::EXTENSION_WAV => self::FORMAT_UNSPECIFIED,
72 | self::EXTENSION_WMA => self::FORMAT_UNSPECIFIED,
73 | ];
74 |
75 |
76 | public function convertFile(FileConverterOptions $options): Process;
77 |
78 | public function supportsConversion(FileConverterOptions $options): bool;
79 |
80 | }
81 |
--------------------------------------------------------------------------------
/src/library/Executables/FileConverterOptions.php:
--------------------------------------------------------------------------------
1 | removeAllCoversAndIgnoreErrors($file);
33 |
34 | if ($tag->hasCoverFile()) {
35 | if (!file_exists($tag->cover)) {
36 | throw new Exception(sprintf("Provided cover file does not exist: %s", $file));
37 | }
38 | $command = ["--add", $tag->cover, $file];
39 | // $this->appendParameterToCommand($command, "-f", $this->optForce);
40 | $process = $this->runProcess($command);
41 |
42 | if ($process->getExitCode() !== 0) {
43 | throw new Exception(sprintf("Could not add cover to file: %s, %s, %d", $file, $process->getOutput() . $process->getErrorOutput(), $process->getExitCode()));
44 | }
45 | }
46 | }
47 |
48 | private function removeAllCoversAndIgnoreErrors(SplFileInfo $file)
49 | {
50 | $command = ["--remove", "--art-any", $file];
51 | $this->runProcess($command);
52 | }
53 |
54 | /**
55 | * @param SplFileInfo $audioFile
56 | * @param SplFileInfo|null $destinationFile
57 | * @param int $index
58 | * @return SplFileInfo|null
59 | * @throws Exception
60 | */
61 | public function exportCover(SplFileInfo $audioFile, SplFileInfo $destinationFile = null, $index = 0)
62 | {
63 | $index = (int)$index;
64 |
65 | $count = $this->countCovers($audioFile);
66 | if ($count === 0) {
67 | return null;
68 | }
69 | $this->runProcess([
70 | "--art-index", (string)$index,
71 | "--extract", $audioFile
72 | ]);
73 |
74 | $fileName = $audioFile->getBasename("." . $audioFile->getExtension());
75 |
76 | $extractedCoverPrefix = ltrim($audioFile->getPath() . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR) . $fileName . ".art[" . $index . "]";
77 | $extractedCoverCandidates = [
78 | new SplFileInfo($extractedCoverPrefix . ".jpg"),
79 | new SplFileInfo($extractedCoverPrefix . ".png"),
80 | ];
81 |
82 | $extractedCoverFile = null;
83 | foreach ($extractedCoverCandidates as $extractedCoverCandidate) {
84 | if ($extractedCoverCandidate->isFile()) {
85 | $extractedCoverFile = $extractedCoverCandidate;
86 | break;
87 | }
88 | }
89 | if ($extractedCoverFile === null) {
90 | throw new Exception(sprintf("exporting cover to %s failed", $extractedCoverFile));
91 | }
92 |
93 | if ($destinationFile === null) {
94 | return $extractedCoverFile;
95 | }
96 |
97 | if (!rename($extractedCoverFile, $destinationFile)) {
98 | @unlink($extractedCoverFile);
99 | throw new Exception(sprintf("renaming cover %s => %s failed", $extractedCoverFile, $destinationFile));
100 | }
101 | return $extractedCoverFile;
102 | }
103 |
104 | private function countCovers(SplFileInfo $audioFile)
105 | {
106 | $process = $this->runProcess([
107 | "--list", $audioFile
108 | ]);
109 | $output = $this->getAllProcessOutput($process);
110 | $header = "IDX BYTES CRC32 TYPE FILE
111 | ----------------------------------------------------------------------
112 | ";
113 | if (!str_contains($output, $header)) {
114 | return 0;
115 | }
116 |
117 | $trimmed = ltrim($header, $output);
118 | $lines = array_filter(explode("\n", $trimmed));
119 | return count($lines);
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/library/Executables/Mp4info.php:
--------------------------------------------------------------------------------
1 | inspectExactDuration($file);
29 | }
30 |
31 |
32 | /**
33 | * @param SplFileInfo $file
34 | * @return TimeUnit
35 | * @throws Exception
36 | */
37 | public function inspectExactDuration(SplFileInfo $file): TimeUnit
38 | {
39 | $process = $this->runProcess([$file]);
40 | $output = $process->getOutput() . $process->getErrorOutput();
41 |
42 | // 1 audio MPEG-4 AAC LC, 0.684 secs, 32 kbps, 44100 Hz
43 | preg_match("/([0-9]+\.[0-9]{3}) secs,/im", $output, $matches);
44 | if (isset($matches[1])) {
45 | return new TimeUnit($matches[1], TimeUnit::SECOND);
46 | }
47 |
48 | // duration: 19012 ms
49 | preg_match("/duration:[\s]+([0-9]+)\s+ms/im", $output, $matches);
50 | if (isset($matches[1])) {
51 | return new TimeUnit($matches[1], TimeUnit::MILLISECOND);
52 | }
53 |
54 | throw new Exception(sprintf("Could not detect length for file %s, output '%s' does not contain a valid length value", $file->getBasename(), $output));
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/library/Executables/Mp4v2Wrapper.php:
--------------------------------------------------------------------------------
1 | art = $art;
32 | $this->chaps = $chaps;
33 | $this->info = $info;
34 | $this->tags = $tags;
35 | }
36 |
37 | public function setLogger(LoggerInterface $logger)
38 | {
39 | $this->art->setLogger($logger);
40 | $this->chaps->setLogger($logger);
41 | $this->info->setLogger($logger);
42 | $this->tags->setLogger($logger);
43 | }
44 |
45 | public function setPlatformCharset($charset)
46 | {
47 | $this->art->setPlatformCharset($charset);
48 | $this->chaps->setPlatformCharset($charset);
49 | $this->info->setPlatformCharset($charset);
50 | $this->tags->setPlatformCharset($charset);
51 | }
52 |
53 | /**
54 | * @param SplFileInfo $file
55 | * @return TimeUnit
56 | * @throws Exception
57 | */
58 | public function estimateDuration(SplFileInfo $file): ?TimeUnit
59 | {
60 | return $this->info->estimateDuration($file);
61 | }
62 |
63 | /**
64 | * @param SplFileInfo $file
65 | * @param Tag $tag
66 | * @param Flags|null $flags
67 | * @throws Exception
68 | */
69 | public function writeTag(SplFileInfo $file, Tag $tag, Flags $flags = null)
70 | {
71 |
72 | $this->tags->writeTag($file, $tag, $flags);
73 | $this->chaps->writeTag($file, $tag, $flags);
74 | if ($tag->hasCoverFile() || in_array("cover", $tag->removeProperties, true)) {
75 | $this->art->writeTag($file, $tag, $flags);
76 | }
77 | }
78 |
79 | /**
80 | * @param SplFileInfo $file
81 | * @return TimeUnit
82 | * @throws Exception
83 | */
84 | public function inspectExactDuration(SplFileInfo $file): TimeUnit
85 | {
86 | return $this->info->inspectExactDuration($file);
87 | }
88 |
89 | /**
90 | * @param SplFileInfo $audioFile
91 | * @param SplFileInfo|null $destinationFile
92 | * @param int $index
93 | * @return SplFileInfo|null
94 | * @throws Exception
95 | */
96 | public function exportCover(SplFileInfo $audioFile, SplFileInfo $destinationFile = null, $index = 0)
97 | {
98 | return $this->art->exportCover($audioFile, $destinationFile, $index);
99 | }
100 |
101 | /**
102 | * @param Chapter[] $chapters
103 | * @return string
104 | */
105 | public function buildChaptersTxt(array $chapters)
106 | {
107 | return $this->chaps->buildChaptersTxt($chapters);
108 | }
109 |
110 | /**
111 | * @param string $chapterString
112 | * @return Chapter[]
113 | * @throws Exception
114 | */
115 | public function parseChaptersTxt(string $chapterString)
116 | {
117 | return $this->chaps->parseChaptersTxt($chapterString);
118 | }
119 |
120 |
121 | }
122 |
--------------------------------------------------------------------------------
/src/library/Executables/Process.php:
--------------------------------------------------------------------------------
1 | eventCallbacks[static::TERMINATED_CALLBACK_EVENT] = $this->eventCallbacks[static::TERMINATED_CALLBACK_EVENT] ?? [];
17 | $this->eventCallbacks[static::TERMINATED_CALLBACK_EVENT][] = $cb;
18 | }
19 |
20 | protected function updateStatus($blocking): void
21 | {
22 | // since getStatus is internally also calling updateStatus, this workaround prevents a recursion
23 | if ($this->disableStatusUpdate) {
24 | return;
25 | }
26 | $this->disableStatusUpdate = true;
27 | if ($this->getStatus() === static::STATUS_TERMINATED) {
28 | $this->runEventCallbacks(static::TERMINATED_CALLBACK_EVENT);
29 | }
30 | parent::updateStatus($blocking);
31 | $this->disableStatusUpdate = false;
32 | }
33 |
34 | private function runEventCallbacks($eventType)
35 | {
36 | $callbacks = $this->eventCallbacks[$eventType] ?? [];
37 | foreach ($callbacks as $callback) {
38 | $callback($this);
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/library/Executables/Tasks/AbstractTask.php:
--------------------------------------------------------------------------------
1 | weight = $weight;
34 | }
35 |
36 | public function getWeight()
37 | {
38 | return $this->weight;
39 | }
40 |
41 | public function isSkipped()
42 | {
43 | return $this->skipped;
44 | }
45 |
46 | public function skip()
47 | {
48 | $this->skipped = true;
49 | }
50 |
51 | public function finish()
52 | {
53 | $this->finished = true;
54 | }
55 |
56 | public function isFinished()
57 | {
58 | return $this->finished;
59 | }
60 |
61 | public function isFailed()
62 | {
63 | return $this->lastException instanceof Throwable;
64 | }
65 |
66 | public function getLastException()
67 | {
68 | return $this->lastException;
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/library/Executables/Tasks/ConversionTask.php:
--------------------------------------------------------------------------------
1 | metaDataHandler = $metaDataHandler;
43 | $this->options = $options;
44 |
45 | $this->finishedOutputFile = new SplFileInfo(str_replace(static::CONVERTING_SUFFIX, static::FINISHED_SUFFIX, $options->destination));
46 | $this->setLogger($logger);
47 | }
48 |
49 | public function run()
50 | {
51 | try {
52 | $this->lastException = null;
53 | if ($this->finishedOutputFile->isFile()) {
54 | $this->skip();
55 | return;
56 | }
57 |
58 | $this->process = $this->metaDataHandler->convertFile($this->options);
59 |
60 | } catch (Throwable $e) {
61 | if(!in_array($e->getMessage(), static::$reportedMessages, true)) {
62 | $this->error(sprintf("Conversion error: %s", $e->getMessage()));
63 | static::$reportedMessages[] = $e->getMessage();
64 | }
65 | }
66 |
67 | }
68 |
69 | public function isRunning()
70 | {
71 | if ($this->process) {
72 | return $this->process->isRunning();
73 | }
74 | return false;
75 | }
76 |
77 | public function getOptions()
78 | {
79 | return $this->options;
80 | }
81 |
82 | public function finish()
83 | {
84 | if (file_exists($this->options->destination)) {
85 | rename($this->options->destination, $this->finishedOutputFile);
86 | }
87 | $this->options->destination = $this->finishedOutputFile;
88 |
89 | foreach ($this->tmpFilesToCleanUp as $file) {
90 | @unlink($file);
91 | }
92 | parent::finish();
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/library/Executables/Tasks/RunnableInterface.php:
--------------------------------------------------------------------------------
1 | isDir()) {
22 | continue;
23 | }
24 | $currentExtension = mb_strtolower($current->getExtension());
25 | if (!in_array($currentExtension, $includeExtensions, true)) {
26 | continue;
27 | }
28 | $currentDirAsString = rtrim($current->getPath(), "/") . "/";
29 |
30 | foreach ($loadedDirs as $key => $loadedDir) {
31 | if (str_starts_with($currentDirAsString, $loadedDir)) {
32 | continue 2;
33 | }
34 | }
35 |
36 | // filter all dirs where parent = currentDirAsString
37 | $loadedDirs = array_filter($loadedDirs, function ($loadedDir) use ($currentDirAsString) {
38 | return !str_starts_with($loadedDir, $currentDirAsString);
39 | });
40 |
41 | $loadedDirs[] = $currentDirAsString;
42 |
43 | }
44 |
45 |
46 | return array_values(array_diff($loadedDirs, $excludeDirectories));
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/library/Filesystem/FileLoader.php:
--------------------------------------------------------------------------------
1 | includeExtensions = $includeExtensions;
30 | }
31 |
32 | public function current()
33 | {
34 | return current($this->files);
35 | }
36 |
37 | public function addNonRecursive(SplFileInfo $fileOrDirectory)
38 | {
39 | if ($fileOrDirectory->isDir()) {
40 | $this->addDirectory($fileOrDirectory, false);
41 | }
42 | $this->add($fileOrDirectory);
43 | }
44 |
45 | public function add(SplFileInfo $fileOrDirectory)
46 | {
47 | if (!$fileOrDirectory->isReadable()) {
48 | $this->skipFileOrDirectory($fileOrDirectory);
49 | return;
50 | }
51 |
52 | if ($fileOrDirectory->isDir()) {
53 | $this->addDirectory($fileOrDirectory, true);
54 | } else {
55 | $this->addFile($fileOrDirectory);
56 | }
57 | }
58 |
59 | private function skipFileOrDirectory(SplFileInfo $fileOrDirectory)
60 | {
61 | $this->skippedFiles[(string)$fileOrDirectory] = static::NOT_READABLE;
62 | }
63 |
64 | private function addDirectory(SplFileInfo $directory, $recursive)
65 | {
66 | if ($recursive) {
67 | $dir = new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS);
68 | $it = new RecursiveIteratorIterator($dir, RecursiveIteratorIterator::CHILD_FIRST);
69 | } else {
70 | $it = new FilesystemIterator($directory);
71 | }
72 | $filtered = new CallbackFilterIterator($it, function (SplFileInfo $current /*, $key, $iterator*/) {
73 | return in_array(mb_strtolower($current->getExtension()), $this->includeExtensions, true);
74 | });
75 | $this->addByIterator($filtered);
76 | }
77 |
78 | private function addByIterator(Iterator $filtered)
79 | {
80 | $files = [];
81 |
82 | /** @var SplFileInfo $itFile */
83 | foreach ($filtered as $itFile) {
84 | if ($itFile->isDir()) {
85 | continue;
86 | }
87 | if (!$itFile->isReadable()) {
88 | $this->skipFileOrDirectory($itFile);
89 | continue;
90 | }
91 |
92 | $files[] = $itFile;
93 | }
94 |
95 | $this->files = array_merge($this->files, $this->sortFilesByName($files));
96 | }
97 |
98 | private function sortFilesByName($files)
99 | {
100 | usort($files, function (SplFileInfo $a, SplFileInfo $b) {
101 | // normalize filenames for sorting
102 | $a = new SplFileInfo($a->getRealPath());
103 | $b = new SplFileInfo($b->getRealPath());
104 |
105 | if ($a->getPath() == $b->getPath()) {
106 | return strnatcmp($a->getBasename(), $b->getBasename());
107 | }
108 |
109 | $aParts = explode(DIRECTORY_SEPARATOR, $a);
110 | $aCount = count($aParts);
111 | $bParts = explode(DIRECTORY_SEPARATOR, $b);
112 | $bCount = count($bParts);
113 | if ($aCount != $bCount) {
114 | return $aCount - $bCount;
115 | }
116 |
117 | foreach ($aParts as $index => $part) {
118 | if ($part != $bParts[$index]) {
119 | return strnatcmp($part, $bParts[$index]);
120 | }
121 | }
122 |
123 | return strnatcmp($a, $b);
124 | });
125 | return $files;
126 | }
127 |
128 | private function addFile(SplFileInfo $file)
129 | {
130 | $this->files[] = $file;
131 | }
132 |
133 | public function getFiles()
134 | {
135 | return $this->files;
136 | }
137 |
138 | public function getSkippedFiles()
139 | {
140 | return $this->skippedFiles;
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/src/library/Parser/IndexStringParser.php:
--------------------------------------------------------------------------------
1 | parsePart($part)));
13 | }
14 | return $indexes;
15 | }
16 |
17 | private function parsePart($part)
18 | {
19 | // support negative numbers by using offset 1
20 | $dashIndex = strpos($part, "-", 1);
21 | if($dashIndex === false) {
22 | yield (int)$part;
23 | return;
24 | }
25 | $start = (int)substr($part, 0, $dashIndex);
26 | $end = (int)substr($part, $dashIndex+1);
27 |
28 | for($i=$start; $i<=$end;$i++) {
29 | yield $i;
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/library/Parser/MusicBrainzChapterParser.php:
--------------------------------------------------------------------------------
1 | mbId = $musicBrainzId;
22 | }
23 |
24 |
25 | public function setFileGetContentsCallback(callable $callback)
26 | {
27 | $this->fileGetContentsCallback = $callback;
28 | }
29 |
30 | /**
31 | * @param int $retries
32 | * @param int $pause
33 | * @param string $callback
34 | * @return mixed|string|string[]|null
35 | * @throws InvalidArgumentException
36 | * @throws Exception
37 | */
38 | public function loadRecordings($retries=5, $pause=100000, $callback = 'file_get_contents')
39 | {
40 |
41 | $cacheKey = "m4b-tool.chapter.json." . $this->mbId;
42 |
43 | $mbJson = $this->cacheAdapterGet($cacheKey, function () use ($retries, $pause, $callback) {
44 |
45 | for ($i = 0; $i < $retries; $i++) {
46 | $urlToGet = "http://musicbrainz.org/ws/2/work/" . $this->mbId . "?inc=recording-rels&fmt=json";
47 | $options = [
48 | 'http' => [
49 | 'method' => "GET",
50 | 'header' => "Accept-language: en\r\n" .
51 | "Cookie: foo=bar\r\n" . // check function.stream-context-create on php.net
52 | "User-Agent: Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B334b Safari/531.21.102011-10-16 20:23:10\r\n" // i.e. An iPad
53 | ]
54 | ];
55 |
56 | $context = stream_context_create($options);
57 | $headers = get_headers($urlToGet, false, $context);
58 | if (substr($headers[0], 9, 3) === "404") {
59 | $urlToGet = "http://musicbrainz.org/ws/2/release/" . $this->mbId . "?inc=recordings&fmt=json";
60 | $context = stream_context_create($options);
61 | }
62 |
63 | $mbJson = @call_user_func_array($callback, [$urlToGet, false, $context]);
64 | if ($mbJson) {
65 | return $mbJson;
66 | }
67 | usleep($pause);
68 | }
69 | return "";
70 | }, 86400);
71 |
72 |
73 | if ($mbJson === "") {
74 | throw new Exception("Could not load musicbrainz record for id: " . $this->mbId);
75 | }
76 |
77 | return $mbJson;
78 | }
79 |
80 | public function parseRecordings($chaptersString)
81 | {
82 | $decoded = json_decode($chaptersString, true);
83 | $recordings = $this->extractRecordingsFromDecodedJson($decoded);
84 | $totalLength = new TimeUnit(0, TimeUnit::MILLISECOND);
85 | $chapters = [];
86 | foreach ($recordings as $recording) {
87 | $length = new TimeUnit((int)$recording["length"], TimeUnit::MILLISECOND);
88 | $chapter = new Chapter(new TimeUnit($totalLength->milliseconds(), TimeUnit::MILLISECOND), $length, (string)$recording["title"]);
89 | $totalLength->add($length->milliseconds(), TimeUnit::MILLISECOND);
90 | $chapters[$chapter->getStart()->milliseconds()] = $chapter;
91 | }
92 | return $chapters;
93 | }
94 |
95 | private function extractRecordingsFromDecodedJson($decoded): array
96 | {
97 | if (!is_array($decoded)) {
98 | return [];
99 | }
100 | if (isset($decoded["recording"])) {
101 | return [$decoded["recording"]];
102 | }
103 | $recordings = [];
104 | foreach ($decoded as $value) {
105 | $recordings = array_merge($recordings, $this->extractRecordingsFromDecodedJson($value));
106 | }
107 | return $recordings;
108 | }
109 |
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/src/library/Parser/SilenceParser.php:
--------------------------------------------------------------------------------
1 | reset();
29 | $this->splitLines($silencesString);
30 | $this->parseLines();
31 | return $this->silences;
32 | }
33 |
34 | private function splitLines($chapterString)
35 | {
36 | $this->lines = preg_split("/\r\n|\n|\r/", $chapterString);
37 | }
38 |
39 | private function reset()
40 | {
41 | $this->silences = [];
42 | $this->lines = [];
43 | }
44 |
45 | /**
46 | * @throws Exception
47 | */
48 | private function parseLines()
49 | {
50 |
51 | foreach ($this->lines as $line) {
52 | $trimmedLine = trim($line);
53 |
54 | if(str_contains($trimmedLine, "Duration:")) {
55 | $this->parseDuration($trimmedLine);
56 | continue;
57 | }
58 | if (!str_contains($trimmedLine, "silence_end")) {
59 | continue;
60 | }
61 |
62 | preg_match("/^.*silence_end:[\s]+([0-9]+\.[0-9]+)[\s]+\|[\s]+silence_duration:[\s]+([0-9]+\.[0-9]+)$/i", $trimmedLine, $matches);
63 | if (count($matches) !== 3) {
64 | continue;
65 | }
66 |
67 | $end = new TimeUnit((float)$matches[1], TimeUnit::SECOND);
68 | $silenceDuration = new TimeUnit((float)$matches[2], TimeUnit::SECOND);
69 | $start = new TimeUnit($end->milliseconds() - $silenceDuration->milliseconds(), TimeUnit::MILLISECOND);
70 |
71 | $this->silences[$start->milliseconds()] = new Silence($start, $silenceDuration);
72 | }
73 | }
74 |
75 | public function getDuration()
76 | {
77 | return $this->duration;
78 | }
79 |
80 | /**
81 | * @param $trimmedLine
82 | * @throws Exception
83 | */
84 | private function parseDuration($trimmedLine)
85 | {
86 | preg_match('/[\s]*Duration:[\s]*([0-9.:]+)[\s]*.*/i', $trimmedLine, $matches);
87 | if(count($matches) == 2) {
88 | $this->duration = TimeUnit::fromFormat($matches[1]);
89 | }
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/src/library/StringUtilities/Scanner.php:
--------------------------------------------------------------------------------
1 | initialize($runes);
23 | }
24 |
25 | public function initialize(RuneList $runes = null)
26 | {
27 | $this->runes = $runes ?? new RuneList();
28 | $this->lastResult = new RuneList();
29 | $this->runes->rewind();
30 | }
31 |
32 | public function getResult()
33 | {
34 | return $this->lastResult;
35 | }
36 |
37 | public function getRemaining()
38 | {
39 | $remaining = $this->runes->slice($this->runes->key());
40 | $remaining->rewind();
41 | return $remaining;
42 | }
43 |
44 | /**
45 | * @return RuneList
46 | */
47 | public function getTrimmedResult()
48 | {
49 | $lastScanLength = mb_strlen($this->lastScan) * -1;
50 | $lastResultPart = (string)$this->lastResult->slice($lastScanLength);
51 | $lastScanString = (string)$this->lastScan;
52 | if ($lastResultPart === $lastScanString) {
53 | return $this->lastResult->slice(0, mb_strlen($this->lastScan) * -1);
54 | }
55 | return $this->lastResult;
56 | }
57 |
58 | public function scanLine($escapeChar = null)
59 | {
60 | if (!$this->seekFor(RuneList::LINE_FEED, $escapeChar, 1) && $this->lastResult === null) {
61 | return false;
62 | }
63 | $this->lastResult->end();
64 | $beforeLastRune = $this->lastResult->offset(static::OFFSET_RUNE_BEFORE_LAST);
65 | if ($beforeLastRune === RuneList::CARRIAGE_RETURN) {
66 | $this->lastScan = RuneList::CARRIAGE_RETURN . RuneList::LINE_FEED;
67 | }
68 | $this->lastResult->rewind();
69 | return true;
70 | }
71 |
72 | private function seekFor($seekString, $escapeSequence, $seekOffset)
73 | {
74 | $this->lastResult = null;
75 | if (!$this->runes->valid()) {
76 | return false;
77 | }
78 | $this->lastScan = $seekString;
79 | $length = null;
80 | $position = $this->runes->key();
81 |
82 | $stopRuneLength = mb_strlen($seekString);
83 | $escapeCharLength = $escapeSequence ? mb_strlen($escapeSequence) : 0;
84 | while ($this->runes->valid()) {
85 | $index = $this->runes->key();
86 | $rune = $this->runes->current();
87 | $this->runes->seek($index + $seekOffset);
88 |
89 | if ($stopRuneLength === 1 && $rune !== $seekString) {
90 | continue;
91 | } else if ($stopRuneLength > 1 && (string)$this->runes->slice($index, $stopRuneLength) !== $seekString) {
92 | continue;
93 | }
94 |
95 |
96 | if ($escapeSequence !== null && $index > $escapeCharLength && (string)$this->runes->slice($index - $escapeCharLength, $escapeCharLength) === $escapeSequence) {
97 | continue;
98 | }
99 |
100 | $length = $index - $position + $stopRuneLength;
101 | $this->runes->seek($index + $stopRuneLength);
102 | break;
103 | }
104 |
105 |
106 | $this->lastResult = $this->runes->slice($position, $length);
107 | $this->lastResult->rewind();
108 | return $length !== null;
109 | }
110 |
111 | public function scanToEnd()
112 | {
113 | if (!$this->runes->valid()) {
114 | return false;
115 | }
116 | $offset = $this->runes->key();
117 | $this->runes->end();
118 | $this->lastResult = $this->runes->slice($offset);
119 | return true;
120 | }
121 |
122 | public function scanForward(string $stopWordString, $escapeChar = null)
123 | {
124 | return $this->seekFor($stopWordString, $escapeChar, 1);
125 | }
126 |
127 | public function scanBackwards($stopWordString, $escapeChar = null)
128 | {
129 | return $this->seekFor($stopWordString, $escapeChar, -1);
130 | }
131 |
132 | public function reset()
133 | {
134 | $this->runes->rewind();
135 | $this->lastResult = $this->runes->slice(0);
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/src/library/Tags/StringBuffer.php:
--------------------------------------------------------------------------------
1 | original = $description;
16 | }
17 |
18 | private function truncateRunes($length)
19 | {
20 | if ($length <= 0) {
21 | return "";
22 | }
23 | if (mb_strlen($this->original) <= $length) {
24 | return $this->original;
25 | }
26 | return mb_substr($this->original, 0, $length);
27 | }
28 |
29 | private function truncateBytesHelper($length, $preserveWords = false)
30 | {
31 | $truncatedToRunes = $this->truncateRunes($length);
32 | if (strlen($truncatedToRunes) <= $length && !$preserveWords) {
33 | return $truncatedToRunes;
34 | }
35 |
36 | $runes = preg_split('//u', $truncatedToRunes, -1, PREG_SPLIT_NO_EMPTY);
37 | while ($lastChar = array_pop($runes)) {
38 |
39 | // runes still contain too many bytes
40 | if (strlen(implode("", $runes)) > $length) {
41 | continue;
42 | }
43 |
44 | // runes bytes are correct and words should not be preserved
45 | if (!$preserveWords) {
46 | break;
47 | }
48 |
49 | // words should be preserved and lastChar is a whitespace => end of word found
50 | if (trim($lastChar) !== $lastChar) {
51 | break;
52 | }
53 | }
54 |
55 |
56 | $result = implode("", $runes);
57 | // if preserving words results in an empty string, hard truncate is preferred
58 | if ($preserveWords && $result === "" && $this->original !== "") {
59 | return $this->truncateBytesHelper($length);
60 | }
61 | return $result;
62 | }
63 |
64 |
65 | public function byteLength()
66 | {
67 | return strlen($this->original);
68 | }
69 |
70 | public function softTruncateBytesSuffix($length, $suffix)
71 | {
72 | if (strlen($this->original) <= $length) {
73 | return $this->original;
74 | }
75 |
76 | $suffixLen = strlen($suffix);
77 | $truncated = $this->truncateBytesHelper($length - $suffixLen, true);
78 | return $truncated . $suffix;
79 | }
80 |
81 | public function __toString()
82 | {
83 | return $this->original;
84 | }
85 |
86 | }
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/ItunesMediaTypeTest.php:
--------------------------------------------------------------------------------
1 | 1,
13 | "3" => null,
14 | 23 => 23,
15 | "TvShow" => 10,
16 | "tvshow" => 10,
17 | null => null,
18 | "invalid-string" => null
19 | ];
20 | foreach ($expectationsArray as $input => $expected) {
21 | $actual = ItunesMediaType::parseInt($input);
22 | $this->assertEquals($expected,$actual, sprintf("failure on input:%s - expected: %s, actual: %s", $input, $expected, $actual));
23 | }
24 | }
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/Tag/AdjustChaptersByGroupLogicTest.php:
--------------------------------------------------------------------------------
1 | markTestSkipped("this test has to be fixed");
28 | // Todo:
29 | // - Reindexer should use the same format for followups (e.g. 1 (1/3) instead of 1.1, 1.2, etc.)
30 | // - Wendekreis der Schlangen does not seem to use the correct chapters
31 | /** @var BinaryWrapper $mockBinaryWrapper */
32 | $mockBinaryWrapper = m::mock(BinaryWrapper::class);
33 | /** @var SplFileInfo $mockFile */
34 | $mockFile = m::mock(SplFileInfo::class);
35 |
36 | $path = __DIR__ . "/chapter-packages/";
37 |
38 | $specificCase = "";
39 | // $specificCase = "2 - Zorn der Engel";
40 |
41 | $globPattern = $path . "/";
42 | if ($specificCase) {
43 | $globPattern .= $specificCase;
44 | } else {
45 | $globPattern .= "*";
46 | }
47 | $dirs = glob($globPattern);
48 |
49 | $mp4chaps = new Mp4chaps();
50 |
51 | foreach ($dirs as $dir) {
52 | if (is_file($dir)) {
53 | continue;
54 | }
55 | $silencesFile = $dir . "/all-silences.json";
56 | $chaptersFromFileTracksFile = $dir . "/ChaptersFromFileTracks-chapters.txt";
57 | $audibleChaptersJsonFile = $dir . "/audible_chapters.json";
58 | $expectedResultChaptersFile = $dir . "/expected-GroupLogic.chapters.txt";
59 |
60 | if (file_exists($silencesFile)) {
61 | $silences = array_map(function ($silenceArray) {
62 | return Silence::jsonDeserialize($silenceArray);
63 | }, json_decode(file_get_contents($silencesFile), true));
64 | } else {
65 | $silences = [];
66 | }
67 |
68 | $lengthCalculator = new ChapterLengthCalculator(function () use ($silences) {
69 | return $silences;
70 | }, new TimeUnit(static::DESIRED_CHAPTER_LENGTH, TimeUnit::SECOND), new TimeUnit(static::MAX_CHAPTER_LENGTH, TimeUnit::SECOND));
71 |
72 | $subject = new AdjustChaptersByGroupLogic($mockBinaryWrapper, $lengthCalculator, $mockFile);
73 |
74 |
75 | $tag = new Tag();
76 | $tag->chapters = $mp4chaps->parseChaptersTxt(file_get_contents($chaptersFromFileTracksFile));
77 |
78 | if (file_exists($audibleChaptersJsonFile)) {
79 | $unmodifiedLoader = new ContentMetadataJson(file_get_contents($audibleChaptersJsonFile));
80 | $audibleTag = $unmodifiedLoader->improve(new Tag());
81 |
82 | $audibleJsonLoader = AudibleChaptersJson::fromFile(new SplFileInfo($audibleChaptersJsonFile), null, null, $lengthCalculator);
83 | $tag = $audibleJsonLoader->improve($tag);
84 | }
85 |
86 | $tag = $subject->improve($tag);
87 |
88 | $this->assertEquals(trim(file_get_contents($expectedResultChaptersFile)), $mp4chaps->buildChaptersTxt($tag->chapters), "Test for " . $dir . " failed");
89 | }
90 |
91 | }
92 |
93 | }
94 |
95 |
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/Tag/BookBeatJsonTest.php:
--------------------------------------------------------------------------------
1 | improve(new Tag);
19 |
20 | $this->assertEquals("Tochter der Flammen", $actual->title);
21 | $this->assertEquals("A. L. Knorr", $actual->artist);
22 | $this->assertEquals("Luca Lehnert", $actual->writer);
23 | $this->assertEquals("Tochter der Flammen", $actual->album);
24 | $this->assertEquals("https://prod-bb-images.akamaized.net/book-covers/coverimage-4066004041186-zebralution-2021-09-02.jpg?w=400", (string)$actual->cover);
25 | $this->assertEquals("Vier Freundinnen mit übersinnlichen Fähigkeiten - Die Töchter der Elemente
26 |
27 | Band zwei der preisgekrönten Urban Fantasy Serie aus Kanada endlich auf Deutsch
28 | - Kann unabhängig von Band 1 gelesen werden
29 |
30 |
31 | Ein Kind des Feuers
32 |
33 | Ganz allein reist die abenteuerlustige Saxony den Sommer über nach Venedig. Sie soll sich dort als Au-pair um den kleinen Isaia kümmern. Allerdings hat ihre Gastfamilie Saxony verschwiegen, dass der Junge an einer mysteriösen Krankheit leidet: Isaias Stirn wird in Stresssituationen heiß wie Kohle und in seinen Augen strahlt eine unheimliche Glut.
34 |
35 | Doch Isaia ist nicht das einzige Mysterium, dem Saxony in Venedig begegnet. Sie trifft den undurchschaubaren Dante, den Sohn eines Mafiabosses. Doch was als aufregende Liebschaft beginnt, wird bald gefährlich. In größter Not eilt ausgerechnet der kranke Isaia Saxony zu Hilfe und überträgt eine einzigartige Fähigkeit auf sie.
36 |
37 | Mit ihren neuen Kräften muss Saxony Dante und entgegentreten. Doch sie fürchtet sich nicht mehr vor ihm.
38 | Denn sie ist eine Tochter des Feuers.
39 | Die Tochter der Flammen.
40 |
41 | Über die Töchter der Elemente - USA Today Bestseller
42 |
43 |
44 | Targa, Saxony, Georjayna und Akiko sind beste Freundinnen. Doch jede hat ein Geheimnis,
45 | das sie mit niemandem teilen kann. Denn sie sind die Töchter der Elemente - magische
46 | Wesen, die erst noch entdecken müssen, wie viel Macht in ihnen schlummert.
47 | Nach diesem Sommer wird nichts mehr so sein wie zuvor.", $actual->description);
48 |
49 | $this->assertEquals("German", $actual->language);
50 |
51 | $this->assertEquals("Der Ursprung der Elemente", $actual->series);
52 | $this->assertEquals("2", $actual->seriesPart);
53 | }
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/Tag/BuchhandelJsonTest.php:
--------------------------------------------------------------------------------
1 | Lass dich hineinziehen in eine Welt voller Träume Lazlo Strange liebt es, Geheimnisse zu ergründen und Abenteuer zu erleben. Allerdings nur zwischen den Seiten seiner Bücher, denn ansonsten erlebt der junge Bibliothekar nur wenig Aufregendes. Er ist ein Träumer und schwelgt am liebsten in den Geschichten um die sagenumwobene Stadt Weep - ein mysteriöser Ort, um den sich zahlreiche Geheimnisse ranken. Eines Tages werden Freiwillige für eine Reise nach Weep gesucht, und für Lazlo steht sofort fest, dass er sich der Gruppe anschließen muss. Ohne zu wissen, was sie in der verborgenen Stadt erwartet, machen sie sich auf den Weg. Wird Lazlos Traum nun endlich Wirklichkeit? Die international gefeierte Reihe der Bestsellerautorin Laini Taylor endlich auf Deutsch
","containsHTML":true}],"productIcon":"audio","titleShort":null,"prices":[{"value":11.99,"country":"DE","currency":"EUR","state":"02","type":"02","taxRate":"R","description":null,"minQuantity":null,"provisional":false,"typeQualifier":null,"priceReference":false,"fixedRetailPrice":false}],"publicationDate":"20190930","productType":"abook","measurements":"","identifier":"978-3-8387-9233-0","productFileFormat":null,"pricesAT":[{"value":11.99,"country":"AT","currency":"EUR","state":"02","type":"02","taxRate":"R","description":null,"minQuantity":null,"provisional":false,"typeQualifier":null,"priceReference":false,"fixedRetailPrice":false}],"oesbNr":null,"originalLanguage":"eng","coverUrl":"https://www.buchhandel.de/cover/9783838792330/9783838792330-cover-m.jpg","specialPriceText":null,"productFormId":"AJ","originalTitle":null,"publisher":"Lübbe Audio","contributors":[{"name":"Taylor, Laini","type":"A01","biographicalNote":null},{"name":"Franko, James","type":"A01","biographicalNote":null},{"name":"Pliquet, Moritz","type":"E07","biographicalNote":null},{"name":"Raimer-Nolte, Ulrike","type":"B06","biographicalNote":null}]},"relationships":{},"links":{"self":"/jsonapi/productDetails/9783838792330"}},"included":[]}
12 | EOT;
13 |
14 | public function testImprove()
15 | {
16 | $subject = new BuchhandelJson(static::SAMPLE_CONTENT);
17 | $actual = $subject->improve(new Tag);
18 |
19 | $this->assertEquals("Strange the Dreamer - Der Junge, der träumte", $actual->title);
20 | $this->assertEquals("James Franko, Laini Taylor", $actual->artist);
21 | $this->assertEquals("Moritz Pliquet", $actual->writer);
22 | $this->assertEquals("Strange the Dreamer - Der Junge, der träumte", $actual->album);
23 | $this->assertEquals("https://www.buchhandel.de/cover/9783838792330/9783838792330-cover-m.jpg", (string)$actual->cover);
24 | $this->assertEquals("Lass dich hineinziehen in eine Welt voller Träume
25 |
26 | Lazlo Strange liebt es, Geheimnisse zu ergründen und Abenteuer zu erleben. Allerdings nur zwischen den Seiten seiner Bücher, denn ansonsten erlebt der junge Bibliothekar nur wenig Aufregendes. Er ist ein Träumer und schwelgt am liebsten in den Geschichten um die sagenumwobene Stadt Weep - ein mysteriöser Ort, um den sich zahlreiche Geheimnisse ranken. Eines Tages werden Freiwillige für eine Reise nach Weep gesucht, und für Lazlo steht sofort fest, dass er sich der Gruppe anschließen muss. Ohne zu wissen, was sie in der verborgenen Stadt erwartet, machen sie sich auf den Weg. Wird Lazlos Traum nun endlich Wirklichkeit?
27 |
28 | Die international gefeierte Reihe der Bestsellerautorin Laini Taylor endlich auf Deutsch
29 | ", $actual->description);
30 |
31 | $this->assertEquals("ger", $actual->language);
32 |
33 | $this->assertEquals("Strange the Dreamer", $actual->series);
34 | $this->assertEquals("1", $actual->seriesPart);
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/Tag/ContentMetadataJsonTest.php:
--------------------------------------------------------------------------------
1 | improve(new Tag());
97 | $this->assertEquals($tag->extraProperties["audible_id"], "SAMPLEHASH");
98 | $this->assertCount(5, $tag->chapters);
99 | $this->assertEquals("Intro", $tag->chapters[0]->getName());
100 | $this->assertEquals("One", $tag->chapters[1]->getName());
101 | $this->assertEquals("Two", $tag->chapters[2]->getName());
102 | $this->assertEquals("Three", $tag->chapters[3]->getName());
103 | $this->assertEquals("Outro", $tag->chapters[4]->getName());
104 | }
105 |
106 | public function testLoadSubChapters()
107 | {
108 | $subject = new ContentMetadataJson(static::FILE_CONTENT_SUB_CHAPTERS);
109 | $tag = $subject->improve(new Tag());
110 | $this->assertCount(9, $tag->chapters);
111 | $this->assertEquals("Intro", $tag->chapters[0]->getName());
112 | $this->assertEquals(0, $tag->chapters[0]->getStart()->milliseconds());
113 |
114 | $this->assertEquals("Preludium", $tag->chapters[1]->getName());
115 | $this->assertEquals(4179, $tag->chapters[1]->getStart()->milliseconds());
116 |
117 | $this->assertEquals("A Chapter that has a name", $tag->chapters[2]->getName());
118 | $this->assertEquals(29093, $tag->chapters[2]->getStart()->milliseconds());
119 |
120 | $this->assertEquals("Another Chapter that has a name", $tag->chapters[3]->getName());
121 | $this->assertEquals(108435, $tag->chapters[3]->getStart()->milliseconds());
122 |
123 | $this->assertEquals("Part I - A Part name", $tag->chapters[4]->getName());
124 | $this->assertEquals(973889, $tag->chapters[4]->getStart()->milliseconds());
125 |
126 | $this->assertEquals("Chapter 1", $tag->chapters[5]->getName());
127 | $this->assertEquals(978789, $tag->chapters[5]->getStart()->milliseconds());
128 |
129 | $this->assertEquals("Chapter 2", $tag->chapters[6]->getName());
130 | $this->assertEquals(3707620, $tag->chapters[6]->getStart()->milliseconds());
131 |
132 | $this->assertEquals("Chapter 3", $tag->chapters[7]->getName());
133 | $this->assertEquals(6962755, $tag->chapters[7]->getStart()->milliseconds());
134 |
135 | $this->assertEquals("Outro", $tag->chapters[8]->getName());
136 | $this->assertEquals(8198335, $tag->chapters[8]->getStart()->milliseconds());
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/tests/M4bTool/Audio/Tag/EquateTest.php:
--------------------------------------------------------------------------------
1 | shouldReceive("mapOptionToTagProperty")->andReturnUsing(function ($tagProperty) {
24 | return $tagProperty;
25 | });
26 | $this->subject = new Equate($equateInstructions, $mockKeyMapper);
27 | }
28 |
29 | public function testImprove()
30 | {
31 | $tag = new Tag();
32 | $tag->album = "an album";
33 | $tag->title = "a name";
34 | $tag->artist = "an artist";
35 | $tag->albumArtist = "an album artist";
36 | $tag->sortArtist = "a sort artist";
37 |
38 | $tag = $this->subject->improve($tag);
39 |
40 | $this->assertEquals("an album", $tag->album);
41 | $this->assertEquals("an album", $tag->title);
42 |
43 | $this->assertEquals("an artist", $tag->artist);
44 | $this->assertEquals("an artist", $tag->albumArtist);
45 | $this->assertEquals("an artist", $tag->sortArtist);
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/tests/M4bTool/Chapter/ChapterShifterTest.php:
--------------------------------------------------------------------------------
1 | tmpChapters = [];
29 | $this->subject = new ChapterShifter();
30 | }
31 |
32 | public function testShiftChaptersPositive()
33 | {
34 | /**
35 | * @var Chapter[] $chapters
36 | */
37 | $chapters = [
38 | $this->createChapter("Chapter 1"),
39 | $this->createChapter("Chapter 2"),
40 | $this->createChapter("Chapter 3"),
41 | ];
42 |
43 | $lastChapter = end($chapters);
44 | $totalDurationMs = $lastChapter->getEnd()->milliseconds();
45 | $this->subject->shiftChapters($chapters, 3000);
46 | $this->assertCount(3, $chapters);
47 | $this->assertEquals(0, $chapters[0]->getStart()->milliseconds());
48 | $this->assertEquals(53000, $chapters[0]->getEnd()->milliseconds());
49 | $this->assertEquals(53000, $chapters[1]->getStart()->milliseconds());
50 | $this->assertEquals(47000, $chapters[2]->getLength()->milliseconds());
51 | $this->assertEquals($totalDurationMs, $chapters[2]->getEnd()->milliseconds());
52 | }
53 |
54 | public function testShiftChaptersNegative()
55 | {
56 | /**
57 | * @var Chapter[] $chapters
58 | */
59 | $chapters = [
60 | $this->createChapter("Chapter 1"),
61 | $this->createChapter("Chapter 2"),
62 | $this->createChapter("Chapter 3"),
63 | ];
64 |
65 | $lastChapter = end($chapters);
66 | $totalDurationMs = $lastChapter->getEnd()->milliseconds();
67 | $this->subject->shiftChapters($chapters, -3000);
68 | $this->assertCount(3, $chapters);
69 | $this->assertEquals(0, $chapters[0]->getStart()->milliseconds());
70 | $this->assertEquals(47000, $chapters[0]->getEnd()->milliseconds());
71 | $this->assertEquals(47000, $chapters[1]->getStart()->milliseconds());
72 | $this->assertEquals(50000, $chapters[1]->getLength()->milliseconds());
73 | $this->assertEquals(53000, $chapters[2]->getLength()->milliseconds());
74 | $this->assertEquals($totalDurationMs, $chapters[2]->getEnd()->milliseconds());
75 | }
76 |
77 | public function testShiftChaptersPositiveShortOutro()
78 | {
79 | /**
80 | * @var Chapter[] $chapters
81 | */
82 | $chapters = [
83 | $this->createChapter("Chapter 1"),
84 | $this->createChapter("Chapter 2"),
85 | $this->createChapter("Outro", null, 1500),
86 | ];
87 |
88 | $lastChapter = end($chapters);
89 | $totalDurationMs = $lastChapter->getEnd()->milliseconds();
90 | $this->subject->shiftChapters($chapters, 3000);
91 | $this->assertCount(3, $chapters);
92 | $this->assertEquals(0, $chapters[0]->getStart()->milliseconds());
93 | $this->assertEquals(1500, $chapters[2]->getLength()->milliseconds());
94 | $this->assertEquals($totalDurationMs, $chapters[2]->getEnd()->milliseconds());
95 | }
96 |
97 | private function createChapter($name, $start = null, $length = 50000, $introduction = null)
98 | {
99 | $lastChapter = end($this->tmpChapters);
100 | if($lastChapter !== false && $start === null) {
101 | $start = $lastChapter->getEnd()->milliseconds();
102 | }
103 | $start ??= 0;
104 |
105 | $chapter = new Chapter(new TimeUnit($start), new TimeUnit($length), $name);
106 | $chapter->setIntroduction($introduction);
107 | $this->tmpChapters[] = $chapter;
108 | return $chapter;
109 | }
110 |
111 |
112 | }
113 |
114 |
115 |
--------------------------------------------------------------------------------
/tests/M4bTool/Common/FlagsTest.php:
--------------------------------------------------------------------------------
1 | assertFalse($subject->contains(static::FLAG_ONE));
21 | $this->assertFalse($subject->contains(static::FLAG_TWO));
22 |
23 | $subject->insert(static::FLAG_TWO);
24 | $this->assertTrue($subject->contains(static::FLAG_TWO));
25 | $subject->insert(static::FLAG_ONE);
26 | $this->assertTrue($subject->contains(static::FLAG_ONE));
27 |
28 | $subject->remove(static::FLAG_TWO);
29 | $this->assertFalse($subject->contains(static::FLAG_TWO));
30 | $subject->remove(static::FLAG_ONE);
31 | $this->assertFalse($subject->contains(static::FLAG_ONE));
32 |
33 | }
34 |
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/tests/M4bTool/Executables/AbstractMp4v2ExecutableTest.php:
--------------------------------------------------------------------------------
1 | assertEquals("../test.chapters.txt", (string)$actual);
16 |
17 | $actual = AbstractMp4v2Executable::buildConventionalFileName($file, AbstractMp4v2Executable::SUFFIX_ART, "png", 1);
18 | $this->assertEquals("../test.art[1].png", (string)$actual);
19 |
20 | $otherFile = new SplFileInfo("merged.m4b");
21 | $actual = AbstractMp4v2Executable::buildConventionalFileName($otherFile, AbstractMp4v2Executable::SUFFIX_CHAPTERS, "txt");
22 | $this->assertEquals("merged.chapters.txt", (string)$actual);
23 |
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tests/M4bTool/Executables/FfmpegTest.php:
--------------------------------------------------------------------------------
1 | subject = new Ffmpeg();
30 | }
31 |
32 | public function testBuildConcatListing()
33 | {
34 |
35 | $this->assertEquals('', $this->subject->buildConcatListing(static::EMPTY_FILE_LIST));
36 | $expectedSimple = <<assertEquals($expectedSimple, $this->subject->buildConcatListing(static::SIMPLE_FILE_LIST));
43 | $expectedWithQuotes = <<assertEquals($expectedWithQuotes, $this->subject->buildConcatListing(static::FILE_LIST_WITH_SINGLE_QUOTES));
50 |
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/tests/M4bTool/Executables/Mp4chapsTest.php:
--------------------------------------------------------------------------------
1 | subject = new Mp4chaps();
25 | $this->chapters = [
26 | $this->createChapter(0, 500, "chapter 1"),
27 | $this->createChapter(500, 500, "chapter 2"),
28 | $this->createChapter(1000, 500, "chapter 3"),
29 | ];
30 | }
31 |
32 | public function testBuildChaptersTxt()
33 | {
34 | $this->assertEquals(static::CHAPTER_STRING, $this->subject->buildChaptersTxt($this->chapters));
35 | }
36 |
37 | /**
38 | * @throws Exception
39 | */
40 | public function parseChapterTxt()
41 | {
42 | $actual = $this->subject->parseChaptersTxt(static::CHAPTER_STRING);
43 | $this->assertCount(count($this->chapters), $actual);
44 | foreach ($this->chapters as $key => $chapter) {
45 | $this->assertEquals($chapter->getStart()->milliseconds(), $actual[$key]->getStart()->milliseconds());
46 | }
47 |
48 | }
49 |
50 |
51 | /**
52 | * @throws Exception
53 | */
54 | public function testParse()
55 | {
56 | $chapterString = '00:00:00.000 Chapter 1
57 | 00:00:22.198 Chapter 2
58 |
59 | 00:00:44.111 Chapter 3';
60 |
61 | $chapters = $this->subject->parseChaptersTxt($chapterString);
62 | $this->assertCount(3, $chapters);
63 | $this->assertEquals(0, key($chapters));
64 | $this->assertEquals(22198, current($chapters)->getLength()->milliseconds());
65 | $this->assertEquals("Chapter 1", current($chapters)->getName());
66 | next($chapters);
67 | $this->assertEquals(22198, key($chapters));
68 | $this->assertEquals(21913, current($chapters)->getLength()->milliseconds());
69 | $this->assertEquals("Chapter 2", current($chapters)->getName());
70 | next($chapters);
71 | $this->assertEquals(44111, key($chapters));
72 | $this->assertEquals(0, current($chapters)->getLength()->milliseconds());
73 | $this->assertEquals("Chapter 3", current($chapters)->getName());
74 | }
75 |
76 | /**
77 | * @throws Exception
78 | */
79 | public function testParseWithComments()
80 | {
81 | $chapterString = '
82 | # total-length 00:00:50.111
83 | 00:00:00.000 Chapter 1
84 | 00:00:22.198 Chapter 2
85 | # a comment
86 | 00:00:44.111 Chapter 3';
87 |
88 | $chapters = $this->subject->parseChaptersTxt($chapterString);
89 | $this->assertCount(3, $chapters);
90 | $this->assertEquals(0, key($chapters));
91 | $this->assertEquals(22198, current($chapters)->getLength()->milliseconds());
92 | $this->assertEquals("Chapter 1", current($chapters)->getName());
93 | next($chapters);
94 | $this->assertEquals(22198, key($chapters));
95 | $this->assertEquals(21913, current($chapters)->getLength()->milliseconds());
96 | $this->assertEquals("Chapter 2", current($chapters)->getName());
97 | next($chapters);
98 | $this->assertEquals(44111, key($chapters));
99 | $this->assertEquals("00:00:50.111", current($chapters)->getEnd()->format());
100 | $this->assertEquals("Chapter 3", current($chapters)->getName());
101 | }
102 |
103 | private function createChapter($start, $length, $name)
104 | {
105 | return new Chapter(new TimeUnit($start), new TimeUnit($length), $name);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/tests/M4bTool/Executables/Mp4infoTest.php:
--------------------------------------------------------------------------------
1 | mockProcess = m::mock(Process::class);
73 | $this->mockProcess->shouldReceive('getErrorOutput')->andReturn("");
74 | $this->mockProcess->shouldReceive('stop');
75 |
76 | $this->mockFile = new SplFileInfo(__FILE__);
77 | /** @var ProcessHelper|m\MockInterface $mockProcessHelper */
78 | $mockProcessHelper = m::mock(ProcessHelper::class);
79 | $mockProcessHelper->shouldReceive('run')->once()->andReturn($this->mockProcess);
80 |
81 | $this->subject = new Mp4info("mp4info", $mockProcessHelper);
82 | }
83 |
84 | /**
85 | * @throws Exception
86 | */
87 | public function testInspectDuration()
88 | {
89 | $this->mockProcess->shouldReceive("getOutput")->andReturn(static::SAMPLE_OUTPUT);
90 | $timeUnit = $this->subject->estimateDuration($this->mockFile);
91 | $this->assertEquals(640684, $timeUnit->milliseconds());
92 |
93 | $timeUnitExact = $this->subject->inspectExactDuration($this->mockFile);
94 | $this->assertEquals(640684, $timeUnitExact->milliseconds());
95 | }
96 |
97 | /**
98 | * @throws Exception
99 | */
100 | public function testInspectDurationWithShortLength()
101 | {
102 | $this->mockProcess->shouldReceive("getOutput")->andReturn(static::SAMPLE_OUTPUT_SHORT_LENGTH);
103 | $timeUnit = $this->subject->estimateDuration($this->mockFile);
104 | $this->assertEquals(684, $timeUnit->milliseconds());
105 |
106 | $timeUnitExact = $this->subject->inspectExactDuration($this->mockFile);
107 | $this->assertEquals(684, $timeUnitExact->milliseconds());
108 | }
109 |
110 | /**
111 | * @throws Exception
112 | */
113 | public function testInspectDurationWithMsLength()
114 | {
115 | $this->mockProcess->shouldReceive("getOutput")->andReturn(static::SAMPLE_OUTPUT_MS_LENGTH);
116 | $timeUnit = $this->subject->estimateDuration($this->mockFile);
117 | $this->assertEquals(19012, $timeUnit->milliseconds());
118 |
119 | $timeUnitExact = $this->subject->inspectExactDuration($this->mockFile);
120 | $this->assertEquals(19012, $timeUnitExact->milliseconds());
121 | }
122 |
123 |
124 | /*
125 | public function testInspectExactDurationException()
126 | {
127 | $this->expectException(Exception::class);
128 | $this->expectExceptionMessage("Could not detect length for file Mp4infoTest.php, output ".'""'." does not contain a valid length value");
129 | $this->mockProcess->shouldReceive("getOutput")->andReturn("");
130 | $this->subject->estimateDuration($this->mockFile);
131 | }
132 | */
133 | }
134 |
--------------------------------------------------------------------------------
/tests/M4bTool/Filesystem/DirectoryLoaderTest.php:
--------------------------------------------------------------------------------
1 | [
22 | 'audiobook 1' => [
23 | 'cd 1' => [
24 | "01.mp3" => "",
25 | "02.mp3" => ""
26 | ],
27 | 'cd 2' => [
28 | "01.ogg" => "",
29 | "02.ogg" => "",
30 | ],
31 | 'cover.jpg' => '',
32 | ],
33 | 'audiobook 2' => [
34 | "01.mp3" => "",
35 | "02.mp3" => ""
36 | ],
37 |
38 | 'others' => [
39 | 'audiobook 3' => [
40 | 'cover.jpg' => '',
41 | 'cd 1' => [
42 | "01.mp3" => "",
43 | "02.mp3" => ""
44 | ],
45 | 'cd 2' => [
46 | "01.ogg" => "",
47 | "02.ogg" => "",
48 | ],
49 | ],
50 | ],
51 | 'an_empty_folder' => [],
52 | ],
53 |
54 | 'file.txt' => 'filecontent',
55 | 'file.jpg' => '',
56 |
57 | ];
58 | $this->vfs = vfsStream::setup('root', null, $structure);
59 |
60 | $this->subject = new DirectoryLoader();
61 | }
62 |
63 | /**
64 | *
65 | */
66 | public function testLoad()
67 | {
68 | $actual = $this->subject->load($this->vfs->url() . "/audiobooks", static::INCLUDE_EXTENSIONS);
69 |
70 |
71 | $expected = [
72 | $this->vfs->url() . "/audiobooks/audiobook 1/",
73 | $this->vfs->url() . "/audiobooks/audiobook 2/",
74 | $this->vfs->url() . "/audiobooks/others/audiobook 3/",
75 | ];
76 | $this->assertEquals($expected, $actual);
77 | }
78 |
79 | /**
80 | *
81 | */
82 | public function testLoadWithExcludeDirs()
83 | {
84 | $excludeDirs = [
85 | $this->vfs->url() . "/audiobooks/audiobook 1/",
86 | $this->vfs->url() . "/audiobooks/audiobook 2/",
87 | ];
88 | $actual = $this->subject->load($this->vfs->url() . "/audiobooks", static::INCLUDE_EXTENSIONS, $excludeDirs);
89 |
90 |
91 | $expected = [
92 | $this->vfs->url() . "/audiobooks/others/audiobook 3/",
93 | ];
94 | $this->assertEquals($expected, $actual);
95 | }
96 |
97 | public function testLoadWithSingleAudioBookStructure()
98 | {
99 | $structure = [
100 | 'input' => [
101 | 'Fantasy' => [
102 | 'John Doe' => [
103 | '.DS_Store' => "",
104 | 'Doetown' => [
105 | "cover.jpg" => "",
106 | "doetown.mp3" => ""
107 | ],
108 | ],
109 | ],
110 | ],
111 | ];
112 | $vfs = vfsStream::setup('root', null, $structure);
113 | $actual = $this->subject->load($vfs->url() . "/input/", static::INCLUDE_EXTENSIONS);
114 | $expected = [
115 | $vfs->url() . "/input/Fantasy/John Doe/Doetown/",
116 | ];
117 | $this->assertEquals($expected, $actual);
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/tests/M4bTool/Parser/IndexStringParserTest.php:
--------------------------------------------------------------------------------
1 | subject = new IndexStringParser();
21 | }
22 |
23 |
24 | public function testParseSingleNumber()
25 | {
26 | $actual = $this->subject->parse("1");
27 | $this->assertEquals([1], $actual);
28 | }
29 |
30 | public function testParseCommaSeparated()
31 | {
32 | $actual = $this->subject->parse("1,3,5,7,8");
33 | $this->assertEquals([1, 3, 5, 7, 8], $actual);
34 | }
35 |
36 | public function testParseDashed()
37 | {
38 | $actual = $this->subject->parse("1-5");
39 | $this->assertEquals([1, 2, 3, 4, 5], $actual);
40 | }
41 |
42 |
43 | public function testParseCombined()
44 | {
45 | $actual = $this->subject->parse("1,3-5,8-10,18,20");
46 | $this->assertEquals([1, 3, 4, 5, 8, 9, 10, 18, 20], $actual);
47 | }
48 |
49 | public function testParseInvalid()
50 | {
51 | $actual = $this->subject->parse("8-5");
52 | $this->assertEquals([], $actual);
53 | }
54 |
55 | public function testParseNegative()
56 | {
57 | $actual = $this->subject->parse("-5--3");
58 | $this->assertEquals([-5, -4, -3], $actual);
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tests/M4bTool/Parser/SilenceParserTest.php:
--------------------------------------------------------------------------------
1 | subject = new SilenceParser();
21 | }
22 |
23 | /**
24 | * @throws Exception
25 | */
26 | public function testParse() {
27 | $chapterString = "
28 | Duration: 13:35:02.34, start: 0.000000, bitrate: 64 kb/s
29 | [silencedetect @ 04e4c640] silence_end: 19.9924 | silence_duration: 4.27556
30 | [silencedetect @ 04e4c640] silence_start: 80.6166
31 | [silencedetect @ 04e4c640] silence_end: 84.7528 | silence_duration: 4.13624
32 | frame= 1 fps=0.0 q=-0.0 size=N/A time=00:03:41.72 bitrate=N/A
33 | [silencedetect @ 04e4c640] silence_start: 261.848
34 | [silencedetect @ 04e4c640] silence_end: 264.591 | silence_duration: 2.74304
35 | frame= 1 fps=1.0 q=-0.0 size=N/A time=00:07:18.71 bitrate=N/A
36 | [silencedetect @ 04e4c640] silence_start: 546.618
37 | [silencedetect @ 04e4c640] silence_end: 548.664 | silence_duration: 2.04644
38 | [silencedetect @ 04e4c640] silence_start: 566.842
39 | ";
40 |
41 | $silences = $this->subject->parse($chapterString);
42 | $this->assertCount(4, $silences);
43 | $this->assertEquals(15716, key($silences));
44 | $this->assertEqualsWithDelta(4275.56, current($silences)->getLength()->milliseconds(), 0.01);
45 | $this->assertInstanceOf(TimeUnit::class, $this->subject->getDuration());
46 | $this->assertEquals(48902340, $this->subject->getDuration()->milliseconds());
47 |
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/tests/M4bTool/StringUtilities/ScannerTest.php:
--------------------------------------------------------------------------------
1 | assertTrue($subject->scanLine());
23 | $this->assertEquals("😋 this is a testing", (string)$subject->getTrimmedResult());
24 | $this->assertTrue($subject->scanLine());
25 | $this->assertEquals("string= with unicode", (string)$subject->getTrimmedResult());
26 | $this->assertTrue($subject->scanLine());
27 | $this->assertEquals("äß öü € and emojis", (string)$subject->getTrimmedResult());
28 | }
29 |
30 | public function testScanLineEnd()
31 | {
32 | $mp3MetaData = <<assertTrue($subject->scanLine());
38 | }
39 |
40 | public function testScanLineWithEscapeChar()
41 | {
42 | $subject = new Scanner(new RuneList(static::UNICODE_STRING_CRLF_ESCAPED));
43 | $subject->scanLine("\\");
44 | $this->assertEquals("😋 this is a string", (string)$subject->getTrimmedResult());
45 | $subject->scanLine("\\");
46 | $this->assertEquals("with escaped\\\nline breaks", (string)$subject->getTrimmedResult());
47 | }
48 |
49 | public function testScanForward()
50 | {
51 | $subject = new Scanner(new RuneList(static::UNICODE_STRING_CRLF));
52 | $subject->ScanForward("=");
53 | $this->assertEquals("😋 this is a testing\r\nstring", (string)$subject->getTrimmedResult());
54 | $this->assertEquals("😋 this is a testing\r\nstring=", (string)$subject->getResult());
55 | }
56 |
57 | public function testScanForwardMultiRune()
58 | {
59 | $subject = new Scanner(new RuneList(static::UNICODE_STRING_MULTI_RUNE));
60 | $subject->scanForward("this");
61 | $this->assertEquals("", (string)$subject->getTrimmedResult());
62 | $this->assertEquals("this", (string)$subject->getResult());
63 |
64 | $subject->scanForward("this");
65 | $this->assertEquals(" ", (string)$subject->getTrimmedResult());
66 |
67 | $subject->scanForward("this");
68 | $this->assertEquals(" another ", (string)$subject->getTrimmedResult());
69 | }
70 |
71 |
72 | // public function testScanBackwardSingleRune() {
73 | // $subject = new Scanner(new RuneList(static::UNICODE_STRING_CRLF));
74 | // $subject->scanLine();
75 | // $subject->scanBackwards("is");
76 | // $this->assertEquals(" a testing\r\nstring= with unicode\näß öü € and emojis", (string)$subject->getTrimmedResult());
77 | // }
78 |
79 |
80 | }
81 |
--------------------------------------------------------------------------------
/tests/M4bTool/Tags/StringBufferTest.php:
--------------------------------------------------------------------------------
1 | assertEquals(static::EMPTY_STRING, (string)$subject);
21 | $this->assertEquals(static::EMPTY_STRING, $subject->softTruncateBytesSuffix(static::DEFAULT_LENGTH, static::DEFAULT_SUFFIX));
22 | }
23 |
24 | public function testSoftTruncateBytesSuffixWithShortString()
25 | {
26 | $subject = new StringBuffer(static::SHORT_STRING);
27 | $this->assertEquals(static::SHORT_STRING, (string)$subject);
28 | $this->assertEquals(static::SHORT_STRING, $subject->softTruncateBytesSuffix(static::DEFAULT_LENGTH, static::DEFAULT_SUFFIX));
29 | }
30 |
31 | public function testSoftTruncateBytesSuffixWithLongString()
32 | {
33 | $subject = new StringBuffer(static::LONG_STRING);
34 | $this->assertEquals(static::LONG_STRING, (string)$subject);
35 | $this->assertEquals("this is a ...", $subject->softTruncateBytesSuffix(static::DEFAULT_LENGTH, static::DEFAULT_SUFFIX));
36 | }
37 |
38 | public function testSoftTruncateBytesSuffixWithLongUnicodeString()
39 | {
40 | $subject = new StringBuffer(static::LONG_UNICODE_STRING);
41 | $this->assertEquals(29, $subject->byteLength());
42 | $this->assertEquals("€ äü ...", $subject->softTruncateBytesSuffix(static::DEFAULT_LENGTH, static::DEFAULT_SUFFIX));
43 | }
44 |
45 | public function testSoftTruncateBytesSuffixWithLongUnicodeWord()
46 | {
47 | $subject = new StringBuffer(static::LONG_UNICODE_WORD);
48 | $this->assertEquals(21, $subject->byteLength());
49 | $this->assertEquals("€äüasdf ...", $subject->softTruncateBytesSuffix(static::DEFAULT_LENGTH, static::DEFAULT_SUFFIX));
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/tools/build-homepage-template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | m4b-tool - a tool to merge, split or and manipulate m4b audiobooks with chapters
11 |
12 |
13 |
14 |
15 |
29 |
30 |
31 |
32 |
33 |
34 | m4b-tool
35 |
37 |
38 |
39 |
40 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
Contact
63 |
64 | Andreas Fuhrich
65 | Schröderstr. 32
66 | 69120 Heidelberg
67 | Baden-Württemberg
68 | Germany
69 | Send me an email:
70 | Or open an issue: https://github.com/sandreas/m4b-tool/issues
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
--------------------------------------------------------------------------------
/tools/build-homepage.php:
--------------------------------------------------------------------------------
1 | loadHTML($html);
10 |
11 | $xpath = new DOMXPath($doc);
12 | $bodyNode = $xpath->query('//article')->item(0);
13 |
14 | $readmeHtml = $doc->saveHTML($bodyNode);
15 |
16 | $templateFile = __DIR__ . "/build-homepage-template.html";
17 | $template = file_get_contents($templateFile);
18 |
19 | $replacements = [
20 | "