├── .clang-format ├── .clang-tidy ├── .clangd ├── .github ├── renovate.json └── workflows │ └── test.yaml ├── .gitignore ├── .gitmodules ├── LICENSE ├── contrib ├── readme.md └── systemd │ ├── ashuffle.service │ └── readme.md ├── contributing.md ├── meson.build ├── meson_options.txt ├── readme.md ├── scripts ├── build-test-image ├── check-format ├── cut-release ├── format ├── github │ ├── check-format │ ├── common.sh │ ├── lint │ ├── release │ ├── resolve-versions │ └── unit-test ├── run-clang-tidy └── run-integration ├── src ├── args.cc ├── args.h ├── ashuffle.cc ├── ashuffle.h ├── getpass.cc ├── getpass.h ├── load.cc ├── load.h ├── log.cc ├── log.h ├── log_internal.h ├── main.cc ├── mpd.h ├── mpd_client.cc ├── mpd_client.h ├── rule.cc ├── rule.h ├── shuffle.cc ├── shuffle.h ├── util.h ├── version.cc.in └── version.h ├── subprojects └── yaml-cpp.wrap ├── t ├── args_test.cc ├── ashuffle_test.cc ├── docker │ ├── Dockerfile.ubuntu │ ├── install_go.sh │ ├── patches │ │ ├── mpd │ │ │ ├── 0.21.20 │ │ │ │ └── 0001-Support-newer-C-stdlibs.patch │ │ │ └── 0.23.5 │ │ │ │ └── 0001-Support-newer-libc.patch │ │ └── retain.md │ └── run_integration.sh ├── helper.h ├── integration │ ├── go.mod │ ├── go.sum │ ├── integration │ │ └── integration_test.go │ ├── testashuffle │ │ └── testashuffle.go │ └── testmpd │ │ └── testmpd.go ├── load_test.cc ├── log_test.cc ├── mpd_fake.h ├── mpd_fake_test.cc ├── readme.md ├── rule_test.cc ├── shuffle_test.cc ├── static │ └── mpd.conf └── test_asserts.h └── tools ├── cmake └── inject_project_source_dir.cmake └── meta ├── commands ├── libmpdclient │ └── libmpdclient.go ├── mpd │ └── mpd.go ├── release │ └── release.go ├── resolveversions │ └── resolve_versions.go └── testbuild │ └── testbuild.go ├── crosstool └── crosstool.go ├── exec └── exec.go ├── fetch └── fetch.go ├── fileutil └── fileutil.go ├── go.mod ├── go.sum ├── meta.go ├── project └── project.go ├── semver └── semver.go ├── versions ├── libmpdclientver │ └── libmpdclientver.go └── mpdver │ └── mpdver.go └── workspace └── workspace.go /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | # BasedOnStyle: Google 4 | AccessModifierOffset: -1 5 | AlignAfterOpenBracket: Align 6 | AlignConsecutiveAssignments: false 7 | AlignConsecutiveDeclarations: false 8 | AlignEscapedNewlines: Left 9 | AlignOperands: true 10 | AlignTrailingComments: true 11 | AllowAllParametersOfDeclarationOnNextLine: true 12 | AllowShortBlocksOnASingleLine: false 13 | AllowShortCaseLabelsOnASingleLine: false 14 | AllowShortFunctionsOnASingleLine: All 15 | AllowShortIfStatementsOnASingleLine: true 16 | AllowShortLoopsOnASingleLine: true 17 | AlwaysBreakAfterDefinitionReturnType: None 18 | AlwaysBreakAfterReturnType: None 19 | AlwaysBreakBeforeMultilineStrings: true 20 | AlwaysBreakTemplateDeclarations: Yes 21 | BinPackArguments: true 22 | BinPackParameters: true 23 | BraceWrapping: 24 | AfterClass: false 25 | AfterControlStatement: false 26 | AfterEnum: false 27 | AfterFunction: false 28 | AfterNamespace: false 29 | AfterObjCDeclaration: false 30 | AfterStruct: false 31 | AfterUnion: false 32 | AfterExternBlock: false 33 | BeforeCatch: false 34 | BeforeElse: false 35 | IndentBraces: false 36 | SplitEmptyFunction: true 37 | SplitEmptyRecord: true 38 | SplitEmptyNamespace: true 39 | BreakBeforeBinaryOperators: None 40 | BreakBeforeBraces: Attach 41 | BreakBeforeInheritanceComma: false 42 | BreakInheritanceList: BeforeColon 43 | BreakBeforeTernaryOperators: true 44 | BreakConstructorInitializersBeforeComma: false 45 | BreakConstructorInitializers: BeforeColon 46 | BreakAfterJavaFieldAnnotations: false 47 | BreakStringLiterals: true 48 | ColumnLimit: 80 49 | CommentPragmas: '^ IWYU pragma:' 50 | CompactNamespaces: false 51 | ConstructorInitializerAllOnOneLineOrOnePerLine: true 52 | ConstructorInitializerIndentWidth: 4 53 | ContinuationIndentWidth: 4 54 | Cpp11BracedListStyle: true 55 | DerivePointerAlignment: true 56 | DisableFormat: false 57 | ExperimentalAutoDetectBinPacking: false 58 | FixNamespaceComments: true 59 | ForEachMacros: 60 | - foreach 61 | - Q_FOREACH 62 | - BOOST_FOREACH 63 | IncludeBlocks: Preserve 64 | IncludeCategories: 65 | - Regex: '^' 66 | Priority: 2 67 | - Regex: '^<.*\.h>' 68 | Priority: 1 69 | - Regex: '^<.*' 70 | Priority: 2 71 | - Regex: '.*' 72 | Priority: 3 73 | IncludeIsMainRegex: '([-_](test|unittest))?$' 74 | IndentCaseLabels: true 75 | IndentPPDirectives: None 76 | IndentWidth: 4 77 | IndentWrappedFunctionNames: false 78 | JavaScriptQuotes: Leave 79 | JavaScriptWrapImports: true 80 | KeepEmptyLinesAtTheStartOfBlocks: false 81 | MacroBlockBegin: '' 82 | MacroBlockEnd: '' 83 | MaxEmptyLinesToKeep: 1 84 | NamespaceIndentation: None 85 | ObjCBinPackProtocolList: Never 86 | ObjCBlockIndentWidth: 2 87 | ObjCSpaceAfterProperty: false 88 | ObjCSpaceBeforeProtocolList: true 89 | PenaltyBreakAssignment: 2 90 | PenaltyBreakBeforeFirstCallParameter: 1 91 | PenaltyBreakComment: 300 92 | PenaltyBreakFirstLessLess: 120 93 | PenaltyBreakString: 1000 94 | PenaltyBreakTemplateDeclaration: 10 95 | PenaltyExcessCharacter: 1000000 96 | PenaltyReturnTypeOnItsOwnLine: 200 97 | PointerAlignment: Left 98 | RawStringFormats: 99 | - Language: Cpp 100 | Delimiters: 101 | - cc 102 | - CC 103 | - cpp 104 | - Cpp 105 | - CPP 106 | - 'c++' 107 | - 'C++' 108 | CanonicalDelimiter: '' 109 | BasedOnStyle: google 110 | - Language: TextProto 111 | Delimiters: 112 | - pb 113 | - PB 114 | - proto 115 | - PROTO 116 | EnclosingFunctions: 117 | - EqualsProto 118 | - EquivToProto 119 | - PARSE_PARTIAL_TEXT_PROTO 120 | - PARSE_TEST_PROTO 121 | - PARSE_TEXT_PROTO 122 | - ParseTextOrDie 123 | - ParseTextProtoOrDie 124 | CanonicalDelimiter: '' 125 | BasedOnStyle: google 126 | ReflowComments: true 127 | SortIncludes: true 128 | SortUsingDeclarations: true 129 | SpaceAfterCStyleCast: false 130 | SpaceAfterTemplateKeyword: true 131 | SpaceBeforeAssignmentOperators: true 132 | SpaceBeforeCpp11BracedList: false 133 | SpaceBeforeCtorInitializerColon: true 134 | SpaceBeforeInheritanceColon: true 135 | SpaceBeforeParens: ControlStatements 136 | SpaceBeforeRangeBasedForLoopColon: true 137 | SpaceInEmptyParentheses: false 138 | SpacesBeforeTrailingComments: 2 139 | SpacesInAngles: false 140 | SpacesInContainerLiterals: true 141 | SpacesInCStyleCastParentheses: false 142 | SpacesInParentheses: false 143 | SpacesInSquareBrackets: false 144 | Standard: Auto 145 | TabWidth: 8 146 | UseTab: Never 147 | ... 148 | 149 | -------------------------------------------------------------------------------- /.clang-tidy: -------------------------------------------------------------------------------- 1 | --- 2 | Checks: 'clang-diagnostic-*,clang-analyzer-*,-clang-analyzer-valist.Uninitialized,-clang-analyzer-security.insecureAPI.*' 3 | WarningsAsErrors: '' 4 | HeaderFilterRegex: '' 5 | AnalyzeTemporaryDtors: false 6 | FormatStyle: none 7 | User: josh 8 | CheckOptions: 9 | - key: google-readability-braces-around-statements.ShortStatementLines 10 | value: '1' 11 | - key: google-readability-function-size.StatementThreshold 12 | value: '800' 13 | - key: google-readability-namespace-comments.ShortNamespaceLines 14 | value: '10' 15 | - key: google-readability-namespace-comments.SpacesBeforeComments 16 | value: '2' 17 | - key: modernize-loop-convert.MaxCopySize 18 | value: '16' 19 | - key: modernize-loop-convert.MinConfidence 20 | value: reasonable 21 | - key: modernize-loop-convert.NamingStyle 22 | value: CamelCase 23 | - key: modernize-pass-by-value.IncludeStyle 24 | value: llvm 25 | - key: modernize-replace-auto-ptr.IncludeStyle 26 | value: llvm 27 | - key: modernize-use-nullptr.NullMacros 28 | value: 'NULL' 29 | ... 30 | 31 | -------------------------------------------------------------------------------- /.clangd: -------------------------------------------------------------------------------- 1 | CompileFlags: 2 | CompilationDatabase: build 3 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":disableDependencyDashboard", 5 | ":timezone(America/Los_Angeles)", 6 | "group:allNonMajor" 7 | ], 8 | "git-submodules": { 9 | "enabled": true, 10 | "extends": [ 11 | "schedule:monthly" 12 | ] 13 | }, 14 | "golang": { 15 | "postUpdateOptions": [ 16 | "gomodTidy" 17 | ] 18 | }, 19 | "regexManagers": [ 20 | { 21 | "fileMatch": [ 22 | "^subprojects/[^/]+\\.wrap$" 23 | ], 24 | "matchStrings": [ 25 | "# renovate: datasource=(?[a-z-]+?) depName=(?[^\\s]+?)(?: lookupName=(?[^\\s]+?))?(?: versioning=(?[a-z-0-9]+?))?\\srevision = (?.+?)\\s" 26 | ] 27 | }, 28 | { 29 | "fileMatch": [ 30 | "^scripts/github/common\\.sh$" 31 | ], 32 | "matchStrings": [ 33 | "# renovate: datasource=(?[a-z-]+?) depName=(?[^\\s]+?)(?: lookupName=(?[^\\s]+?))?(?: versioning=(?[a-z-0-9]+?))?\\s[^=]+=\"(?.+?)\"\\s" 34 | ] 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | push: 7 | branches: 8 | - master 9 | tags: 10 | - v* 11 | schedule: 12 | - cron: 6 22 * * FRI 13 | 14 | jobs: 15 | check-format: 16 | name: Check Format 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Clone 20 | uses: actions/checkout@v4 21 | - name: Check 22 | run: scripts/github/check-format 23 | check-lint: 24 | name: Check Lint 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Clone 28 | uses: actions/checkout@v4 29 | - name: Lint 30 | run: scripts/github/lint 31 | unit-test: 32 | strategy: 33 | matrix: 34 | sanitizer: ["none", "asan"] 35 | include: 36 | - sanitizer: asan 37 | name_suffix: " (ASAN)" 38 | name: Unit Test${{ matrix.name_suffix }} 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Clone 42 | uses: actions/checkout@v4 43 | - name: Unit Test 44 | env: 45 | SANITIZER: ${{ matrix.sanitizer }} 46 | run: scripts/github/unit-test 47 | build-integration-test-images: 48 | strategy: 49 | matrix: 50 | target: 51 | - name: "Latest" 52 | libmpdclient_version: "latest" 53 | mpd_version: "latest" 54 | - name: "Noble" 55 | libmpdclient_version: "2.22" 56 | mpd_version: "0.23.14" 57 | - name: "Jammy" 58 | libmpdclient_version: "2.20" 59 | mpd_version: "0.23.5" 60 | - name: "Focal" 61 | libmpdclient_version: "2.18" 62 | mpd_version: "0.21.20" 63 | name: "Build ${{ matrix.target.name }} Integration Test Container" 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | - uses: actions/setup-go@v5 68 | with: 69 | go-version: 'stable' 70 | cache-dependency-path: tools/meta/go.sum 71 | - uses: docker/setup-buildx-action@v3 72 | # This whole little song and dance is pretty annoying. Ideally I could 73 | # just use ./scripts/run-integration and be done with it, but to get 74 | # caching, I need to use the build-push-action. This is because gha 75 | # only exposes the necessary values to the node runtime, not via a script 76 | # consumable mechanism. There is a bug out for this, so hopefully this 77 | # can all be deleted eventually: https://github.com/actions/runner/issues/3046 78 | # 79 | # We still need to run "build-test-image" here to do the little tarring 80 | # step. Maybe I can delete that, and just go straight to build-push-action? 81 | - name: Stage Build 82 | id: stage 83 | env: 84 | MPD_VERSION: ${{ matrix.target.mpd_version }} 85 | LIBMPDCLIENT_VERSION: ${{ matrix.target.libmpdclient_version }} 86 | run: | 87 | git submodule update --init --recursive 88 | # produces the outputs used in the subseqent step 89 | scripts/github/resolve-versions 90 | - name: Build Integration Test Container 91 | uses: docker/build-push-action@v6 92 | with: 93 | context: . 94 | file: t/docker/Dockerfile.ubuntu 95 | build-args: | 96 | LIBMPDCLIENT_VERSION=${{ steps.stage.outputs.LIBMPDCLIENT_VERSION }} 97 | MPD_VERSION=${{ steps.stage.outputs.MPD_VERSION }} 98 | tags: test/ashuffle:latest 99 | cache-from: type=gha,scope=${{ matrix.target.name }} 100 | cache-to: type=gha,mode=max,scope=${{ matrix.target.name }} 101 | integration-test: 102 | strategy: 103 | matrix: 104 | target: 105 | - name: "Latest" 106 | libmpdclient_version: "latest" 107 | mpd_version: "latest" 108 | - name: "Noble" 109 | libmpdclient_version: "2.22" 110 | mpd_version: "0.23.14" 111 | - name: "Jammy" 112 | libmpdclient_version: "2.20" 113 | mpd_version: "0.23.5" 114 | - name: "Focal" 115 | libmpdclient_version: "2.18" 116 | mpd_version: "0.21.20" 117 | test_group: 118 | - name: "Short" 119 | args: "-short" 120 | - name: "Memory (Massive)" 121 | args: "-run 'TestMaxMemoryUsage/massive'" 122 | - name: "Memory (Worst Case)" 123 | args: "-run 'TestMaxMemoryUsage/worst.case'" 124 | - name: "Startup (From MPD)" 125 | args: "-run 'TestFastStartup/from.mpd'" 126 | - name: "Startup (From File)" 127 | args: "-run 'TestFastStartup/from.file'" 128 | - name: "Startup (From File, With Filter)" 129 | args: "-run 'TestFastStartup/from.file,.with.filter'" 130 | - name: "Startup (From MPD, Group By)" 131 | args: "-run 'TestFastStartup/from.mpd,.group-by'" 132 | name: "Integration Test (${{ matrix.target.name }}): ${{ matrix.test_group.name }}" 133 | runs-on: ubuntu-latest 134 | needs: [check-format, check-lint, unit-test, build-integration-test-images] 135 | steps: 136 | # this is a replay of the build-container steps, but since those ran 137 | # previously, this should be fully cached. 138 | - uses: actions/checkout@v4 139 | - uses: actions/setup-go@v5 140 | with: 141 | go-version: 'stable' 142 | cache-dependency-path: tools/meta/go.sum 143 | - uses: docker/setup-buildx-action@v3 144 | # This whole little song and dance is pretty annoying. Ideally I could 145 | # just use ./scripts/run-integration and be done with it, but to get 146 | # caching, I need to use the build-push-action. This is because gha 147 | # only exposes the necessary values to the node runtime, not via a script 148 | # consumable mechanism. There is a bug out for this, so hopefully this 149 | # can all be deleted eventually: https://github.com/actions/runner/issues/3046 150 | # 151 | # We still need to run "build-test-image" here to do the little tarring 152 | # step. Maybe I can delete that, and just go straight to build-push-action? 153 | - name: Stage Build 154 | id: stage 155 | env: 156 | MPD_VERSION: ${{ matrix.target.mpd_version }} 157 | LIBMPDCLIENT_VERSION: ${{ matrix.target.libmpdclient_version }} 158 | run: | 159 | git submodule update --init --recursive 160 | # produces the outputs used in the subseqent step 161 | scripts/github/resolve-versions 162 | - name: Build Integration Test Container 163 | uses: docker/build-push-action@v6 164 | with: 165 | context: . 166 | file: t/docker/Dockerfile.ubuntu 167 | build-args: | 168 | LIBMPDCLIENT_VERSION=${{ steps.stage.outputs.LIBMPDCLIENT_VERSION }} 169 | MPD_VERSION=${{ steps.stage.outputs.MPD_VERSION }} 170 | tags: test/ashuffle:latest 171 | cache-from: type=gha,scope=${{ matrix.target.name }} 172 | load: true 173 | # This is the actual new work, running the actual test. 174 | - name: Run Test 175 | run: | 176 | scripts/run-integration \ 177 | --no_build_use_image=test/ashuffle:latest \ 178 | --no_tty ${{ matrix.test_group.args }} 179 | release-build: 180 | name: Release Build (${{ matrix.target.arch }}) 181 | runs-on: ubuntu-latest 182 | needs: [check-format, check-lint, unit-test] 183 | strategy: 184 | matrix: 185 | target: 186 | - arch: "x86_64" 187 | triple: "x86_64-linux-gnu" 188 | - arch: "aarch64" 189 | triple: "aarch64-linux-gnu" 190 | - arch: "armv6h" 191 | triple: "armv6h-linux-gnueabihf" 192 | - arch: "armv7h" 193 | triple: "armv7h-linux-gnueabihf" 194 | steps: 195 | - name: Clone 196 | uses: actions/checkout@v4 197 | - name: Release Build 198 | run: scripts/github/release ashuffle.${{ matrix.target.triple }} ${{ matrix.target.arch }} 199 | - name: Upload 200 | uses: actions/upload-artifact@v4 201 | with: 202 | name: ashuffle.${{ matrix.target.triple }} 203 | path: ashuffle.${{ matrix.target.triple }} 204 | if-no-files-found: error 205 | retention-days: 7 206 | release: 207 | name: Release 208 | runs-on: ubuntu-latest 209 | needs: [release-build, integration-test] 210 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 211 | steps: 212 | - name: Clone 213 | uses: actions/checkout@v4 214 | - name: Download Builds 215 | uses: actions/download-artifact@v4 216 | with: 217 | pattern: "ashuffle.*" 218 | # Download files into the destination path, not separate directories. 219 | merge-multiple: true 220 | - name: Deploy 221 | env: 222 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 223 | RELEASE_TAG: ${{ github.ref }} 224 | run: | 225 | find . 226 | # Cut off the "refs/tags" prefix. 227 | tag="${RELEASE_TAG#"refs/tags/"}" 228 | gh release create \ 229 | --draft \ 230 | --title "${tag}" \ 231 | --generate-notes \ 232 | "${tag}" \ 233 | ashuffle.x86_64-linux-gnu \ 234 | ashuffle.aarch64-linux-gnu \ 235 | ashuffle.armv7h-linux-gnueabihf \ 236 | ashuffle.armv6h-linux-gnueabihf 237 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ashuffle.dSYM 2 | *.o 3 | *.swp 4 | 5 | # Exclude generated Go packages. 6 | t/integration/pkg/** 7 | t/integration/src/github.com/** 8 | 9 | # Common meson build directory 10 | build/** 11 | 12 | # The default meta binary created by `go build` 13 | tools/meta/meta 14 | 15 | # Subprojects from wrap files 16 | /subprojects/yaml-cpp/ 17 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "subprojects/absl"] 2 | path = subprojects/absl 3 | url = https://github.com/abseil/abseil-cpp.git 4 | [submodule "subprojects/googletest"] 5 | path = subprojects/googletest 6 | url = https://github.com/google/googletest.git 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2020 Josh Kunz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /contrib/readme.md: -------------------------------------------------------------------------------- 1 | # contributed instructions/code 2 | 3 | All code in this `contrib` directory was created by users of ashuffle, and 4 | is provided here as part of ashuffle's source to make it easier for other 5 | ashuffle users to find. The code or instructions in this directory are not 6 | "officially supported", and they may be wrong or out of date. 7 | 8 | Each folder in this directory represents a "topic", and the associated 9 | code, and instructions are contained within. For example, information about 10 | running ashuffle using systemd, can be found in the `systemd` directory. 11 | Most directories should have a `readme.md` file in the root explaining the 12 | provided code and how to use it. 13 | 14 | Feel free to open a PR with additional resources if you find something you 15 | think other ashuffle users would also find useful. 16 | -------------------------------------------------------------------------------- /contrib/systemd/ashuffle.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Shuffle service for mpd 3 | Requires=mpd.service 4 | After=mpd.service 5 | 6 | [Service] 7 | ExecStart=/usr/bin/ashuffle --tweak play-on-startup=no 8 | 9 | [Install] 10 | WantedBy=default.target 11 | -------------------------------------------------------------------------------- /contrib/systemd/readme.md: -------------------------------------------------------------------------------- 1 | If your distributions package provides the service file, you can start the 2 | ashuffle service with: 3 | 4 | $ systemctl --user start ashuffle.service 5 | 6 | You can use `systemctl enable` to autostart ashuffle on every login: 7 | 8 | $ systemctl --user enable ashuffle.service 9 | 10 | If your installed package doesn't provide the service file, just copy the 11 | service file in this directory into your systemd user directory before using 12 | the above commands: 13 | 14 | ~/.config/systemd/user/ashuffle.service 15 | 16 | To start the ashuffle service with parameters, use the systemctl edit command: 17 | 18 | systemctl --user edit ashuffle.service 19 | 20 | This creates and opens the file 21 | `~/.config/systemd/user/ashuffle.service.d/override.conf` allowing you to 22 | insert something like this: 23 | 24 | ``` 25 | [Service] 26 | ExecStart= 27 | ExecStart=/usr/bin/ashuffle --only 10 --tweak play-on-startup=no 28 | ``` 29 | 30 | Note: `ExecStart` must be cleared before being re-assigned. 31 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to ashuffle 2 | 3 | Contributions to ashuffle are always welcome! This document describes the 4 | code standards of ashuffle and the contribution process. The goal of this 5 | document is not to discourage contributions, but to set clear expectations 6 | of what will and won't be merged. If you're unsure of anything in this 7 | document, feel free to still open an issue or pull-request with a change. We 8 | are happy to help with any issues you may have. 9 | 10 | ## Will you add support for "X" 11 | 12 | ashuffle aims to be in the "do one thing and do it well" category of tools. 13 | This means avoiding features that overly complicate the core functionality of 14 | ashuffle: to shuffle the user's MPD library. Generally, this manifests as 15 | avoiding features that could just as easily be achieved via a separate MPD 16 | client. Here are some examples of previously accepted and rejected changes to 17 | give you a better idea of what this means. 18 | 19 | **Changes that were accepted:** 20 | 21 | * Support for MPD authentication 22 | * The `--queue-buffer` option to let crossfade users more easily use ashuffle. 23 | * Preventing shuffle when MPD's "single" mode is on. 24 | 25 | **Changes that would likley be accepted:** 26 | 27 | * Shuffle by "group" (e.g., album-based or playlist-based shuffle). This is 28 | just a modification to ashuffle's core loop. It couldn't easily be achieved 29 | by another client. 30 | 31 | **Changes that were rejected:** 32 | 33 | * Having ashuffle prune the MPD queue to a maximum length. This could be just 34 | as easily achieved by another MPD client. It is orthogonal to ashuffle's 35 | core function. 36 | 37 | ## How to contribute 38 | 39 | ashuffle uses the standard GitHub ["pull request"][1] model for contributions: 40 | 41 | 1. Fork the main ashuffle repository. 42 | 2. Make the changes you want in your ashuffle fork. 43 | 3. Open a pull request against ashuffle for the changes. 44 | 4. Respond to feedback by updating your change as needed. 45 | 5. Your change will be accepted and merged into ashuffle. 46 | 47 | If you're unsure how to do any of those steps, refer to GitHub's help articles 48 | on pull requests, and just try your best. We'll try to help with the 49 | contribution process if you have issues. Just reach out. 50 | 51 | ## Expectations for contributed code 52 | 53 | Beginning with ashuffle v2, ashuffle has adopted a consistent code style, 54 | a set of comprehensive unit tests, and integration testing to ensure 55 | compatibility with older libmpdclient and MPD versions. Your contribution 56 | should be well formatted, tested, and have integration testing where needed. 57 | 58 | Try not to worry too much about these requirements. Opening a pull-request 59 | against ashuffle will cause ashuffle's formatting checks and entire automated 60 | test suite to be run against your change. We don't expect new requests to be 61 | perfect. Reviewers will let you know if you need to add more comprehensive 62 | testing to your change, and help to diagnose test failures. 63 | 64 | **Formatting:** ashuffle follows the Google formatting style for C/C++ code 65 | with some slight tweaks (e.g., 4 spaces for indentation instead of 2). Go 66 | code follows the canonical Go style as checked by `go fmt`. There is a 67 | [`.clang-format`](.clang-format) in the root of this repository that works with 68 | `clang-format`. You can use [`scripts/format`](scripts/format) to automatically 69 | format your C and Go code using `clang-format` and `go fmt` respectively. There 70 | is also a format checker, [`scripts/check-format`](scripts/check-format) that 71 | can be used as a pre-commit hook for checking that your code is well formatted. 72 | Meson configuration should follow the [Meson style guide]( 73 | https://mesonbuild.com/Style-guide.html). Meson files should use 2-space 74 | indents. 75 | 76 | **Unit Testing:** All code should be tested. New features should have at least 77 | some test coverage for common usage. We will still accept patches for code 78 | that does not test clear error cases, or cases that cannot easily be 79 | unit-tested. Code that regresses ashuffle's existing test suite will not be 80 | accepted. See [`t/readme.md`](t/readme.md) for a more detailed description of 81 | ashuffle's unit tests. 82 | 83 | **Integration Testing:** As described in 84 | [ashuffle's compatibility statement](readme.md#mpd-version-support) ashuffle 85 | aims to support the latest versions of libmpdclient and MPD, as well as all 86 | versions that are used in an actively supported Ubuntu release 87 | (including LTS releases). This means that ashuffle must support several 88 | years of MPD and libmpdclient releases. To verify ashuffle's compatibility, 89 | ashuffle has an integration test suite that runs against real versions of 90 | MPD and libmpdclient. These tests are expected to continue working for all 91 | changes. Large features should include integration tests to verify they are 92 | not broken by new MPD/libmpdclient releases. 93 | 94 | [1]: https://help.github.com/en/articles/about-pull-requests 95 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | # vim: set sw=2 ts=2: 2 | project( 3 | 'ashuffle', 4 | ['c', 'cpp'], 5 | version: 'v3.14.9', 6 | default_options: ['cpp_std=c++17', 'warning_level=2'] 7 | ) 8 | 9 | add_global_arguments( 10 | [ 11 | '-Werror=switch', 12 | '-fno-omit-frame-pointer', 13 | ], 14 | language : ['c', 'cpp'], 15 | ) 16 | 17 | # Figure out if a linker option for std::filesystem is needed. Older versions 18 | # of clang (before Clang 9.0) and GCC (before 9.1) required linking a special 19 | # "c++fs" library. 20 | stdfs_deps = [] 21 | 22 | cxx_compiler = meson.get_compiler('cpp') 23 | 24 | stdfs_test = ''' 25 | #include 26 | int main(void) { return std::filesystem::exists("/foo"); } 27 | ''' 28 | 29 | if cxx_compiler.links(stdfs_test, name: 'std::fs links with only stdlib') 30 | # then we don't need to do anything. 31 | elif cxx_compiler.links(stdfs_test, args: ['-lc++fs'], name: 'std::fs links with -lc++fs') 32 | stdfs_deps += [declare_dependency(link_args: ['-lc++fs'])] 33 | elif cxx_compiler.links(stdfs_test, args: ['-lstdc++fs'], name: 'std::fs links with -lstdc++fs') 34 | stdfs_deps += [declare_dependency(link_args: ['-lstdc++fs'])] 35 | else 36 | error('Could not auto-detect how to link std::filesystem. Build configuration unsupported') 37 | endif 38 | 39 | cmake = import('cmake') 40 | 41 | # absl dependencies need to be explicited... 42 | # It might be possible to use cmake dependencies (e.g. "absl:string") 43 | # defined in abslTargets.cmake in the future but that does not seem 44 | # worth the time trying to figure that out. 45 | absl_libs = [ 46 | # Via Base: 47 | 'absl_raw_logging_internal', 48 | 49 | # Via Strings: 50 | 'absl_int128', 51 | 'absl_str_format_internal', 52 | 'absl_strings_internal', 53 | 'absl_strings', 54 | 55 | # Via Hash: 56 | 'absl_hash', 57 | 'absl_city', 58 | 'absl_low_level_hash', 59 | 60 | # Via Time: 61 | 'absl_time', 62 | 'absl_base', 63 | 'absl_spinlock_wait', 64 | 65 | # Via Status & Time: 66 | 'absl_cord', 67 | 'absl_cord_internal', 68 | 'absl_cordz_functions', 69 | 'absl_cordz_handle', 70 | 'absl_cordz_info', 71 | 'absl_cordz_update_tracker', 72 | 'absl_crc32c', 73 | 'absl_crc_cord_state', 74 | 'absl_crc_cpu_detect', 75 | 'absl_crc_internal', 76 | 'absl_debugging_internal', 77 | 'absl_decode_rust_punycode', 78 | 'absl_demangle_internal', 79 | 'absl_demangle_rust', 80 | 'absl_exponential_biased', 81 | 'absl_graphcycles_internal', 82 | 'absl_kernel_timeout_internal', 83 | 'absl_malloc_internal', 84 | 'absl_stacktrace', 85 | 'absl_status', 86 | 'absl_statusor', 87 | 'absl_strerror', 88 | 'absl_symbolize', 89 | 'absl_synchronization', 90 | 'absl_throw_delegate', 91 | 'absl_time_zone', 92 | 'absl_utf8_for_code_point', 93 | 94 | # Via Log 95 | 'absl_leak_check', 96 | ] 97 | 98 | absl_deps = [] 99 | if not get_option('unsupported_use_system_absl') 100 | # HACK: absl detects if it's being built in "system" mode, or "subproject" 101 | # mode depending on the cmake PROJECT_SOURCE_DIR variable. Since meson 102 | # parses the cmake package info in isolation, absl assumes that it is in 103 | # "system" mode and generates install rules that meson propogates to the 104 | # library targets by setting the `install` attribute. Since we want absl 105 | # to remain internal, we hack this check by forcing the PROJECT_SOURCE_DIR 106 | # to match the true source root. This is done by using 107 | # CMAKE_PROJECT_..._INCLUDE to inject a cmake snippet after absl's 108 | # invocation of `project()` to update PROJECT_SOURCE_DIR. 109 | absl_project_inc = join_paths(meson.current_source_dir(), 'tools/cmake/inject_project_source_dir.cmake') 110 | 111 | absl_opts = cmake.subproject_options() 112 | absl_opts.add_cmake_defines({ 113 | 'CMAKE_CXX_STANDARD': '17', 114 | # See above. 115 | 'CMAKE_PROJECT_absl_INCLUDE': absl_project_inc, 116 | # Absl's CMAKE build feels like it is MinGW for some reason when building 117 | # under Meson. Explicitly tell CMAKE it is not MinGW. 118 | 'MINGW': 'false', 119 | # Use new linker policy to avoid warnings. 120 | 'CMAKE_POLICY_DEFAULT_CMP0156': 'NEW', 121 | 'CMAKE_POLICY_DEFAULT_CMP0179': 'NEW', 122 | # Use new language version policy to avoid warnings. 123 | 'CMAKE_POLICY_DEFAULT_CMP0128': 'NEW', 124 | }) 125 | 126 | absl = cmake.subproject('absl', options: absl_opts) 127 | 128 | absl_deps = [] 129 | foreach lib : absl_libs 130 | absl_deps += absl.dependency(lib) 131 | endforeach 132 | else 133 | cpp = meson.get_compiler('cpp') 134 | 135 | # note that the system's absl needs to be compiled for C++17 standard 136 | # or final link will fail. 137 | foreach lib : absl_libs 138 | dep = dependency(lib) 139 | if dep.found() 140 | absl_deps += dep 141 | endif 142 | endforeach 143 | endif 144 | 145 | if not get_option('unsupported_use_system_yamlcpp') 146 | yaml_cpp_opts = cmake.subproject_options() 147 | yaml_cpp_opts.add_cmake_defines({ 148 | 'CMAKE_CXX_STANDARD': '17', 149 | 'YAML_BUILD_SHARED_LIBS': 'OFF', 150 | # Use new linker policy to avoid warnings. 151 | 'CMAKE_POLICY_DEFAULT_CMP0156': 'NEW', 152 | 'CMAKE_POLICY_DEFAULT_CMP0179': 'NEW', 153 | }) 154 | 155 | yaml_cpp = cmake.subproject('yaml-cpp', options: yaml_cpp_opts).dependency('yaml-cpp') 156 | else 157 | yaml_cpp = dependency('yaml-cpp') 158 | endif 159 | 160 | libmpdclient = dependency('libmpdclient') 161 | 162 | src_inc = include_directories('src') 163 | 164 | version_cc = configure_file( 165 | configuration: { 166 | 'VERSION': meson.project_version(), 167 | }, 168 | input: 'src/version.cc.in', 169 | output: 'version.cc', 170 | ) 171 | 172 | libversion = static_library( 173 | 'version', 174 | [version_cc], 175 | include_directories: src_inc, 176 | ) 177 | 178 | sources = files( 179 | 'src/args.cc', 180 | 'src/ashuffle.cc', 181 | 'src/getpass.cc', 182 | 'src/load.cc', 183 | 'src/log.cc', 184 | 'src/rule.cc', 185 | 'src/shuffle.cc', 186 | ) 187 | 188 | executable_sources = files('src/mpd_client.cc', 'src/main.cc') 189 | 190 | libashuffle = static_library( 191 | 'ashuffle', 192 | sources, 193 | include_directories: src_inc, 194 | dependencies: absl_deps + [yaml_cpp, libmpdclient], 195 | ) 196 | 197 | ashuffle = executable( 198 | 'ashuffle', 199 | executable_sources, 200 | dependencies: stdfs_deps + absl_deps + [libmpdclient], 201 | link_with: [libashuffle, libversion], 202 | install: true, 203 | ) 204 | 205 | fs = import('fs') 206 | 207 | if fs.exists('scripts/run-clang-tidy') 208 | # In the integration test container, we don't supply this script for caching 209 | # reasons, but this fails the build. So we make this target conditional 210 | # on the script being present. 211 | clang_tidy = run_target('ashuffle-clang-tidy', 212 | command : files('scripts/run-clang-tidy') + sources + executable_sources 213 | ) 214 | endif # clang-tidy 215 | 216 | if get_option('tests').enabled() 217 | 218 | if not get_option('unsupported_use_system_gtest') 219 | googletest_opts = cmake.subproject_options() 220 | googletest_opts.add_cmake_defines({ 221 | 'BUILD_GMOCK': 'ON', 222 | 'CMAKE_CXX_STANDARD': '17', 223 | 'INSTALL_GTEST': 'OFF', 224 | # Use new linker policy to avoid warnings. 225 | 'CMAKE_POLICY_DEFAULT_CMP0156': 'NEW', 226 | 'CMAKE_POLICY_DEFAULT_CMP0179': 'NEW', 227 | }) 228 | 229 | googletest = cmake.subproject('googletest', options: googletest_opts) 230 | 231 | gtest_deps = [ 232 | dependency('threads'), 233 | googletest.dependency('gtest'), 234 | googletest.dependency('gmock'), 235 | googletest.dependency('gmock_main'), 236 | ] 237 | else 238 | gtest_deps = [ 239 | dependency('threads'), 240 | dependency('gtest', version: '>=1.10'), 241 | dependency('gmock', version: '>=1.10'), 242 | dependency('gmock_main', version: '>=1.10'), 243 | ] 244 | endif 245 | 246 | mpdfake_inc = include_directories('t') 247 | mpdfake_dep = declare_dependency(include_directories : mpdfake_inc) 248 | 249 | test_options = [ 250 | 'werror=true', 251 | ] 252 | 253 | tests = { 254 | 'args': ['t/args_test.cc'], 255 | 'ashuffle': ['t/ashuffle_test.cc'], 256 | 'load': ['t/load_test.cc'], 257 | 'log': ['t/log_test.cc'], 258 | 'mpd_fake': ['t/mpd_fake_test.cc'], 259 | 'rule': ['t/rule_test.cc'], 260 | 'shuffle': ['t/shuffle_test.cc'], 261 | } 262 | 263 | foreach test_name, test_sources : tests 264 | test_exe = executable( 265 | test_name + '_test', 266 | test_sources, 267 | include_directories : src_inc, 268 | link_with: libashuffle, 269 | dependencies : stdfs_deps + absl_deps + gtest_deps + [mpdfake_dep], 270 | override_options : test_options, 271 | ) 272 | test(test_name, test_exe) 273 | endforeach 274 | 275 | endif # tests feature 276 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option('tests', type : 'feature', value : 'disabled') 2 | option('unsupported_use_system_absl', type : 'boolean', value : false) 3 | option('unsupported_use_system_gtest', type : 'boolean', value : false) 4 | option('unsupported_use_system_yamlcpp', type : 'boolean', value : false) 5 | -------------------------------------------------------------------------------- /scripts/build-test-image: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | function die() { 6 | echo "$@" >&2 7 | exit 1 8 | } 9 | 10 | usage() { 11 | cat <] [-l ] [-t ] [...] 13 | 14 | Arguments: 15 | -h, --help Display this help text. 16 | -m, --mpd-version Use the given version of MPD. 17 | -l, --libmpdclient-version Use the given version of libmpdclient. 18 | -t, --image-tag Use the given tag for the built image. 19 | Used as part of the github workflow. 20 | Any remaining arguments ([...]) are passed to docker. 21 | EOF 22 | exit 1 23 | } 24 | 25 | # Use the environment variables by default. 26 | MPD_VERSION="${MPD_VERSION:-}" 27 | LIBMPDCLIENT_VERSION="${LIBMPDCLIENT_VERSION:-}" 28 | IMAGE_TAG="${IMAGE_TAG:-test/mpd:latest}" 29 | 30 | declare -a DOCKER_ARGS 31 | while test "$#" -gt 0; do 32 | case "$1" in 33 | -h|--help) 34 | usage 35 | ;; 36 | -m|--mpd-version) 37 | MPD_VERSION="$2" 38 | shift 2 39 | ;; 40 | -l|--libmpdclient-version) 41 | LIBMPDCLIENT_VERSION="$2" 42 | shift 2 43 | ;; 44 | -t|--image-tag) 45 | IMAGE_TAG="$2" 46 | shift 2 47 | ;; 48 | *) 49 | DOCKER_ARGS+=( "$1" ) 50 | shift 51 | ;; 52 | esac 53 | done 54 | 55 | cd $(git rev-parse --show-toplevel) 56 | 57 | args=( "${DOCKER_ARGS[@]}" ) 58 | if test -n "${MPD_VERSION}"; then 59 | args+=( "--build-arg" "MPD_VERSION=${MPD_VERSION}" ) 60 | fi 61 | if test -n "${LIBMPDCLIENT_VERSION}"; then 62 | args+=( "--build-arg" "LIBMPDCLIENT_VERSION=${LIBMPDCLIENT_VERSION}" ) 63 | fi 64 | docker build "${args[@]}" \ 65 | -t "${IMAGE_TAG}" -f ./t/docker/Dockerfile.ubuntu . 66 | -------------------------------------------------------------------------------- /scripts/check-format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # The clang-format binary to use. 4 | CLANG_FORMAT="${CLANG_FORMAT:-clang-format}" 5 | # The set of files to check, either 'only-staged-modified', or 'all'. 6 | # 'only-staged-modified' is useful in a git presubmit checks because it only 7 | # checks files that are about to be commited. 8 | TO_CHECK="${TO_CHECK:-only-staged-modified}" 9 | 10 | declare -a BAD_FORMAT 11 | declare DIFFS 12 | 13 | function staged_modified_files() { 14 | git status --porcelain=2 \ 15 | | egrep '^1 M' | awk '{ print $9 }' 16 | } 17 | 18 | function check_diff() { 19 | actual="$1" 20 | expected="$2" 21 | 22 | diff_out="$(diff --unified "${actual}" "${expected}")" 23 | if [[ $? -eq 0 ]]; then 24 | return 25 | fi 26 | 27 | BAD_FORMAT+=( "$actual" ) 28 | 29 | # otherwise, a diff occured. 30 | if [[ -n "${DIFFS}" ]]; then 31 | DIFFS+=$'\n' 32 | fi 33 | DIFFS+="-- Diff in ${actual}:" 34 | DIFFS+=$'\n' 35 | # Use tail to hide the file name header. 36 | DIFFS+="$(echo "${diff_out}" | tail -n +3)" 37 | } 38 | 39 | function check_go() { 40 | F="$1" 41 | check_diff "$F" <(gofmt "$F") 42 | } 43 | 44 | function check_c() { 45 | F="$1" 46 | check_diff "$F" <("${CLANG_FORMAT}" "$F") 47 | } 48 | 49 | function files_to_check() { 50 | case "${TO_CHECK}" in 51 | only-staged-modified) 52 | staged_modified_files 53 | ;; 54 | all) 55 | git ls-files 56 | ;; 57 | *) 58 | echo "Unrecognized TO_CHECK option \"${TO_CHECK}\"" >&2 59 | exit 1 60 | ;; 61 | esac 62 | } 63 | 64 | while read F; do 65 | if test -z "$F"; then continue; fi 66 | case "$F" in 67 | *.cc|*.c|*.h) 68 | check_c "$F" 69 | ;; 70 | *.go) 71 | check_go "$F" 72 | ;; 73 | esac 74 | done <<<$( files_to_check ) 75 | 76 | if test "${#BAD_FORMAT[@]}" -gt 0; then 77 | echo "Unformatted files found:" 78 | printf " %s\n" "${BAD_FORMAT[@]}" 79 | echo 80 | echo "Fix by running:" 81 | echo " scripts/format \\" 82 | echo " " "${BAD_FORMAT[@]}" 83 | echo 84 | echo "Diffs:" 85 | echo 86 | echo "${DIFFS}" 87 | echo 88 | echo "clang-format version: $("${CLANG_FORMAT}" --version)" 89 | echo "go version: $(go version)" 90 | exit 1 91 | fi 92 | 93 | exit 0 94 | -------------------------------------------------------------------------------- /scripts/cut-release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/fish 2 | 3 | function usage 4 | echo "Usage: "(basename (status filename))" [-p/--push] " >&2 5 | end 6 | 7 | argparse "p/push" "h/help" -- $argv 8 | or begin 9 | usage >&2 10 | exit 1 11 | end 12 | 13 | if set -q _flag_help 14 | usage >&2 15 | exit 16 | end 17 | 18 | test (count $argv) -eq 1 19 | or begin 20 | usage 21 | echo "Invalid args $argv exactly one argument should be supplied" >&2 22 | exit 1 23 | end 24 | 25 | set tag $argv[1] 26 | 27 | set TAG_RE 'v[0-9]+\.[0-9]+\.[0-9]+' 28 | set VERSION_RE "( version: ')($TAG_RE)(',)" 29 | 30 | echo $tag | egrep -q '^'$TAG_RE'$' 31 | or begin 32 | echo "Invalid tag format for $tag" >&2 33 | exit 1 34 | end 35 | 36 | set root (realpath (dirname (status filename))/..) 37 | 38 | set current_tag (sed -n -E "s/$VERSION_RE/\2/p" $root/meson.build) 39 | 40 | not test -z "$current_tag" 41 | or begin 42 | echo "Failed to detect current version check meson.build" >&2 43 | exit 1 44 | end 45 | 46 | echo "Attempting upgrade from $current_tag -> $tag" 47 | 48 | git tag | egrep -q "^$tag\$" 49 | and begin 50 | echo "Tag '$tag' already set, refusing to cut this release" >&2 51 | exit 52 | end 53 | 54 | # Set the new tag. 55 | sed -i -E "s/$VERSION_RE/\1$tag\3/" $root/meson.build 56 | 57 | git diff 58 | 59 | git commit -a -m "release: $tag" 60 | or exit 1 61 | 62 | git tag $tag 63 | or exit 1 64 | 65 | set -q _flag_push 66 | or exit 0 67 | 68 | git push 69 | and git push --tags 70 | -------------------------------------------------------------------------------- /scripts/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | src_files() { 4 | if test "$#" -gt 0; then 5 | printf "%s\n" "$@" 6 | else 7 | git ls-files 8 | fi 9 | } 10 | 11 | src_files "$@" | while read F; do 12 | case $F in 13 | *.cc|*.c|*.h) 14 | clang-format -i "$F" 15 | ;; 16 | *.go) 17 | gofmt -w "$F" 18 | ;; 19 | esac 20 | done 21 | -------------------------------------------------------------------------------- /scripts/github/check-format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | . "scripts/github/common.sh" 4 | 5 | setup "$(uname -m)" 6 | 7 | exec env \ 8 | CLANG_FORMAT="${CLANG_FORMAT}" \ 9 | TO_CHECK=all \ 10 | scripts/check-format 11 | -------------------------------------------------------------------------------- /scripts/github/common.sh: -------------------------------------------------------------------------------- 1 | # renovate: datasource=pypi depName=meson 2 | MESON_VERSION="1.8.0" 3 | LLVM_RELEASE="14" 4 | 5 | CLANG_CC="clang-${LLVM_RELEASE}" 6 | CLANG_CXX="clang++-${LLVM_RELEASE}" 7 | CLANG_FORMAT="clang-format-${LLVM_RELEASE}" 8 | CLANG_TIDY="clang-tidy-${LLVM_RELEASE}" 9 | LLD="lld-${LLVM_RELEASE}" 10 | 11 | die() { 12 | echo "$@" >&2 13 | exit 1 14 | } 15 | 16 | build_meta() { 17 | (cd tools/meta && GO11MODULE=on go build -o "${RUNNER_TEMP}/meta") 18 | } 19 | 20 | setup() { 21 | target_arch="$1" 22 | 23 | declare -l -a deb_packages 24 | deb_packages=( 25 | "${CLANG_CC}" 26 | "${CLANG_FORMAT}" 27 | "${CLANG_TIDY}" 28 | cmake 29 | "${LLD}" 30 | ninja-build 31 | patchelf 32 | python3 33 | python3-pip 34 | python3-setuptools 35 | python3-wheel 36 | ) 37 | if [[ "${target_arch}" = "x86_64" ]]; then 38 | deb_packages+=( libmpdclient-dev ) 39 | fi 40 | 41 | if test -n "${IN_DEBUG_MODE:-}"; then 42 | echo apt install "${deb_packages[@]}" 43 | return 0 44 | fi 45 | 46 | sudo env DEBIAN_FRONTEND=noninteractive apt-get update -y && \ 47 | sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y \ 48 | "${deb_packages[@]}" \ 49 | || die "couldn't apt-get required packages" 50 | sudo pip3 install meson=="${MESON_VERSION}" || die "couldn't install meson" 51 | } 52 | -------------------------------------------------------------------------------- /scripts/github/lint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | . "scripts/github/common.sh" 4 | 5 | setup "$(uname -m)" 6 | env meson build || die "couldn't run meson" 7 | exec env CLANG_TIDY="${CLANG_TIDY}" ninja -C build ashuffle-clang-tidy 8 | -------------------------------------------------------------------------------- /scripts/github/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | . "scripts/github/common.sh" 6 | 7 | assure_format() { 8 | local f="$1" 9 | local match="$2" 10 | 11 | if ! [[ -e "$f" ]]; then 12 | die "cannot check format of '$f' file does not exist" 13 | fi 14 | 15 | local arch="$(readelf -h "$f" | sed -En 's/\s+Machine:\s+(\S.*)$/\1/p')" 16 | if [[ $? -ne 0 ]]; then 17 | die "failed to fetch ELF machine of file '$f'" 18 | fi 19 | 20 | [[ "$arch" =~ "$match" ]] 21 | local status=$? 22 | if [[ $status -eq 1 ]]; then 23 | die "mismatched machine for $f. Got '$arch' wanted $match'" >&2 24 | fi 25 | return 0 26 | } 27 | 28 | OUT="$1" 29 | ARCH="$2" 30 | 31 | build_meta 32 | setup "${ARCH}" 33 | 34 | cross_args=() 35 | check_arch="" 36 | case "${ARCH}" in 37 | x86_64) 38 | check_arch="X86-64" 39 | ;; 40 | aarch64) 41 | check_arch="AArch64" 42 | cross_args=( 43 | --cross_cc="${CLANG_CC}" 44 | --cross_cxx="${CLANG_CXX}" 45 | ) 46 | ;; 47 | arm*) 48 | check_arch="ARM" 49 | cross_args=( 50 | --cross_cc="${CLANG_CC}" 51 | --cross_cxx="${CLANG_CXX}" 52 | ) 53 | ;; 54 | *) 55 | die "unrecognized arch ${ARCH}" 56 | ;; 57 | esac 58 | 59 | "${RUNNER_TEMP}/meta" release -o "${OUT}" "${cross_args[@]}" "${ARCH}" 60 | 61 | assure_format "${OUT}" "${check_arch}" 62 | -------------------------------------------------------------------------------- /scripts/github/resolve-versions: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | . "scripts/github/common.sh" 6 | 7 | build_meta 8 | 9 | "${RUNNER_TEMP}/meta" resolve-versions --mpd "${MPD_VERSION}" --libmpdclient "${LIBMPDCLIENT_VERSION}" >> "${GITHUB_OUTPUT}" 10 | -------------------------------------------------------------------------------- /scripts/github/unit-test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BUILD_ROOT=build 4 | 5 | . "scripts/github/common.sh" 6 | 7 | # Set the mode to the environment variable SANITIZER if set, otherwise, 8 | # use "default" 9 | SANITIZER="${SANITIZER:-none}" 10 | if test "$#" -gt 0; then 11 | case $1 in 12 | asan) 13 | SANITIZER=asan 14 | ;; 15 | *) 16 | die "unrecognized mode $1" 17 | esac 18 | fi 19 | 20 | setup "$(uname -m)" 21 | 22 | echo "Running with sanitizer ${SANITIZER}" 23 | case "${SANITIZER}" in 24 | none) 25 | env CC="${CLANG_CC}" CXX="${CLANG_CXX}" meson -Dtests=enabled "${BUILD_ROOT}" \ 26 | || die "couldn't run meson with sanitizer ${SANITIZER}" 27 | ;; 28 | asan) 29 | env CC="${CLANG_CC}" CXX="${CLANG_CXX}" LDFLAGS="-fsanitize=address" \ 30 | meson -Dtests=enabled -Db_sanitize=address -Db_lundef=false "${BUILD_ROOT}" \ 31 | || die "couldn't run meson with sanitizer ${SANITIZER}" 32 | ;; 33 | *) 34 | die "unrecognized sanitizer ${SANITIZER}" 35 | esac 36 | 37 | exec ninja -C "${BUILD_ROOT}" test 38 | -------------------------------------------------------------------------------- /scripts/run-clang-tidy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cp "${MESON_BUILD_ROOT}/compile_commands.json" "${MESON_BUILD_ROOT}/compile_commands.json.orig" 4 | 5 | # meson generates a `-pipe` flag for some reason, which clang chokes on. 6 | sed -iE 's/-pipe//g' "${MESON_BUILD_ROOT}/compile_commands.json" 7 | if test $? -ne 0; then 8 | echo "failed edit compile commands" >&2 9 | exit 1 10 | fi 11 | 12 | cat "${MESON_BUILD_ROOT}/compile_commands.json" | jq ' 13 | [.[] | select(.file | test(".*subprojects/.*") | not)] 14 | ' > "${MESON_BUILD_ROOT}/compile_commands.json.fixed" 15 | 16 | cp "${MESON_BUILD_ROOT}/compile_commands.json.fixed" "${MESON_BUILD_ROOT}/compile_commands.json" 17 | rm "${MESON_BUILD_ROOT}/compile_commands.json.fixed" 18 | 19 | CLANG_TIDY="${CLANG_TIDY:-clang-tidy}" 20 | 21 | "${CLANG_TIDY}" -p "${MESON_BUILD_ROOT}" "$@" 22 | code=$? 23 | 24 | # restore the original compile commands. 25 | mv "${MESON_BUILD_ROOT}/compile_commands.json.orig" "${MESON_BUILD_ROOT}/compile_commands.json" 26 | 27 | exit "${code}" 28 | -------------------------------------------------------------------------------- /scripts/run-integration: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | die() { 3 | echo "$@" >&2 4 | exit 1 5 | } 6 | 7 | run_id="$(head -c 100 /dev/urandom | sha256sum | head -c 30)" 8 | 9 | tagname="test/ashuffle:run_${run_id}" 10 | 11 | tty="-t" 12 | build_extra=() 13 | run_extra=() 14 | nobuild="false" 15 | 16 | while test $# -gt 0; do 17 | case "$1" in 18 | --no_tty) 19 | tty="" 20 | ;; 21 | --no_build_use_image=*) 22 | nobuild="true" 23 | tagname="${1#"--no_build_use_image="}" 24 | ;; 25 | --build.*) 26 | build_extra+=( "--${1#"--build."}" ) 27 | ;; 28 | --build_bare.*) 29 | build_extra+=( "${1#"--build_bare."}" ) 30 | ;; 31 | *) 32 | run_extra+=( "$1" ) 33 | ;; 34 | esac 35 | shift 36 | done 37 | 38 | if [[ "${nobuild}" != "true" ]]; then 39 | ./scripts/build-test-image \ 40 | --image-tag "${tagname}" \ 41 | "${build_extra[@]}" || die "couldn't build test image" 42 | fi 43 | 44 | exec docker run \ 45 | --name "ashuffle_integration_run_${run_id}" \ 46 | --privileged \ 47 | --device /dev/fuse:/dev/fuse \ 48 | --rm \ 49 | $tty \ 50 | -i \ 51 | "${tagname}" \ 52 | "${run_extra[@]}" 53 | -------------------------------------------------------------------------------- /src/args.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_ARGS_H__ 2 | #define __ASHUFFLE_ARGS_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include 12 | 13 | #include "mpd.h" 14 | #include "rule.h" 15 | 16 | namespace ashuffle { 17 | 18 | struct ParseError { 19 | enum Type { 20 | kUnknown, // Initial error type, unknown error. 21 | kGeneric, // Generic failure. Described by 'msg'. 22 | kHelp, // The user requested the help to be printed. 23 | kVersion, // The user requested the version to be printed. 24 | }; 25 | Type type; 26 | std::string msg; 27 | 28 | ParseError() : type(kUnknown){}; 29 | ParseError(std::string_view m) : ParseError(kGeneric, m){}; 30 | ParseError(Type t, std::string_view m) : type(t), msg(m){}; 31 | }; 32 | 33 | class Options { 34 | public: 35 | std::vector ruleset; 36 | unsigned queue_only = 0; 37 | std::istream *file_in = nullptr; 38 | std::ostream *log_file = nullptr; 39 | bool check_uris = true; 40 | unsigned queue_buffer = 0; 41 | std::optional host = {}; 42 | unsigned port = 0; 43 | // Special test-only options. 44 | struct { 45 | bool print_all_songs_and_exit = false; 46 | } test = {}; 47 | // Minor "tweak" options that are not part of the main options. 48 | struct { 49 | // Window size to use for the global shuffle chain. 50 | int window_size = 7; 51 | // If true, start playing music when ashuffle is first started. 52 | // Otherwise, ashuffle will wait for an MPD event before playing 53 | // music. 54 | bool play_on_startup = true; 55 | // Duration to wait before checking queue length for suspend/resume. 56 | absl::Duration suspend_timeout = absl::ZeroDuration(); 57 | // If true, exit when MPD produces a database update event. This is 58 | // intented to be used in cases where the user is passing in a 59 | // list of songs via -f, and they may want to re-generate that list. 60 | bool exit_on_db_update = false; 61 | // Duration to attempt to reconnect to MPD after a disconnection. 62 | // After this time, ashuffle will assume it cannot reconnect and 63 | // will quit. 64 | absl::Duration reconnect_timeout = absl::Seconds(10); 65 | } tweak = {}; 66 | std::vector group_by = {}; 67 | 68 | // Parse parses the arguments in the given vector and returns ParseResult 69 | // based on the success/failure of the parse. 70 | static std::variant Parse( 71 | const mpd::TagParser &, const std::vector &); 72 | 73 | // ParseFromC parses the arguments in the given c-style arguments list, 74 | // like would be given in `main`. 75 | static std::variant ParseFromC( 76 | const mpd::TagParser &tag_parser, const char **argv, int argc) { 77 | std::vector args; 78 | // Start from '1' to skip the program name itself. 79 | for (int i = 1; i < argc; i++) { 80 | args.push_back(argv[i]); 81 | } 82 | return Options::Parse(tag_parser, args); 83 | } 84 | 85 | // Take ownership fo the given istream, and set the file_in member to 86 | // point to the referenced istream. This should only be used while the 87 | // Options are being constructed. 88 | void InternalTakeIstream(std::unique_ptr &&is) { 89 | file_in = is.get(); 90 | owned_file_ = std::move(is); 91 | }; 92 | 93 | // Same as InternalTakeIstream but for the log file ostream. 94 | void InternalTakeLog(std::unique_ptr &&os) { 95 | log_file = os.get(); 96 | owned_log_file_ = std::move(os); 97 | } 98 | 99 | private: 100 | // The owned_file is set if this Options class owns the file_in ptr. 101 | // The file_in ptr is only *sometimes* owned. For example, the file_in ptr 102 | // may point to std::cin, which has static lifetime, and is not owned by 103 | // this object. 104 | std::unique_ptr owned_file_; 105 | 106 | // Same as above. 107 | std::unique_ptr owned_log_file_; 108 | }; 109 | 110 | // Print the help message on the given output stream, and return the input 111 | // ostream. 112 | std::ostream &DisplayHelp(std::ostream &); 113 | 114 | // Print the given ParseError to the given output stream. 115 | std::ostream &operator<<(std::ostream &out, const ParseError &e); 116 | 117 | } // namespace ashuffle 118 | 119 | #endif 120 | -------------------------------------------------------------------------------- /src/ashuffle.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_ASHUFFLE_H__ 2 | #define __ASHUFFLE_ASHUFFLE_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | #include "args.h" 15 | #include "load.h" 16 | #include "mpd.h" 17 | #include "rule.h" 18 | #include "shuffle.h" 19 | 20 | namespace ashuffle { 21 | 22 | namespace { 23 | 24 | // A getpass_f value that can be used in non-interactive mode. 25 | [[maybe_unused]] std::function* kNonInteractiveGetpass = nullptr; 26 | 27 | } // namespace 28 | 29 | // `MPD_PORT` environment variables. If a password is needed, no password can 30 | // be found in MPD_HOST, then `getpass_f' will be used to prompt the user 31 | // for a password. If `getpass_f' is NULL, then a password will not be 32 | // prompted. 33 | absl::StatusOr> Connect( 34 | const mpd::Dialer& d, const Options& options, 35 | std::function* getpass_f); 36 | 37 | struct TestDelegate { 38 | bool (*until_f)() = nullptr; 39 | std::function sleep_f = absl::SleepFor; 40 | }; 41 | 42 | // Use the MPD `idle` command to queue songs random songs when the current 43 | // queue finishes playing. This is the core loop of `ashuffle`. The tests 44 | // delegate is used during tests to observe loop effects. It should be set to 45 | // NULL during normal operations. 46 | absl::Status Loop(mpd::MPD* mpd, ShuffleChain* songs, const Options& options, 47 | TestDelegate d = TestDelegate()); 48 | 49 | // Return a loader capable of re-loading the current shuffle chain given 50 | // a particular set of options. If it's not possible to create such a 51 | // loader, returns an empty option. 52 | std::optional> Reloader(mpd::MPD* mpd, 53 | const Options& options); 54 | // Print the size of the database to the given stream, accounting for 55 | // grouping. 56 | void PrintChainLength(std::ostream& stream, const ShuffleChain& chain); 57 | 58 | } // namespace ashuffle 59 | 60 | #endif 61 | -------------------------------------------------------------------------------- /src/getpass.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | #include "getpass.h" 7 | 8 | namespace { 9 | 10 | template 11 | void SetFlag(FieldT &field, FlagT flag, bool state) { 12 | if (state) { 13 | field |= flag; 14 | } else { 15 | field &= ~flag; 16 | } 17 | } 18 | 19 | void SetEcho(FILE *stream, bool echo_state, bool echo_nl_state) { 20 | struct termios flags; 21 | int res = tcgetattr(fileno(stream), &flags); 22 | if (res != 0) { 23 | if (errno == ENOTTY) { 24 | // If the output device is not a tty, then we don't need to 25 | // worry about the echo. 26 | return; 27 | } 28 | perror("SetEcho (tcgetattr)"); 29 | std::exit(1); 30 | } 31 | SetFlag(flags.c_lflag, ECHO, echo_state); 32 | SetFlag(flags.c_lflag, ECHONL, echo_nl_state); 33 | res = tcsetattr(fileno(stream), TCSANOW, &flags); 34 | if (res != 0) { 35 | perror("SetEcho (tcsetattr)"); 36 | std::exit(1); 37 | } 38 | } 39 | 40 | } // namespace 41 | 42 | namespace ashuffle { 43 | 44 | std::string GetPass(FILE *in_stream, FILE *out_stream, 45 | std::string_view prompt) { 46 | if (fwrite(prompt.data(), prompt.size(), 1, out_stream) != 1) { 47 | perror("getpass (fwrite)"); 48 | std::exit(1); 49 | } 50 | if (fflush(out_stream) != 0) { 51 | perror("getpass (fflush)"); 52 | std::exit(1); 53 | } 54 | 55 | SetEcho(out_stream, false, true); 56 | 57 | char *result = NULL; 58 | size_t result_size = 0; 59 | ssize_t result_len = getline(&result, &result_size, in_stream); 60 | if (result_len < 0) { 61 | perror("getline (getpass)"); 62 | exit(1); 63 | } 64 | // Trim off the trailing newline, if it exists 65 | if (result[result_len - 1] == '\n') { 66 | result[result_len - 1] = '\0'; 67 | } 68 | 69 | SetEcho(out_stream, true, true); 70 | 71 | return result; 72 | } 73 | 74 | } // namespace ashuffle 75 | -------------------------------------------------------------------------------- /src/getpass.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_GETPASS_H__ 2 | #define __ASHUFFLE_GETPASS_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | namespace ashuffle { 9 | 10 | // GetPass obtains a password from the user. It writes the given prompt to 11 | // `out_stream` and then waits for the user to type a line on `in_stream` 12 | // which is then returned. Terminal echoing is disabled while the user is 13 | // writing their password, to add additional privacy. 14 | std::string GetPass(FILE *in_stream, FILE *out_stream, std::string_view prompt); 15 | 16 | } // namespace ashuffle 17 | 18 | #endif 19 | -------------------------------------------------------------------------------- /src/load.cc: -------------------------------------------------------------------------------- 1 | #include "load.h" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | 10 | namespace ashuffle { 11 | 12 | namespace { 13 | 14 | // A Group is a vector of field values, present or not. 15 | typedef std::vector> Group; 16 | 17 | // A GroupMap is a mapping from Groups to song URI vectors of the URIs in the 18 | // given group. 19 | typedef std::unordered_map, absl::Hash> 20 | GroupMap; 21 | 22 | } // namespace 23 | 24 | /* build the list of songs to shuffle from using MPD */ 25 | void MPDLoader::Load(ShuffleChain *songs) { 26 | GroupMap groups; 27 | 28 | mpd::MPD::MetadataOption metadata = mpd::MPD::MetadataOption::kInclude; 29 | if (rules_.empty() && group_by_.empty()) { 30 | // If we don't need to process any rules, or group tracks, then we 31 | // can omit metadata from the query. This is an optimization, 32 | // mainly to avoid 33 | // https://github.com/MusicPlayerDaemon/libmpdclient/issues/69 34 | metadata = mpd::MPD::MetadataOption::kOmit; 35 | } 36 | 37 | auto reader_or = mpd_->ListAll(metadata); 38 | if (!reader_or.ok()) { 39 | Die("Failed to get reader: %s", reader_or.status().ToString()); 40 | } 41 | std::unique_ptr reader = std::move(*reader_or); 42 | 43 | while (!reader->Done()) { 44 | std::unique_ptr song = *reader->Next(); 45 | if (!Verify(*song)) { 46 | continue; 47 | } 48 | 49 | if (group_by_.empty()) { 50 | songs->Add(song->URI()); 51 | continue; 52 | } 53 | Group group; 54 | for (auto &field : group_by_) { 55 | group.emplace_back(song->Tag(field)); 56 | } 57 | groups[group].push_back(song->URI()); 58 | } 59 | 60 | if (group_by_.empty()) { 61 | return; 62 | } 63 | 64 | for (auto &&[_, group] : groups) { 65 | songs->Add(group); 66 | } 67 | } 68 | 69 | bool MPDLoader::Verify(const mpd::Song &song) { 70 | for (const Rule &rule : rules_) { 71 | if (!rule.Accepts(song)) { 72 | return false; 73 | } 74 | } 75 | return true; 76 | } 77 | 78 | FileMPDLoader::FileMPDLoader(mpd::MPD *mpd, const std::vector &ruleset, 79 | const std::vector &group_by, 80 | std::istream *file) 81 | : MPDLoader(mpd, ruleset, group_by), file_(file) { 82 | for (std::string uri; std::getline(*file_, uri);) { 83 | valid_uris_.emplace_back(uri); 84 | } 85 | std::sort(valid_uris_.begin(), valid_uris_.end()); 86 | } 87 | 88 | bool FileMPDLoader::Verify(const mpd::Song &song) { 89 | if (!std::binary_search(valid_uris_.begin(), valid_uris_.end(), 90 | song.URI())) { 91 | // If the URI for this song is not in the list of valid_uris_, then 92 | // it shouldn't be loaded by this loader. 93 | return false; 94 | } 95 | 96 | // Otherwise, just check against the normal rules. 97 | return MPDLoader::Verify(song); 98 | } 99 | 100 | void FileLoader::Load(ShuffleChain *songs) { 101 | for (std::string uri; std::getline(*file_, uri);) { 102 | songs->Add(uri); 103 | } 104 | } 105 | 106 | } // namespace ashuffle 107 | -------------------------------------------------------------------------------- /src/load.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_LOAD_H__ 2 | #define __ASHUFFLE_LOAD_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include 10 | 11 | #include "mpd.h" 12 | #include "rule.h" 13 | #include "shuffle.h" 14 | #include "util.h" 15 | 16 | namespace ashuffle { 17 | 18 | // Loader is the abstract interface for objects that are capable of loading 19 | // songs into a shuffle chain from various sources. 20 | class Loader { 21 | public: 22 | virtual ~Loader(){}; 23 | virtual void Load(ShuffleChain* into) = 0; 24 | }; 25 | 26 | class MPDLoader : public Loader { 27 | public: 28 | ~MPDLoader() override = default; 29 | MPDLoader(mpd::MPD* mpd, const std::vector& ruleset) 30 | : MPDLoader(mpd, ruleset, std::vector()){}; 31 | MPDLoader(mpd::MPD* mpd, const std::vector& ruleset, 32 | const std::vector& group_by) 33 | : mpd_(mpd), rules_(ruleset), group_by_(group_by){}; 34 | 35 | void Load(ShuffleChain* into) override; 36 | 37 | protected: 38 | virtual bool Verify(const mpd::Song&); 39 | 40 | private: 41 | mpd::MPD* mpd_; 42 | const std::vector& rules_; 43 | const std::vector group_by_; 44 | }; 45 | 46 | class FileMPDLoader : public MPDLoader { 47 | public: 48 | ~FileMPDLoader() override = default; 49 | FileMPDLoader(mpd::MPD* mpd, const std::vector& ruleset, 50 | const std::vector& group_by, 51 | std::istream* file); 52 | 53 | protected: 54 | bool Verify(const mpd::Song&) override; 55 | 56 | private: 57 | std::istream* file_; 58 | std::vector valid_uris_; 59 | }; 60 | 61 | class FileLoader : public Loader { 62 | public: 63 | ~FileLoader() override = default; 64 | FileLoader(std::istream* file) : file_(file){}; 65 | 66 | void Load(ShuffleChain* into) override; 67 | 68 | private: 69 | std::istream* file_; 70 | }; 71 | 72 | } // namespace ashuffle 73 | 74 | #endif // __ASHUFFLE_LOAD_H__ 75 | -------------------------------------------------------------------------------- /src/log.cc: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | 5 | namespace ashuffle { 6 | 7 | std::ostream& operator<<(std::ostream& os, const Log::Level& level) { 8 | switch (level) { 9 | case Log::Level::kInfo: 10 | os << "INFO"; 11 | break; 12 | case Log::Level::kError: 13 | os << "ERROR"; 14 | break; 15 | } 16 | return os; 17 | } 18 | 19 | namespace log { 20 | 21 | std::ostream& operator<<(std::ostream& out, const SourceLocation& loc) { 22 | out << loc.file << ":" << loc.line << " in " << loc.function; 23 | return out; 24 | } 25 | 26 | void SetOutput(std::ostream& output) { DefaultLogger().SetOutput(output); } 27 | 28 | Logger& DefaultLogger() { 29 | // static-lifetime logger. 30 | static Logger logger; 31 | return logger; 32 | } 33 | 34 | } // namespace log 35 | } // namespace ashuffle 36 | -------------------------------------------------------------------------------- /src/log.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_LOG_H__ 2 | #define __ASHUFFLE_LOG_H__ 3 | 4 | #include 5 | 6 | #include 7 | 8 | #include "log_internal.h" 9 | 10 | namespace ashuffle { 11 | 12 | class Log final { 13 | public: 14 | Log(log::SourceLocation loc = log::SourceLocation()) : loc_(loc) {} 15 | 16 | template 17 | void Info(const absl::FormatSpec& fmt, Args... args) { 18 | WriteLog(Level::kInfo, fmt, args...); 19 | } 20 | 21 | void InfoStr(std::string_view message) { 22 | WriteLogStr(Level::kInfo, message); 23 | } 24 | 25 | template 26 | void Error(const absl::FormatSpec& fmt, Args... args) { 27 | WriteLog(Level::kError, fmt, args...); 28 | } 29 | 30 | void ErrorStr(std::string_view message) { 31 | WriteLogStr(Level::kError, message); 32 | } 33 | 34 | private: 35 | enum class Level { 36 | kInfo, 37 | kError, 38 | }; 39 | 40 | friend std::ostream& operator<<(std::ostream&, const Level&); 41 | 42 | void WriteLogStr(Level level, std::string_view message) { 43 | log::DefaultLogger().Stream() 44 | << level << " " << loc_ << ": " << message << std::endl; 45 | } 46 | 47 | template 48 | void WriteLog(Level level, const absl::FormatSpec& fmt, 49 | Args... args) { 50 | log::DefaultLogger().Stream() 51 | << level << " " << loc_ << ": " << absl::StrFormat(fmt, args...) 52 | << std::endl; 53 | } 54 | 55 | log::SourceLocation loc_; 56 | }; 57 | 58 | namespace log { 59 | 60 | // Set the output of the default logger to the given ostream. The ostream must 61 | // have program lifetime. 62 | void SetOutput(std::ostream& output); 63 | 64 | } // namespace log 65 | } // namespace ashuffle 66 | 67 | #endif // __ASHUFFLE_LOG_H__ 68 | -------------------------------------------------------------------------------- /src/log_internal.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_LOG_INTERNAL_H__ 2 | #define __ASHUFFLE_LOG_INTERNAL_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | namespace ashuffle { 11 | namespace log { 12 | 13 | struct SourceLocation final { 14 | SourceLocation(const std::string_view file = __builtin_FILE(), 15 | const std::string_view function = __builtin_FUNCTION(), 16 | unsigned line = __builtin_LINE()) 17 | : file(file), function(function), line(line) {} 18 | 19 | const std::string_view file; 20 | const std::string_view function; 21 | const unsigned line; 22 | }; 23 | 24 | std::ostream& operator<<(std::ostream&, const SourceLocation&); 25 | 26 | // Logger is an object that owns the output file and standard formatting 27 | // for logs. 28 | class Logger final { 29 | public: 30 | Logger(){}; 31 | 32 | void SetOutput(std::ostream& output) { output_ = &output; } 33 | 34 | std::ostream& Stream() { 35 | static std::ofstream devnull("/dev/null"); 36 | if (output_ != nullptr) { 37 | return *output_; 38 | } 39 | return devnull; 40 | } 41 | 42 | private: 43 | std::ostream* output_; 44 | }; 45 | 46 | // Fetch the default logger. 47 | Logger& DefaultLogger(); 48 | 49 | } // namespace log 50 | } // namespace ashuffle 51 | 52 | #endif // __ASHUFFLE_LOG_INTERNAL_H__ 53 | -------------------------------------------------------------------------------- /src/main.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include 12 | #include 13 | 14 | #include "absl/status/status.h" 15 | #include "absl/strings/str_format.h" 16 | #include "absl/time/time.h" 17 | #include "args.h" 18 | #include "ashuffle.h" 19 | #include "getpass.h" 20 | #include "load.h" 21 | #include "log.h" 22 | #include "mpd_client.h" 23 | #include "shuffle.h" 24 | #include "version.h" 25 | 26 | using namespace ashuffle; 27 | 28 | // The amount of time to wait between reconnection attempts. 29 | const absl::Duration kReconnectWait = absl::Milliseconds(250); 30 | 31 | namespace { 32 | std::unique_ptr BuildLoader(mpd::MPD* mpd, const Options& opts) { 33 | if (opts.file_in != nullptr && opts.check_uris) { 34 | return std::make_unique(mpd, opts.ruleset, opts.group_by, 35 | opts.file_in); 36 | } else if (opts.file_in != nullptr) { 37 | return std::make_unique(opts.file_in); 38 | } 39 | 40 | return std::make_unique(mpd, opts.ruleset, opts.group_by); 41 | } 42 | 43 | void LoopOnce(mpd::MPD* mpd, ShuffleChain& songs, const Options& options) { 44 | absl::Time start = absl::Now(); 45 | absl::Status status = Loop(mpd, &songs, options); 46 | absl::Duration loop_length = absl::Now() - start; 47 | if (!status.ok()) { 48 | Log().Error("LOOP failed after %s with error: %s", 49 | absl::FormatDuration(loop_length), status.ToString()); 50 | } else { 51 | Log().Info("LOOP exited successfully after %s (probably a bug)", 52 | absl::FormatDuration(loop_length)); 53 | } 54 | } 55 | 56 | } // namespace 57 | 58 | int main(int argc, const char* argv[]) { 59 | std::variant parse = 60 | Options::ParseFromC(*mpd::client::Parser(), argv, argc); 61 | if (ParseError* err = std::get_if(&parse); err != nullptr) { 62 | switch (err->type) { 63 | case ParseError::Type::kVersion: 64 | // Don't print help in this case, since the user specifically 65 | // requested we print the version. 66 | std::cout << "ashuffle version: " << kVersion << std::endl; 67 | exit(EXIT_SUCCESS); 68 | case ParseError::Type::kUnknown: 69 | std::cerr << "unknown option parsing error. Please file a bug " 70 | << "at https://github.com/joshkunz/ashuffle" 71 | << std::endl; 72 | break; 73 | case ParseError::Type::kHelp: 74 | // We always print the help, so just break here. 75 | break; 76 | case ParseError::Type::kGeneric: 77 | std::cerr << "error: " << err->msg << std::endl; 78 | break; 79 | } 80 | std::cerr << DisplayHelp; 81 | exit(EXIT_FAILURE); 82 | } 83 | 84 | Options options = std::move(std::get(parse)); 85 | 86 | if (!options.check_uris && !options.group_by.empty()) { 87 | std::cerr << "-g/--group-by not supported with no-check" << std::endl; 88 | exit(EXIT_FAILURE); 89 | } 90 | 91 | if (options.log_file == nullptr) { 92 | // By default, log to stderr. 93 | log::SetOutput(std::cerr); 94 | } else { 95 | log::SetOutput(*options.log_file); 96 | } 97 | 98 | bool disable_reconnect = false; 99 | std::function pass_f = [&disable_reconnect] { 100 | disable_reconnect = true; 101 | std::string pass = GetPass(stdin, stdout, "mpd password: "); 102 | Log().InfoStr( 103 | "Disabling reconnect support since the password was " 104 | "provided interactively. Supply password via MPD_HOST " 105 | "environment variable to enable automatic " 106 | "reconnects"); 107 | return pass; 108 | }; 109 | 110 | /* attempt to connect to MPD */ 111 | absl::StatusOr> mpd = 112 | Connect(*mpd::client::Dialer(), options, &pass_f); 113 | if (!mpd.ok()) { 114 | Die("Failed to connect to mpd: %s", mpd.status().ToString()); 115 | } 116 | 117 | ShuffleChain songs((size_t)options.tweak.window_size); 118 | 119 | { 120 | // We construct the loader in a new scope, since loaders can 121 | // consume a lot of memory. 122 | std::unique_ptr loader = BuildLoader(mpd->get(), options); 123 | loader->Load(&songs); 124 | } 125 | 126 | // For integration testing, we sometimes just want to have ashuffle 127 | // dump the list of songs in its shuffle chain. 128 | if (options.test.print_all_songs_and_exit) { 129 | bool first = true; 130 | for (auto&& group : songs.Items()) { 131 | if (!first) { 132 | std::cout << "---" << std::endl; 133 | } 134 | first = false; 135 | for (auto&& song : group) { 136 | std::cout << song << std::endl; 137 | } 138 | } 139 | exit(EXIT_SUCCESS); 140 | } 141 | 142 | if (songs.Len() == 0) { 143 | PrintChainLength(std::cerr, songs); 144 | exit(EXIT_FAILURE); 145 | } 146 | 147 | PrintChainLength(std::cout, songs); 148 | 149 | if (options.queue_only) { 150 | size_t number_of_songs = 0; 151 | for (unsigned i = 0; i < options.queue_only; i++) { 152 | auto& picked_songs = songs.Pick(); 153 | number_of_songs += picked_songs.size(); 154 | if (auto status = (*mpd)->Add(picked_songs); !status.ok()) { 155 | Die("Failed to enqueue songs: %s", status.ToString()); 156 | } 157 | } 158 | 159 | /* print number of songs or groups (and songs) added */ 160 | std::cout << absl::StrFormat( 161 | "Added %u %s%s", options.queue_only, 162 | options.group_by.empty() ? "song" : "group", 163 | options.queue_only > 1 ? "s" : ""); 164 | if (!options.group_by.empty()) { 165 | std::cout << absl::StrFormat(" (%u songs)", number_of_songs); 166 | } 167 | std::cout << "." << std::endl; 168 | exit(EXIT_SUCCESS); 169 | return 0; 170 | } 171 | 172 | LoopOnce(mpd->get(), songs, options); 173 | if (disable_reconnect) { 174 | exit(EXIT_FAILURE); 175 | } 176 | 177 | absl::Time disconnect_begin = absl::Now(); 178 | while ((absl::Now() - disconnect_begin) < options.tweak.reconnect_timeout) { 179 | mpd = Connect(*mpd::client::Dialer(), options, kNonInteractiveGetpass); 180 | if (!mpd.ok()) { 181 | Log().Error("Failed to reconnect to MPD %s, been waiting %s", 182 | mpd.status().ToString(), 183 | absl::FormatDuration(absl::Now() - disconnect_begin)); 184 | 185 | absl::SleepFor(kReconnectWait); 186 | continue; 187 | } 188 | 189 | if (auto l = Reloader(mpd->get(), options); l.has_value()) { 190 | (*l)->Load(&songs); 191 | PrintChainLength(std::cout, songs); 192 | } 193 | 194 | LoopOnce(mpd->get(), songs, options); 195 | 196 | // Re-set the disconnection timer after we successfully reconnect. 197 | disconnect_begin = absl::Now(); 198 | } 199 | Log().Error("Could not reconnect after %s, aborting.", 200 | absl::FormatDuration(options.tweak.reconnect_timeout)); 201 | 202 | exit(EXIT_FAILURE); 203 | } 204 | -------------------------------------------------------------------------------- /src/mpd.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_MPD_H__ 2 | #define __ASHUFFLE_MPD_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include "absl/status/status.h" 12 | #include "absl/status/statusor.h" 13 | #include "absl/time/time.h" 14 | 15 | #include 16 | #include 17 | #include 18 | 19 | namespace ashuffle { 20 | namespace mpd { 21 | 22 | class TagParser { 23 | public: 24 | // Parse parses the given tag, and returns the appropriate tag type. 25 | // If no matching tag is found, then an empty optional is returned. 26 | virtual std::optional Parse( 27 | const std::string_view tag) const = 0; 28 | virtual ~TagParser(){}; 29 | }; 30 | 31 | class Song { 32 | public: 33 | virtual ~Song(){}; 34 | 35 | // Get the given tag for this song. 36 | virtual std::optional Tag(enum mpd_tag_type tag) const = 0; 37 | 38 | // Returns the URI of this song. 39 | virtual std::string URI() const = 0; 40 | }; 41 | 42 | class Status { 43 | public: 44 | virtual ~Status(){}; 45 | 46 | // Return the current queue length. Returns 0 if the queue is empty. 47 | virtual unsigned QueueLength() const = 0; 48 | 49 | // Single returns true if "single mode" is toggled in MPD. 50 | virtual bool Single() const = 0; 51 | 52 | // SongPosition returns the position of the "current" song in the queue. 53 | // If there is no current song (e.g., all songs in the queue have been 54 | // played, or the queue is empty) then an empty option is returned; 55 | virtual std::optional SongPosition() const = 0; 56 | 57 | // Returns the current play state of the player. 58 | virtual bool IsPlaying() const = 0; 59 | }; 60 | 61 | // SongReader is a helper for iterating over a list of songs fetched from 62 | // MPD. 63 | class SongReader { 64 | public: 65 | virtual ~SongReader(){}; 66 | 67 | // Next returns the next song from the iterator, or a NOT_FOUND error 68 | // if there are no more songs to iterate. 69 | virtual absl::StatusOr> Next() = 0; 70 | 71 | // Done returns true when there are no more songs to get. After Done 72 | // returns true, future calls to `Next` will return an empty option. 73 | virtual bool Done() = 0; 74 | }; 75 | 76 | // IdleEventSet contains a set of MPD "Idle" events. These events are used to 77 | // signal to MPD what conditions trigger the end of an "idle" command. 78 | struct IdleEventSet { 79 | // Events is an integer representation of the bit-set. 80 | int events = 0; 81 | 82 | template 83 | IdleEventSet(Events... set_events) { 84 | std::initializer_list es = {set_events...}; 85 | for (int event : es) { 86 | Add(static_cast(event)); 87 | } 88 | } 89 | 90 | // Add adds the given event to the set. 91 | void Add(enum mpd_idle event) { events |= event; } 92 | 93 | // Has returns true if the given event is in the set. 94 | bool Has(enum mpd_idle event) const { return !!(events & event); } 95 | 96 | // Enum is a helper, that returns an enum representation of `events`. 97 | enum mpd_idle Enum() const { return static_cast(events); } 98 | }; 99 | 100 | // MPD represents a connection to an MPD instance. 101 | class MPD { 102 | public: 103 | virtual ~MPD(){}; 104 | 105 | // Pauses the player. 106 | virtual absl::Status Pause() = 0; 107 | 108 | // Resumes playing. 109 | virtual absl::Status Play() = 0; 110 | 111 | // Play the song at the given queue position. 112 | virtual absl::Status PlayAt(unsigned position) = 0; 113 | 114 | // Gets the current player/MPD status. 115 | virtual absl::StatusOr> CurrentStatus() = 0; 116 | 117 | // Options for controlling whether or not song metadata is included in 118 | // a ListAll call. 119 | enum class MetadataOption { 120 | // All metadata sourced from MPD is included and queryable on the songs. 121 | kInclude, 122 | // No metadata is included on the songs. Only song URI. 123 | kOmit, 124 | }; 125 | 126 | // Returns a song reader that can be used to list all songs stored in MPD's 127 | // database. 128 | virtual absl::StatusOr> ListAll( 129 | MetadataOption metadata = MetadataOption::kInclude) = 0; 130 | 131 | // Searches MPD's DB for a particular song URI, and returns that song. 132 | // Returns a NOT_FOUND status if the song could not be found. 133 | virtual absl::StatusOr> Search( 134 | std::string_view uri) = 0; 135 | 136 | // Blocks until one of the enum mpd_idle events in the event set happens. 137 | // A new event set is returned, containing all events that occured during 138 | // the idle period. 139 | virtual absl::StatusOr Idle(const IdleEventSet&) = 0; 140 | 141 | // Add, adds the song wit the given URI to the MPD queue. 142 | virtual absl::Status Add(const std::string& uri) = 0; 143 | 144 | // Add also works on vectors of URIs, by repeatedly invoking Add for each 145 | // element. 146 | absl::Status Add(const std::vector& uris) { 147 | for (auto& u : uris) { 148 | absl::Status status = Add(u); 149 | if (!status.ok()) { 150 | return status; 151 | } 152 | } 153 | return absl::OkStatus(); 154 | }; 155 | 156 | enum PasswordStatus { 157 | kAccepted, 158 | kRejected, 159 | }; 160 | // ApplyPassword applies the given password to the MPD connection. If 161 | // the password was received by MPD successfully, a PasswordStatus is 162 | // returned. 163 | virtual absl::StatusOr ApplyPassword( 164 | const std::string& password) = 0; 165 | 166 | struct Authorization { 167 | // Set to true if this connection is authorized to execute all 168 | // requested commands. 169 | bool authorized = false; 170 | // If authorized is false, this will be filled with the missing 171 | // commands. 172 | std::vector missing = {}; 173 | }; 174 | 175 | // CheckCommandsAllowed checks that the given commands are allowed on 176 | // the MPD connection. 177 | virtual absl::StatusOr CheckCommands( 178 | const std::vector& cmds) = 0; 179 | }; 180 | 181 | // Address represents the dial address of a given MPD instance. 182 | struct Address { 183 | // host is the hostname of the MPD instance. 184 | std::string host = ""; 185 | // Port is the TCP port the MPD instance is listening on. 186 | unsigned port = 0; 187 | }; 188 | 189 | class Dialer { 190 | public: 191 | virtual ~Dialer(){}; 192 | 193 | constexpr static absl::Duration kDefaultTimeout = absl::Seconds(25); 194 | 195 | // Dial connects to the MPD instance at the given Address, optionally, 196 | // with the given timeout. On success a variant with a unique_ptr to 197 | // an MPD instance is returned. On failure, a string is returned with 198 | // a human-readable description of the error. 199 | virtual absl::StatusOr> Dial( 200 | const Address&, absl::Duration timeout = kDefaultTimeout) const = 0; 201 | }; 202 | 203 | } // namespace mpd 204 | } // namespace ashuffle 205 | 206 | #endif // __ASHUFFLE_MPD_H__ 207 | -------------------------------------------------------------------------------- /src/mpd_client.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_MPD_CLIENT_H__ 2 | #define __ASHUFFLE_MPD_CLIENT_H__ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "mpd.h" 10 | 11 | namespace ashuffle { 12 | namespace mpd { 13 | namespace client { 14 | 15 | // Returns the default TagParser, backed by libmpdclient. 16 | std::unique_ptr Parser(); 17 | 18 | // Returns a new MPD dialer that uses libmpdclient to dial MPD. 19 | std::unique_ptr Dialer(); 20 | 21 | } // namespace client 22 | } // namespace mpd 23 | } // namespace ashuffle 24 | 25 | #endif // __ASHUFFLE_MPD_CLIENT_H__ 26 | -------------------------------------------------------------------------------- /src/rule.cc: -------------------------------------------------------------------------------- 1 | #include "rule.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | namespace ashuffle { 9 | 10 | void Rule::AddPattern(enum mpd_tag_type tag, std::string value) { 11 | assert(tag != MPD_TAG_UNKNOWN && "cannot add unknown tag to pattern"); 12 | std::transform(value.begin(), value.end(), value.begin(), 13 | [](unsigned char c) { return std::tolower(c); }); 14 | patterns_.push_back(Pattern(tag, value)); 15 | } 16 | 17 | bool Rule::Accepts(const mpd::Song &song) const { 18 | assert(type_ == Rule::Type::kExclude && 19 | "only exclusion rules are supported"); 20 | for (const Pattern &p : patterns_) { 21 | std::optional tag_value = song.Tag(p.tag); 22 | if (!tag_value) { 23 | // If the tag doesn't exist, we can't match on it. Accept this 24 | // song because it can't possible match this tag. 25 | return true; 26 | } 27 | 28 | // Lowercase the tag value, to make sure our comparison is not 29 | // case sensitive. 30 | std::transform(tag_value->begin(), tag_value->end(), tag_value->begin(), 31 | [](unsigned char c) { return std::tolower(c); }); 32 | if (tag_value->find(p.value) == std::string::npos) { 33 | // No substring match, this pattern does not match. 34 | return true; 35 | } 36 | } 37 | // All tags existed and matched. Reject this song. 38 | return false; 39 | } 40 | 41 | } // namespace ashuffle 42 | -------------------------------------------------------------------------------- /src/rule.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_RULE_H__ 2 | #define __ASHUFFLE_RULE_H__ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "mpd.h" 10 | 11 | namespace ashuffle { 12 | 13 | // Internal API. 14 | struct Pattern { 15 | enum mpd_tag_type tag; 16 | std::string value; 17 | 18 | Pattern(enum mpd_tag_type t, std::string_view v) : tag(t), value(v){}; 19 | }; 20 | 21 | // Rule represents a set of patterns (song attribute/value pairs) that should 22 | // be matched against song values. 23 | class Rule { 24 | public: 25 | // Type represents the type of this rule. 26 | enum Type { 27 | // kExclude is the type of "exclusion" rules. Songs are only accepted 28 | // by exclusion rules when no rule patterns match. All songs match 29 | // the empty rule. 30 | kExclude, 31 | }; 32 | 33 | // Construct a new exclusion rule . 34 | Rule() : Rule(Type::kExclude){}; 35 | Rule(Type t) : type_(t){}; 36 | 37 | // Type returns the type of this rule. 38 | Type GetType() const { return type_; } 39 | 40 | // Empty returns true when this rule matches no patterns. 41 | inline bool Empty() const { return patterns_.empty(); } 42 | 43 | // Size returns the number of patterns in this rule. 44 | inline size_t Size() const { return patterns_.size(); } 45 | 46 | // Add the given pattern to this rule. 47 | void AddPattern(enum mpd_tag_type, std::string value); 48 | 49 | // Returns true if the given song is "accepted" by the rule. Whether or 50 | // not a song is accepted depends on the "type" of the rule. E.g., for an 51 | // exclude rule (type kExclude) if the song matched a rule pattern, the 52 | // song would *not* be accepted. 53 | bool Accepts(const mpd::Song &song) const; 54 | 55 | private: 56 | Type type_; 57 | std::vector patterns_; 58 | }; 59 | 60 | } // namespace ashuffle 61 | 62 | #endif 63 | -------------------------------------------------------------------------------- /src/shuffle.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include "shuffle.h" 9 | 10 | namespace ashuffle { 11 | 12 | void ShuffleChain::Clear() { 13 | _window.clear(); 14 | _pool.clear(); 15 | _items.clear(); 16 | } 17 | 18 | void ShuffleChain::Add(ShuffleItem item) { 19 | _items.emplace_back(item); 20 | _pool.push_back(_items.size() - 1); 21 | } 22 | 23 | size_t ShuffleChain::Len() const { return _items.size(); } 24 | size_t ShuffleChain::LenURIs() const { 25 | size_t sum = 0; 26 | for (auto& group : _items) { 27 | sum += group._uris.size(); 28 | } 29 | return sum; 30 | } 31 | 32 | /* ensure that our window is as full as it can possibly be. */ 33 | void ShuffleChain::FillWindow() { 34 | while (_window.size() <= _max_window && _pool.size() > 0) { 35 | std::uniform_int_distribution rd{0, 36 | _pool.size() - 1}; 37 | /* push a random song from the pool onto the end of the window */ 38 | size_t idx = rd(_rng); 39 | _window.push_back(_pool[idx]); 40 | _pool.erase(_pool.begin() + idx); 41 | } 42 | } 43 | 44 | const std::vector& ShuffleChain::Pick() { 45 | assert(Len() != 0 && "cannot pick from empty chain"); 46 | FillWindow(); 47 | size_t picked_idx = _window[0]; 48 | _window.pop_front(); 49 | _pool.push_back(picked_idx); 50 | return _items[picked_idx]._uris; 51 | } 52 | 53 | std::vector> ShuffleChain::Items() { 54 | std::vector> result; 55 | for (auto group : _items) { 56 | result.push_back(group._uris); 57 | } 58 | return result; 59 | } 60 | 61 | } // namespace ashuffle 62 | -------------------------------------------------------------------------------- /src/shuffle.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_SHUFFLE_H__ 2 | #define __ASHUFFLE_SHUFFLE_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | namespace ashuffle { 11 | 12 | class ShuffleChain; 13 | 14 | class ShuffleItem { 15 | public: 16 | template 17 | ShuffleItem(T v) : ShuffleItem(std::vector{v}){}; 18 | ShuffleItem(std::vector uris) : _uris(uris){}; 19 | 20 | private: 21 | std::vector _uris; 22 | friend class ShuffleChain; 23 | }; 24 | 25 | class ShuffleChain { 26 | public: 27 | // By default, create a new shuffle chain with a window-size of 1. 28 | ShuffleChain() : ShuffleChain(1){}; 29 | 30 | // Create a new ShuffleChain with the given window length. 31 | explicit ShuffleChain(size_t window) : _max_window(window) { 32 | std::random_device rd; 33 | _rng.seed(rd()); 34 | } 35 | 36 | // Create a new ShuffleChain with the given window length 37 | // and using the given RandomNumberEngine 38 | ShuffleChain(size_t window, std::mt19937 rng) 39 | : _max_window(window), _rng(rng) {} 40 | 41 | // Clear this shuffle chain, removing anypreviously added songs. 42 | void Clear(); 43 | 44 | // Add a string to the pool of songs that can be picked out of this 45 | // chain. 46 | void Add(ShuffleItem i); 47 | 48 | // Return the total number of Items (groups) in this chain. 49 | size_t Len() const; 50 | 51 | // Return the total number of URIs in this chain, in all items. 52 | size_t LenURIs() const; 53 | 54 | // Pick a group of songs out of this chain. 55 | const std::vector& Pick(); 56 | 57 | // Items returns a vector of all items in this chain. This operation is 58 | // extremely heavyweight, since it copies most of the storage used by 59 | // the chain. Use with caution. 60 | std::vector> Items(); 61 | 62 | private: 63 | void FillWindow(); 64 | 65 | size_t _max_window; 66 | std::vector _items; 67 | std::deque _window; 68 | std::deque _pool; 69 | std::mt19937 _rng; 70 | }; 71 | 72 | } // namespace ashuffle 73 | 74 | #endif 75 | -------------------------------------------------------------------------------- /src/util.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_UTIL_H__ 2 | #define __ASHUFFLE_UTIL_H__ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | namespace { 10 | 11 | // Die logs the given message as if it was printed via `absl::StrFormat`, 12 | // and then terminates the program with with an error status code. 13 | template 14 | void Die(absl::FormatSpec format, Args... vars) { 15 | std::cerr << absl::StrFormat(format, vars...) << std::endl; 16 | std::exit(EXIT_FAILURE); 17 | } 18 | 19 | } // namespace 20 | 21 | #endif // __ASHUFFLE_UTIL_H__ 22 | -------------------------------------------------------------------------------- /src/version.cc.in: -------------------------------------------------------------------------------- 1 | #include "version.h" 2 | 3 | namespace ashuffle { 4 | 5 | // Filled in by Meson. 6 | const char* kVersion = "@VERSION@"; 7 | 8 | } // namespace ashuffle -------------------------------------------------------------------------------- /src/version.h: -------------------------------------------------------------------------------- 1 | 2 | #ifndef __ASHUFFLE_VERSION_H__ 3 | #define __ASHUFFLE_VERSION_H__ 4 | 5 | namespace ashuffle { 6 | 7 | // kVersion is filled in by the build system to contain the version of 8 | // ashuffle. 9 | extern const char* kVersion; 10 | 11 | } // namespace ashuffle 12 | 13 | #endif // __ASHUFFLE_VERSION_H__ -------------------------------------------------------------------------------- /subprojects/yaml-cpp.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | directory = yaml-cpp 3 | 4 | url = https://github.com/jbeder/yaml-cpp.git 5 | # rennovate: datasource=github-tags depName=jbeder/yaml-cpp versioning=semver-coerced 6 | revision = yaml-cpp-0.7.0 7 | depth = 1 8 | 9 | [provide] 10 | dependency_names = yaml-cpp 11 | -------------------------------------------------------------------------------- /t/docker/Dockerfile.ubuntu: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 as build 2 | 3 | # renovate: datasource=pypi depName=meson 4 | ENV MESON_VERSION=1.3.1 5 | 6 | RUN env DEBIAN_FRONTEND=noninteractive apt-get update -y && \ 7 | env DEBIAN_FRONTEND=noninteraceive apt-get install --no-install-recommends -y \ 8 | build-essential \ 9 | cmake \ 10 | doxygen \ 11 | fuse \ 12 | gcc-9 g++-9 \ 13 | git \ 14 | libboost-all-dev \ 15 | libglib2.0-dev \ 16 | libmad0-dev libid3tag0-dev \ 17 | ninja-build \ 18 | pkg-config \ 19 | python3 python3-venv python3-pip python3-setuptools python3-wheel \ 20 | valgrind \ 21 | wget \ 22 | xz-utils && \ 23 | apt-get autoremove -y && \ 24 | apt-get clean 25 | 26 | RUN python3 -m venv /meson-venv && \ 27 | /meson-venv/bin/pip3 install meson==${MESON_VERSION} && \ 28 | ln -s /meson-venv/bin/meson /usr/bin/meson && \ 29 | meson --version 30 | 31 | COPY /t/docker/install_go.sh /opt/helpers/ 32 | 33 | # renovate: datasource=github-tags depName=golang/go 34 | ENV GO_VERSION=go1.21.6 35 | RUN /opt/helpers/install_go.sh ${GO_VERSION} 36 | 37 | COPY /tools/meta/ /opt/meta 38 | RUN cd /opt/meta && go build 39 | 40 | # Install libmpdclient 41 | 42 | ARG LIBMPDCLIENT_VERSION 43 | ENV LIBMPDCLIENT_VERSION ${LIBMPDCLIENT_VERSION:-latest} 44 | RUN /opt/meta/meta install libmpdclient \ 45 | --version=${LIBMPDCLIENT_VERSION} --prefix=/usr 46 | 47 | # Install MPD 48 | 49 | ARG MPD_VERSION 50 | ENV MPD_VERSION ${MPD_VERSION:-latest} 51 | COPY /t/docker/patches/ /patches/ 52 | RUN /opt/meta/meta install mpd \ 53 | --version=${MPD_VERSION} --patch_root=/patches --prefix=/usr 54 | 55 | # Install our static test helpers 56 | 57 | COPY /t/static/mpd.conf /conf 58 | 59 | # Copy in the integration test runner 60 | COPY /t/docker/run_integration.sh /exec/ 61 | 62 | # Copy in the sources for ashuffle and the tests. 63 | 64 | # subproject (dependency) sources 65 | COPY /subprojects/ /ashuffle/subprojects/ 66 | # cmake helper to force absl to build in non-system mode 67 | COPY /tools/cmake/ /ashuffle/tools/cmake/ 68 | # meson build scripts 69 | COPY meson* /ashuffle/ 70 | # Actual sources 71 | COPY /src/ /ashuffle/src/ 72 | # Integration tests 73 | COPY /t/integration/ /ashuffle/t/integration/ 74 | 75 | RUN cd /ashuffle && \ 76 | /opt/meta/meta testbuild 77 | 78 | ENTRYPOINT ["/exec/run_integration.sh"] 79 | -------------------------------------------------------------------------------- /t/docker/install_go.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BINDIR=/usr/bin 4 | ROOT=/opt/go 5 | 6 | die() { 7 | echo "$@" >&2 8 | exit 1 9 | } 10 | 11 | mkdir -p "${ROOT}" 12 | test -d "${ROOT}" || die "build root '${ROOT}' not a valid directory" 13 | cd "${ROOT}" 14 | 15 | VERSION="$1" 16 | 17 | test -n "${VERSION}" || die "go version is empty, invalid version" 18 | 19 | url="https://go.dev/dl/${VERSION}.linux-amd64.tar.gz" 20 | wget -q -O- "${url}" | tar --strip-components=1 -xz 21 | if test "$?" -ne 0; then 22 | die "couldn't download and extract ${VERSION} source" 23 | fi 24 | 25 | ln -s "${ROOT}/bin/go" "${BINDIR}/" || die "couldn't symlink go" 26 | ln -s "${ROOT}/bin/gofmt" "${BINDIR}/" || die "couldn't symlink gofmt" 27 | 28 | echo "Installed Go: $(go version)" 29 | exit 0 30 | -------------------------------------------------------------------------------- /t/docker/patches/mpd/0.21.20/0001-Support-newer-C-stdlibs.patch: -------------------------------------------------------------------------------- 1 | From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 2 | From: Josh Kunz 3 | Date: Sun, 28 Jul 2024 10:12:12 -0700 4 | Subject: Support newer C++ stdlibs 5 | 6 | * Adds import for std::exchange 7 | * Adds import for uint64_t 8 | * Adds import for runtime_error 9 | --- 10 | src/fs/io/FileReader.hxx | 2 ++ 11 | src/input/InputStream.hxx | 1 + 12 | src/player/Thread.cxx | 1 + 13 | 3 files changed, 4 insertions(+) 14 | 15 | diff --git a/src/fs/io/FileReader.hxx b/src/fs/io/FileReader.hxx 16 | index f5fa4ec69..01c809b04 100644 17 | --- a/src/fs/io/FileReader.hxx 18 | +++ b/src/fs/io/FileReader.hxx 19 | @@ -24,6 +24,8 @@ 20 | #include "fs/AllocatedPath.hxx" 21 | #include "util/Compiler.h" 22 | 23 | +#include 24 | + 25 | #ifdef _WIN32 26 | #include 27 | #else 28 | diff --git a/src/input/InputStream.hxx b/src/input/InputStream.hxx 29 | index 4d012e91a..ac8b18e2a 100644 30 | --- a/src/input/InputStream.hxx 31 | +++ b/src/input/InputStream.hxx 32 | @@ -27,6 +27,7 @@ 33 | 34 | #include 35 | #include 36 | +#include 37 | 38 | #include 39 | 40 | diff --git a/src/player/Thread.cxx b/src/player/Thread.cxx 41 | index 64a502e6d..8c3951343 100644 42 | --- a/src/player/Thread.cxx 43 | +++ b/src/player/Thread.cxx 44 | @@ -53,6 +53,7 @@ 45 | 46 | #include 47 | #include 48 | +#include 49 | 50 | #include 51 | 52 | -- 53 | 2.43.0 54 | 55 | -------------------------------------------------------------------------------- /t/docker/patches/mpd/0.23.5/0001-Support-newer-libc.patch: -------------------------------------------------------------------------------- 1 | From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 2 | From: Josh Kunz 3 | Date: Sun, 28 Jul 2024 10:45:39 -0700 4 | Subject: Support newer libc++ 5 | 6 | * Explicit cstdint include 7 | --- 8 | src/fs/io/FileReader.hxx | 2 ++ 9 | 1 file changed, 2 insertions(+) 10 | 11 | diff --git a/src/fs/io/FileReader.hxx b/src/fs/io/FileReader.hxx 12 | index 6f1a34923..b236837ac 100644 13 | --- a/src/fs/io/FileReader.hxx 14 | +++ b/src/fs/io/FileReader.hxx 15 | @@ -33,6 +33,8 @@ 16 | #include "Reader.hxx" 17 | #include "fs/AllocatedPath.hxx" 18 | 19 | +#include 20 | + 21 | #ifdef _WIN32 22 | #include 23 | #include // for INVALID_HANDLE_VALUE 24 | -- 25 | 2.43.0 26 | 27 | -------------------------------------------------------------------------------- /t/docker/patches/retain.md: -------------------------------------------------------------------------------- 1 | This file exists to make sure that this directory is retained by git. 2 | -------------------------------------------------------------------------------- /t/docker/run_integration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd /ashuffle/t/integration 3 | # The default test timeout is 10m. Need to set the timeout to 1h so the larger 4 | # performance tests can run. 5 | exec go test -v -timeout 1h "$@" ashuffle/integration 6 | -------------------------------------------------------------------------------- /t/helper.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_T_HELPER_H__ 2 | #define __ASHUFFLE_T_HELPER_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "util.h" 10 | 11 | namespace fs = ::std::filesystem; 12 | 13 | namespace ashuffle { 14 | namespace test_helper { 15 | 16 | class TemporaryFile final { 17 | public: 18 | // Construct a new temporary file with the given contents. 19 | explicit TemporaryFile(std::string_view contents) { 20 | tmp_ = std::tmpfile(); 21 | if (tmp_ == nullptr) { 22 | Die("Failed to open temporary file errno=%d", errno); 23 | } 24 | if (contents.size() != 0) { 25 | if (std::fwrite(contents.data(), contents.size(), 1, tmp_) != 1) { 26 | Die("Failed to write test contents into temporary file " 27 | "errno=%d", 28 | errno); 29 | } 30 | } 31 | // Make sure others can read our writes. 32 | if (std::fflush(tmp_)) { 33 | Die("Failed to flush test contents to temporary file errno=%d", 34 | errno); 35 | } 36 | } 37 | 38 | ~TemporaryFile() { 39 | // On destruction make sure we close the temporary file we own. 40 | std::fclose(tmp_); 41 | } 42 | 43 | // Path returns the path to the temporary file. 44 | std::string Path() const { 45 | return fs::path("/proc/self/fd") / std::to_string(fileno(tmp_)); 46 | } 47 | 48 | // Move-only type. 49 | TemporaryFile(TemporaryFile&) = delete; 50 | TemporaryFile& operator=(TemporaryFile&) = delete; 51 | TemporaryFile(TemporaryFile&&) = default; 52 | 53 | private: 54 | // The underlying temporary file. 55 | FILE* tmp_; 56 | }; 57 | 58 | } // namespace test_helper 59 | } // namespace ashuffle 60 | 61 | #endif // __ASHUFFLE_T_HELPER_H__ 62 | -------------------------------------------------------------------------------- /t/integration/go.mod: -------------------------------------------------------------------------------- 1 | module ashuffle 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | github.com/bogem/id3v2/v2 v2.1.4 9 | github.com/cenkalti/backoff v2.2.1+incompatible 10 | github.com/fhs/gompd/v2 v2.3.0 11 | github.com/google/go-cmp v0.7.0 12 | github.com/joshkunz/fakelib v0.0.8 13 | github.com/joshkunz/massif v0.0.2 14 | github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 15 | github.com/montanaflynn/stats v0.7.1 16 | golang.org/x/sync v0.14.0 17 | ) 18 | 19 | require ( 20 | github.com/hanwen/go-fuse/v2 v2.4.2 // indirect 21 | golang.org/x/sys v0.9.0 // indirect 22 | golang.org/x/text v0.10.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /t/integration/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= 2 | github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= 3 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 4 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/fhs/gompd/v2 v2.3.0 h1:wuruUjmOODRlJhrYx73rJnzS7vTSXSU7pWmZtM3VPE0= 8 | github.com/fhs/gompd/v2 v2.3.0/go.mod h1:nNdZtcpD5VpmzZbRl5rV6RhxeMmAWTxEsSIMBkmMIy4= 9 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 10 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 11 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 12 | github.com/hanwen/go-fuse/v2 v2.4.2 h1:ujevavwvGMg4s1TTSGWqid0q7WHk0XC8EOzHtygnt9E= 13 | github.com/hanwen/go-fuse/v2 v2.4.2/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= 14 | github.com/joshkunz/fakelib v0.0.8 h1:lG7XYuWMrO6424y+A2i5yfNFXD79RNUzx2TmeEgAPss= 15 | github.com/joshkunz/fakelib v0.0.8/go.mod h1:eTCUQIky5u4bwepQMOg4KJ8fstFDICSPz6YKAUTHbCI= 16 | github.com/joshkunz/massif v0.0.2 h1:eyFmmVDxMSIVBJ/OOHZaiTy1ofZqH1B4vs30N4a6ctE= 17 | github.com/joshkunz/massif v0.0.2/go.mod h1:KAmzsWWCPULX/NdP4qb5JYvjdDebq8+zE2oUwEajO0E= 18 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= 19 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= 20 | github.com/martinlindhe/unit v0.0.0-20210313160520-19b60e03648d/go.mod h1:8QbxAolnDKw/JhUJMU80MRjHjEs0tLwkjZAPrTn+xLA= 21 | github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6 h1:muzoir7BEy+lDPqdROr57IjJBP7OydzCg0VDhZtdG+w= 22 | github.com/martinlindhe/unit v0.0.0-20230420213220-4adfd7d0a0d6/go.mod h1:8QbxAolnDKw/JhUJMU80MRjHjEs0tLwkjZAPrTn+xLA= 23 | github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= 24 | github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= 25 | github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= 26 | github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 31 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 32 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 33 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 35 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 36 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 37 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 38 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 39 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 42 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 43 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 44 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 45 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 46 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 50 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 52 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 53 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 54 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 55 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 56 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 57 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 58 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 59 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 60 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 61 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 62 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 66 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | -------------------------------------------------------------------------------- /t/integration/testashuffle/testashuffle.go: -------------------------------------------------------------------------------- 1 | // Package testashuffle provides helpers for creating test ashuffle runs. 2 | package testashuffle 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "io/ioutil" 11 | "os" 12 | "os/exec" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/joshkunz/massif" 17 | ) 18 | 19 | // The default amount of time to wait for ashuffle to shutdown. 20 | const maxShutdownWait = 5 * time.Second 21 | 22 | type Ashuffle struct { 23 | cmd *exec.Cmd 24 | cancelFunc func() 25 | 26 | Stdout *bytes.Buffer 27 | Stderr *bytes.Buffer 28 | 29 | // The filename of the massif output file. 30 | massifOutputFile string 31 | 32 | // Maximum duration to wait for ashuffle to shutdown before forced shutdown. 33 | shutdownTimeout time.Duration 34 | } 35 | 36 | type ShutdownType uint 37 | 38 | const ( 39 | ShutdownUnknown ShutdownType = iota 40 | ShutdownHard 41 | ShutdownSoft 42 | ) 43 | 44 | // Waits for ashuffle to terminate. Once it does, it sends an empty struct 45 | // on the returned channel and closes it. If the underlying process never 46 | // terminates, the returned channel may never yield a value. 47 | func (a *Ashuffle) tryWait() <-chan error { 48 | c := make(chan error, 1) 49 | go func() { 50 | c <- a.cmd.Wait() 51 | close(c) 52 | }() 53 | return c 54 | } 55 | 56 | // safeWait waits for the underlying ashuffle process to abort within 57 | // MaxShutdownWait time units, or it forcibly kills the process by cancelling 58 | // its context. 59 | func (a *Ashuffle) safeWait() error { 60 | select { 61 | case err := <-a.tryWait(): 62 | return err 63 | case <-time.After(a.shutdownTimeout): 64 | a.cancelFunc() 65 | return errors.New("ashuffle took too long to exit. It has been killed") 66 | } 67 | } 68 | 69 | // Shutdown the ashuffle instance. If sType is not given, or is ShutdownUnknown, 70 | // or ShutdownHardthen a "hard" shutdown will be performed (ashuffle is 71 | // killed). If ShutdownSoft is given, then the process will wait at most 72 | // MaxShutdownWait time units for ashuffle to terminate normally, or it will 73 | // forcibly terminate the process. 74 | func (a *Ashuffle) Shutdown(sType ...ShutdownType) error { 75 | var t ShutdownType 76 | if len(sType) > 0 { 77 | t = sType[0] 78 | } 79 | 80 | // Don't send the abort signal, if we are just doing a "soft" shutdown. 81 | // Wait for the process to terminate normally. 82 | if t != ShutdownSoft { 83 | a.cancelFunc() 84 | } 85 | err := a.safeWait() 86 | if err == nil { 87 | return nil 88 | } else if err, ok := err.(*exec.ExitError); ok { 89 | bySig := err.ExitCode() == -1 90 | status := err.Sys().(syscall.WaitStatus) 91 | // If the user did not specify ShutdownSoft, we actually expect 92 | // ashuffle to get killed by the cancel func. Don't fail for "real" if 93 | // that happens. 94 | if t != ShutdownSoft && bySig && status.Signal() == syscall.SIGKILL { 95 | return nil 96 | } 97 | if err.Success() { 98 | return nil 99 | } 100 | } 101 | return err 102 | } 103 | 104 | func (a *Ashuffle) HeapProfile() (*massif.Massif, error) { 105 | if a.massifOutputFile == "" { 106 | return nil, errors.New("heap profiling not enabled for this run") 107 | } 108 | 109 | profile, err := os.Open(a.massifOutputFile) 110 | if err != nil { 111 | return nil, err 112 | } 113 | defer func() { 114 | profile.Close() 115 | os.Remove(a.massifOutputFile) 116 | }() 117 | 118 | return massif.Parse(profile) 119 | } 120 | 121 | type MPDAddress interface { 122 | // Address returns the MPD host and port (port may be empty if no 123 | // port is needed. 124 | Address() (string, string) 125 | } 126 | 127 | type literalMPDAddress struct { 128 | host string 129 | port string 130 | } 131 | 132 | func (l literalMPDAddress) Address() (string, string) { 133 | return l.host, l.port 134 | } 135 | 136 | // LiteralMPDAddress returns a new MPD address that always returns the given 137 | // host/port. 138 | func LiteralMPDAddress(host, port string) MPDAddress { 139 | return literalMPDAddress{host, port} 140 | } 141 | 142 | type Options struct { 143 | MPDAddress MPDAddress 144 | Args []string 145 | EnableHeapProfile bool 146 | ShutdownTimeout time.Duration 147 | Stdin io.Reader 148 | Stdout io.Writer 149 | } 150 | 151 | func New(ctx context.Context, path string, opts *Options) (*Ashuffle, error) { 152 | runCtx, cancel := context.WithCancel(ctx) 153 | var cmd *exec.Cmd 154 | var massifOutput string 155 | if opts != nil && opts.EnableHeapProfile { 156 | mOut, err := ioutil.TempFile("", "ashuffle.massif") 157 | if err != nil { 158 | cancel() 159 | return nil, err 160 | } 161 | massifOutput = mOut.Name() 162 | cmd = exec.CommandContext(runCtx, 163 | "valgrind", 164 | "--tool=massif", 165 | "--massif-out-file="+massifOutput, 166 | path, 167 | ) 168 | mOut.Close() 169 | } else { 170 | cmd = exec.CommandContext(runCtx, path) 171 | } 172 | 173 | var stdout, stderr bytes.Buffer 174 | cmd.Stderr = &stderr 175 | if opts != nil && opts.Stdout != nil { 176 | // If the user provides their own stdout channel, then tee between 177 | // both the buffer, and their channel. 178 | cmd.Stdout = io.MultiWriter(opts.Stdout, &stdout) 179 | } else { 180 | cmd.Stdout = &stdout 181 | } 182 | 183 | if opts != nil && opts.Stdin != nil { 184 | cmd.Stdin = opts.Stdin 185 | } 186 | 187 | shutdownTimeout := maxShutdownWait 188 | if opts != nil && opts.ShutdownTimeout != 0 { 189 | shutdownTimeout = opts.ShutdownTimeout 190 | } 191 | 192 | if opts != nil { 193 | cmd.Args = append(cmd.Args, opts.Args...) 194 | env := os.Environ() 195 | if opts.MPDAddress != nil { 196 | mpdHost, mpdPort := opts.MPDAddress.Address() 197 | if mpdHost != "" { 198 | env = append(env, fmt.Sprintf("MPD_HOST=%s", mpdHost)) 199 | } 200 | if mpdPort != "" { 201 | env = append(env, fmt.Sprintf("MPD_PORT=%s", mpdPort)) 202 | } 203 | } 204 | cmd.Env = env 205 | } 206 | 207 | if err := cmd.Start(); err != nil { 208 | cancel() 209 | return nil, err 210 | } 211 | return &Ashuffle{ 212 | cmd: cmd, 213 | cancelFunc: cancel, 214 | Stdout: &stdout, 215 | Stderr: &stderr, 216 | massifOutputFile: massifOutput, 217 | shutdownTimeout: shutdownTimeout, 218 | }, nil 219 | } 220 | -------------------------------------------------------------------------------- /t/integration/testmpd/testmpd.go: -------------------------------------------------------------------------------- 1 | // Package testmpd provides helpers for starting and examining test MPD 2 | // instances. 3 | package testmpd 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "errors" 9 | "fmt" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "os/exec" 14 | "path/filepath" 15 | "strconv" 16 | "sync" 17 | "syscall" 18 | "text/template" 19 | "time" 20 | 21 | "github.com/cenkalti/backoff" 22 | mpdc "github.com/fhs/gompd/v2/mpd" 23 | "github.com/martinlindhe/unit" 24 | ) 25 | 26 | const ( 27 | mpdConnectBackoff = 500 * time.Millisecond 28 | mpdConnectMax = 30 * time.Second 29 | mpdUpdateDBBackoff = 100 * time.Millisecond 30 | mpdUpdateDBMax = 30 * time.Second 31 | mpdShutdownMaxWait = 5 * time.Second 32 | ) 33 | 34 | // Password is the type of an MPD password. A literal password, a collection 35 | // of permissions for users of that password. 36 | type Password struct { 37 | // Password is the actual password the user must enter to get the 38 | // requested permissions. 39 | Password string 40 | 41 | // Permissions is the list of permissions granted to users of this 42 | // password. 43 | Permissions []string 44 | } 45 | 46 | // Options are the options used when creating this MPD instance. 47 | type Options struct { 48 | // BinPath is the path to the MPD binary. Leave it empty to use the PATH 49 | // to search for MPD. 50 | BinPath string 51 | // LibraryRoot is the root directory of MPD's music library. 52 | LibraryRoot string 53 | // DefaultPermissions is the list of permissions given to unauthenticated 54 | // users. Leave it empty or nil to use the MPD default. 55 | DefaultPermissions []string 56 | 57 | // If non-zero, this value is set as the `max_output_buffer_size` option 58 | // in the MPD configuration. 59 | MaxOutputBufferSize unit.Datasize 60 | 61 | // The maximum amount of time to wait for MPD to update its database. If 62 | // unset, the default timeout is used. 63 | UpdateDBTimeout time.Duration 64 | 65 | // Passwords is the list of passwords to configure on this instance. See 66 | // `Password' for per-password options. 67 | Passwords []Password 68 | } 69 | 70 | type mpdTemplateInput struct { 71 | Options 72 | MPDRoot string 73 | } 74 | 75 | var mpdConfTemplate = template.Must(template.New("mpd.conf"). 76 | Funcs(map[string]interface{}{ 77 | "floatToInt": func(f float64) int64 { 78 | return int64(f) 79 | }, 80 | }). 81 | Parse(` 82 | music_directory "{{ .LibraryRoot }}" 83 | playlist_directory "{{ .MPDRoot }}/playlists" 84 | db_file "{{ .MPDRoot }}/database" 85 | pid_file "{{ .MPDRoot }}/pid" 86 | state_file "{{ .MPDRoot }}/state" 87 | sticker_file "{{ .MPDRoot }}/sticker.sql" 88 | bind_to_address "{{ .MPDRoot }}/socket" 89 | {{ if ne .MaxOutputBufferSize 0.0 }}max_output_buffer_size "{{ .MaxOutputBufferSize.Kibibytes | floatToInt }}"{{ end }} 90 | audio_output { 91 | type "null" 92 | name "null" 93 | } 94 | 95 | {{ if .DefaultPermissions -}} 96 | default_permissions " 97 | {{- range $index, $perm := .DefaultPermissions -}} 98 | {{- if $index -}},{{- end -}} 99 | {{ $perm }} 100 | {{- end -}} 101 | " 102 | {{- end -}} 103 | 104 | {{ if .Passwords }} 105 | {{ range .Passwords }} 106 | password "{{ .Password }}@ 107 | {{- range $index, $perm := .Permissions -}} 108 | {{- if $index -}},{{- end -}} 109 | {{ $perm }} 110 | {{- end -}} 111 | " 112 | {{- end }} 113 | {{ end }} 114 | `)) 115 | 116 | // build builds a conf file from the options, and returns the conf file 117 | // text, as well as the MPD UNIX socket path (which is set in the 118 | // configuration) 119 | func (m Options) Build(rootDir string) (string, string) { 120 | var confFile bytes.Buffer 121 | 122 | if err := mpdConfTemplate.Execute(&confFile, mpdTemplateInput{m, rootDir}); err != nil { 123 | panic(err) 124 | } 125 | return confFile.String(), filepath.Join(rootDir, "socket") 126 | } 127 | 128 | // MPD is the type of an MPD instance. It can be constructed with New, 129 | // and controlled with the various member methods. 130 | type MPD struct { 131 | // Addr is the UNIX socket this MPD instance is listening on. 132 | Addr string 133 | 134 | // Stdout is a buffer that contains the standard output of the MPD process. 135 | Stdout *bytes.Buffer 136 | // Stderr is a buffer that contains the standard error of the MPD process. 137 | Stderr *bytes.Buffer 138 | 139 | root Root 140 | cmd *exec.Cmd 141 | cli *mpdc.Client 142 | cancelFunc func() 143 | 144 | // Errors is a list of all errors that have occured when trying to access 145 | // this MPD instance. Usually it should be empty, so it's not worth 146 | // checking most of the time. The `IsOk` can be used as a quick check 147 | // that access to this MPD instance is healthy. 148 | Errors []error 149 | } 150 | 151 | // Address returns `i.Addr` as the host, and an empty port. 152 | func (m MPD) Address() (string, string) { 153 | return m.Addr, "" 154 | } 155 | 156 | // Shutdown shuts down this MPD instance, and cleans up associated data. 157 | func (m *MPD) Shutdown() error { 158 | if m.root.owned { 159 | // Only cleanup the root if it's owned by this MPD instance. 160 | defer m.root.Cleanup() 161 | } 162 | m.cli.Close() 163 | 164 | // Shutdown the server, either via SIGTERM, or by cancelling the 165 | // command context. 166 | if err := m.cmd.Process.Signal(syscall.SIGTERM); err != nil { 167 | // If we fail, then shutdown the context to force shutdown of MPD. 168 | log.Printf("failed to send SIGTERM to MPD: %s, forcing shutdown", err) 169 | m.cancelFunc() 170 | } 171 | 172 | // Wait for the cmd to exit. In the background. 173 | done := make(chan error, 1) 174 | go func() { 175 | done <- m.cmd.Wait() 176 | close(done) 177 | }() 178 | 179 | // Make sure we call cancel no matter what. Cancel is idempotent, so it's 180 | // fine if it gets called twice. 181 | defer m.cancelFunc() 182 | 183 | select { 184 | case err := <-done: 185 | return err 186 | case <-time.After(mpdShutdownMaxWait): 187 | log.Printf("failed to shutdown MPD after %s, forcing shutdown", mpdShutdownMaxWait) 188 | m.cancelFunc() 189 | return <-done 190 | } 191 | } 192 | 193 | // IsOk returns true if there have been no errors on this instance. You can 194 | // use MPD.Errors to see any errors that have occured. 195 | func (m *MPD) IsOk() bool { 196 | return len(m.Errors) == 0 197 | } 198 | 199 | func (m *MPD) maybeErr(err error) { 200 | if err != nil { 201 | m.Errors = append(m.Errors, err) 202 | } 203 | } 204 | 205 | // Play plays the song in the current position in the MPD queue. 206 | func (m *MPD) Play() { 207 | m.maybeErr(m.cli.Pause(false)) 208 | } 209 | 210 | // Pause pauses the currently playing song. 211 | func (m *MPD) Pause() { 212 | m.maybeErr(m.cli.Pause(true)) 213 | } 214 | 215 | // Next skips the current song. 216 | func (m *MPD) Next() { 217 | m.maybeErr(m.cli.Next()) 218 | } 219 | 220 | // Prev goes back to the previous song. 221 | func (m *MPD) Prev() { 222 | m.maybeErr(m.cli.Previous()) 223 | } 224 | 225 | // Db returns a list of all URIs in this MPD instance's database. 226 | func (m *MPD) Db() []string { 227 | res, err := m.cli.GetFiles() 228 | if err != nil { 229 | m.Errors = append(m.Errors, err) 230 | return nil 231 | } 232 | return res 233 | } 234 | 235 | // Queue returns an array of the songs currently in the queue. 236 | func (m *MPD) Queue() []string { 237 | attrs, err := m.cli.PlaylistInfo(-1, -1) 238 | if err != nil { 239 | m.Errors = append(m.Errors, err) 240 | return nil 241 | } 242 | var result []string 243 | for _, attr := range attrs { 244 | result = append(result, attr["file"]) 245 | } 246 | return result 247 | } 248 | 249 | func (m *MPD) QueuePos() int64 { 250 | attrs, err := m.cli.Status() 251 | if err != nil { 252 | m.Errors = append(m.Errors, err) 253 | return -1 254 | } 255 | res, err := strconv.ParseInt(attrs["song"], 10, 64) 256 | if err != nil { 257 | m.Errors = append(m.Errors, err) 258 | return -1 259 | } 260 | return res 261 | } 262 | 263 | type State string 264 | 265 | const ( 266 | StateUnknown = State("unknown") 267 | StatePlay = State("play") 268 | StatePause = State("pause") 269 | StateStop = State("stop") 270 | ) 271 | 272 | func (m *MPD) PlayState() State { 273 | attr, err := m.cli.Status() 274 | if err != nil { 275 | m.Errors = append(m.Errors, err) 276 | return StateUnknown 277 | } 278 | switch attr["state"] { 279 | case "play": 280 | return StatePlay 281 | case "pause": 282 | return StatePause 283 | case "stop": 284 | return StateStop 285 | } 286 | return StateUnknown 287 | } 288 | 289 | func (m *MPD) Clear() { 290 | m.maybeErr(m.cli.Clear()) 291 | } 292 | 293 | // A Root represents the state required to run an instance of MPD. 294 | type Root struct { 295 | path string 296 | socket string 297 | confPath string 298 | options *Options 299 | 300 | // Set to true if this root is owned by the MPD instance. If set to 301 | // false it will not be cleaned up when the MPD instance is destroyed. 302 | owned bool 303 | 304 | cleanupOnce sync.Once 305 | } 306 | 307 | // Cleanup any state associated with this root. Should be called when 308 | // the root is no longer needed. 309 | func (r *Root) Cleanup() { 310 | // We only want to do this once per-root. Future cleanups should be 311 | // no-ops. 312 | r.cleanupOnce.Do(func() { 313 | os.RemoveAll(r.path) 314 | }) 315 | } 316 | 317 | // The root is what actually defines the address, so Root can also provide 318 | // the address associated with an MPD instance. 319 | func (r *Root) Address() (string, string) { 320 | return r.socket, "" 321 | } 322 | 323 | func (r Root) hasOptions() bool { 324 | return r.options != nil 325 | } 326 | 327 | // Create a new Root. The caller is expected to call Root.Cleanup when its 328 | // done using the root. 329 | func NewRoot(opts *Options) (*Root, error) { 330 | rootPath, err := ioutil.TempDir(os.TempDir(), "mpd-harness") 331 | if err != nil { 332 | return nil, err 333 | } 334 | conf, err := ioutil.TempFile(rootPath, "generated-conf") 335 | if err != nil { 336 | os.RemoveAll(rootPath) 337 | return nil, err 338 | } 339 | confPath := conf.Name() 340 | confString, mpdSocket := opts.Build(rootPath) 341 | if _, err := conf.Write([]byte(confString)); err != nil { 342 | os.RemoveAll(rootPath) 343 | return nil, err 344 | } 345 | conf.Close() 346 | return &Root{ 347 | path: rootPath, 348 | socket: mpdSocket, 349 | confPath: confPath, 350 | options: opts, 351 | }, nil 352 | } 353 | 354 | func New(ctx context.Context, opts *Options) (*MPD, error) { 355 | root, err := NewRoot(opts) 356 | if err != nil { 357 | return nil, err 358 | } 359 | // We created this root, so we take ownership of its lifecycle. 360 | root.owned = true 361 | return NewWithRoot(ctx, root) 362 | } 363 | 364 | // New creates a new MPD instance with the given options. If `opts' is nil, 365 | // then default options will be used. If a new MPD instance cannot be created, 366 | // an error is returned. 367 | func NewWithRoot(ctx context.Context, root *Root) (*MPD, error) { 368 | mpdCtx, mpdCancel := context.WithCancel(ctx) 369 | stdout := bytes.Buffer{} 370 | stderr := bytes.Buffer{} 371 | mpdBin := "mpd" 372 | if root.hasOptions() && root.options.BinPath != "" { 373 | mpdBin = root.options.BinPath 374 | } 375 | cmd := exec.CommandContext(mpdCtx, mpdBin, "--no-daemon", "--stderr", root.confPath) 376 | cmd.Stdout = &stdout 377 | cmd.Stderr = &stderr 378 | 379 | earlyExitCleanup := func() { 380 | mpdCancel() 381 | // We only cleanup the root if we own it. 382 | if root.owned { 383 | root.Cleanup() 384 | } 385 | } 386 | 387 | if err := cmd.Start(); err != nil { 388 | earlyExitCleanup() 389 | return nil, err 390 | } 391 | 392 | // Keep re-trying to connect to mpd every mpdConnectBackoff, aborting 393 | // if mpdConnectMax time units have gone by. 394 | connectCtx, cancel := context.WithTimeout(ctx, mpdConnectMax) 395 | defer cancel() 396 | connectBackoff := backoff.WithContext(backoff.NewConstantBackOff(mpdConnectBackoff), connectCtx) 397 | 398 | var cli *mpdc.Client 399 | err := backoff.Retry(func() error { 400 | onceCli, err := mpdc.Dial("unix", root.socket) 401 | if err != nil { 402 | return err 403 | } 404 | cli = onceCli 405 | return nil 406 | }, connectBackoff) 407 | if err != nil { 408 | earlyExitCleanup() 409 | return nil, fmt.Errorf("failed to connect to mpd at %s: %v", root.socket, err) 410 | } 411 | if cli == nil { 412 | panic("backoff did not return an error. This should not happen.") 413 | } 414 | 415 | updateTimeout := mpdUpdateDBMax 416 | if root.hasOptions() && root.options.UpdateDBTimeout != 0 { 417 | updateTimeout = root.options.UpdateDBTimeout 418 | } 419 | 420 | updateCtx, cancel := context.WithTimeout(ctx, updateTimeout) 421 | defer cancel() 422 | updateBackoff := backoff.WithContext(backoff.NewConstantBackOff(mpdUpdateDBBackoff), updateCtx) 423 | err = backoff.Retry(func() error { 424 | attr, err := cli.Status() 425 | if err != nil { 426 | return backoff.Permanent(err) 427 | } 428 | if attr["updating_db"] == "1" { 429 | return errors.New("db still updating") 430 | } 431 | return nil 432 | 433 | }, updateBackoff) 434 | if err != nil { 435 | earlyExitCleanup() 436 | return nil, fmt.Errorf("failed to wait for MPD db to update: %v", err) 437 | } 438 | 439 | return &MPD{ 440 | Addr: root.socket, 441 | Stdout: &stdout, 442 | Stderr: &stderr, 443 | 444 | root: *root, 445 | cmd: cmd, 446 | cli: cli, 447 | cancelFunc: mpdCancel, 448 | }, nil 449 | } 450 | -------------------------------------------------------------------------------- /t/load_test.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include "args.h" 6 | #include "load.h" 7 | #include "mpd.h" 8 | #include "rule.h" 9 | #include "shuffle.h" 10 | 11 | #include "t/mpd_fake.h" 12 | 13 | #include 14 | #include 15 | 16 | using namespace ashuffle; 17 | 18 | using ::testing::ContainerEq; 19 | using ::testing::WhenSorted; 20 | 21 | TEST(MPDLoaderTest, Basic) { 22 | fake::MPD mpd; 23 | mpd.db.emplace_back("song_a"); 24 | mpd.db.emplace_back("song_b"); 25 | 26 | ShuffleChain chain; 27 | std::vector ruleset; 28 | 29 | MPDLoader loader(static_cast(&mpd), ruleset); 30 | loader.Load(&chain); 31 | 32 | std::vector> want = {{"song_a"}, {"song_b"}}; 33 | EXPECT_THAT(chain.Items(), WhenSorted(ContainerEq(want))); 34 | } 35 | 36 | TEST(MPDLoaderTest, WithFilter) { 37 | fake::MPD mpd; 38 | 39 | mpd.db.push_back(fake::Song("song_a", {{MPD_TAG_ARTIST, "__artist__"}})); 40 | mpd.db.push_back( 41 | fake::Song("song_b", {{MPD_TAG_ARTIST, "__not_artist__"}})); 42 | mpd.db.push_back(fake::Song("song_c", {{MPD_TAG_ARTIST, "__artist__"}})); 43 | 44 | ShuffleChain chain; 45 | std::vector ruleset; 46 | 47 | Rule rule; 48 | // Exclude all songs with the artist "__not_artist__". 49 | rule.AddPattern(MPD_TAG_ARTIST, "__not_artist__"); 50 | ruleset.push_back(rule); 51 | 52 | MPDLoader loader(static_cast(&mpd), ruleset); 53 | loader.Load(&chain); 54 | 55 | std::vector> want = {{"song_a"}, {"song_c"}}; 56 | EXPECT_THAT(chain.Items(), WhenSorted(ContainerEq(want))); 57 | } 58 | 59 | TEST(MPDLoaderTest, WithGroup) { 60 | fake::MPD mpd; 61 | mpd.db.push_back(fake::Song("song_a", {{MPD_TAG_ALBUM, "__album__"}})); 62 | mpd.db.push_back(fake::Song("song_b", {{MPD_TAG_ALBUM, "__album__"}})); 63 | 64 | std::vector group_by = {MPD_TAG_ARTIST}; 65 | 66 | ShuffleChain chain; 67 | std::vector ruleset; 68 | 69 | MPDLoader loader(static_cast(&mpd), ruleset, group_by); 70 | loader.Load(&chain); 71 | 72 | std::vector want = {"song_a", "song_b"}; 73 | EXPECT_THAT(chain.Pick(), WhenSorted(ContainerEq(want))); 74 | } 75 | 76 | std::unique_ptr TestStream(std::vector lines) { 77 | return std::make_unique(absl::StrJoin(lines, "\n")); 78 | } 79 | 80 | TEST(FileLoaderTest, Basic) { 81 | ShuffleChain chain; 82 | fake::Song song_a("song_a"), song_b("song_b"), song_c("song_c"); 83 | 84 | std::unique_ptr s = TestStream({ 85 | song_a.URI(), 86 | song_b.URI(), 87 | song_c.URI(), 88 | }); 89 | 90 | FileLoader loader(s.get()); 91 | loader.Load(&chain); 92 | 93 | std::vector> want = { 94 | {song_a.URI()}, {song_b.URI()}, {song_c.URI()}}; 95 | 96 | EXPECT_THAT(chain.Items(), WhenSorted(ContainerEq(want))); 97 | } 98 | 99 | TEST(FileMPDLoaderTest, Basic) { 100 | // step 1. Initialize the MPD connection. 101 | fake::MPD mpd; 102 | 103 | // step 2. Build the ruleset, and add an exclusions for __not_artist__ 104 | std::vector ruleset; 105 | 106 | Rule artist_match; 107 | // Exclude all songs with the artist "__not_artist__". 108 | artist_match.AddPattern(MPD_TAG_ARTIST, "__not_artist__"); 109 | ruleset.push_back(artist_match); 110 | 111 | // step 3. Prepare the shuffle_chain. 112 | ShuffleChain chain; 113 | 114 | // step 4. Prepare our songs/song list. The song_list will be used for 115 | // subsequent calls to `mpd_recv_song`. 116 | fake::Song song_a("song_a", {{MPD_TAG_ARTIST, "__artist__"}}); 117 | fake::Song song_b("song_b", {{MPD_TAG_ARTIST, "__not_artist__"}}); 118 | fake::Song song_c("song_c", {{MPD_TAG_ARTIST, "__artist__"}}); 119 | // This song will not be present in the MPD library, so it doesn't need 120 | // any tags. 121 | fake::Song song_d("song_d"); 122 | 123 | mpd.db.push_back(song_a); 124 | mpd.db.push_back(song_b); 125 | mpd.db.push_back(song_c); 126 | // Don't push song_d, so we can validate that only songs in the MPD 127 | // library are allowed. 128 | // mpd.db.push_back(song_d) 129 | 130 | // step 5. Set up our test input file, but writing the URIs of our songs. 131 | std::unique_ptr s = TestStream({ 132 | song_a.URI(), 133 | song_b.URI(), 134 | song_c.URI(), 135 | // But we do want to write song_d here, so that ashuffle has to check 136 | // it. 137 | song_d.URI(), 138 | }); 139 | 140 | // step 6. Run! (and validate) 141 | std::vector group_by; 142 | FileMPDLoader loader(static_cast(&mpd), ruleset, group_by, 143 | s.get()); 144 | loader.Load(&chain); 145 | 146 | std::vector> want = {{song_a.URI()}, 147 | {song_c.URI()}}; 148 | EXPECT_THAT(chain.Items(), WhenSorted(ContainerEq(want))); 149 | } 150 | -------------------------------------------------------------------------------- /t/log_test.cc: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "log.h" 4 | 5 | #include 6 | #include 7 | 8 | using namespace ashuffle; 9 | 10 | using ::testing::AllOf; 11 | using ::testing::HasSubstr; 12 | 13 | auto location_matchers = std::vector({ 14 | HasSubstr("log_test.cc"), 15 | HasSubstr("TestBody"), 16 | HasSubstr("test message"), 17 | }); 18 | 19 | TEST(LogTest, Info) { 20 | std::stringstream out; 21 | log::SetOutput(out); 22 | Log().Info("test message"); 23 | 24 | // 22 is the line number we expect to be logged. 25 | EXPECT_THAT(out.str(), AllOf(HasSubstr("INFO"), HasSubstr("22"), 26 | AllOfArray(location_matchers))); 27 | } 28 | 29 | TEST(LogTest, Error) { 30 | std::stringstream out; 31 | log::SetOutput(out); 32 | Log().Error("test message"); 33 | 34 | // 32 is the line number we expect to be logged. 35 | EXPECT_THAT(out.str(), AllOf(HasSubstr("ERROR"), HasSubstr("32"), 36 | AllOfArray(location_matchers))); 37 | } 38 | -------------------------------------------------------------------------------- /t/mpd_fake.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_T_MPD_FAKE_H__ 2 | #define __ASHUFFLE_T_MPD_FAKE_H__ 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include 11 | #include 12 | #include 13 | 14 | #include "mpd.h" 15 | 16 | namespace ashuffle { 17 | namespace fake { 18 | 19 | namespace { 20 | 21 | // For debugging: Set to true to make the fake echo all MPD calls made 22 | // by the tests. 23 | constexpr bool kMPDEcho = false; 24 | std::ostream& dbg() { 25 | static std::ofstream devnull("/dev/null"); 26 | return kMPDEcho ? std::cerr : devnull; 27 | } 28 | } // namespace 29 | 30 | class Song : public mpd::Song { 31 | public: 32 | using tag_map = std::unordered_map; 33 | std::string uri; 34 | tag_map tags; 35 | 36 | Song() : Song("", {}){}; 37 | Song(std::string_view u) : Song(u, {}){}; 38 | Song(tag_map t) : Song("", t){}; 39 | Song(std::string_view u, tag_map t) : uri(u), tags(t){}; 40 | 41 | std::optional Tag(enum mpd_tag_type tag) const override { 42 | if (tags.find(tag) == tags.end()) { 43 | return std::nullopt; 44 | } 45 | return tags.at(tag); 46 | } 47 | 48 | std::string URI() const override { return uri; } 49 | 50 | bool operator==(const Song& other) const { 51 | return uri == other.uri && tags == other.tags; 52 | } 53 | 54 | friend std::ostream& operator<<(std::ostream& os, const Song& s) { 55 | os << "Song(\"" << s.uri << "\""; 56 | 57 | if (s.tags.empty()) { 58 | return os << ")"; 59 | } 60 | 61 | os << ", {"; 62 | bool first = true; 63 | for (auto& [tag, val] : s.tags) { 64 | if (!first) { 65 | os << ", "; 66 | } 67 | first = false; 68 | std::string tag_name; 69 | switch (tag) { 70 | case MPD_TAG_ARTIST: 71 | tag_name = "artist"; 72 | break; 73 | case MPD_TAG_ALBUM: 74 | tag_name = "album"; 75 | break; 76 | default: 77 | tag_name = ""; 78 | } 79 | os << tag_name << ": " << val; 80 | } 81 | return os << "})"; 82 | } 83 | }; 84 | 85 | class TagParser : public mpd::TagParser { 86 | public: 87 | using tagname_map = std::unordered_map; 88 | tagname_map tags; 89 | 90 | TagParser() : tags({}){}; 91 | TagParser(tagname_map t) : tags(t){}; 92 | 93 | ~TagParser() override = default; 94 | 95 | std::optional Parse( 96 | const std::string_view tag) const override { 97 | std::string tag_copy(tag); 98 | if (tags.find(tag_copy) == tags.end()) { 99 | return std::nullopt; 100 | } 101 | return tags.at(tag_copy); 102 | } 103 | }; 104 | 105 | // State stores the "state" of the MPD player. It is also used by the 106 | // status fake to implement the status interface. 107 | struct State { 108 | bool single_mode = false; 109 | bool playing = false; 110 | std::optional song_position = std::nullopt; 111 | unsigned queue_length = 0; 112 | }; 113 | 114 | inline bool operator==(const State& lhs, const State& rhs) { 115 | return (lhs.single_mode == rhs.single_mode && lhs.playing == rhs.playing && 116 | lhs.song_position == rhs.song_position && 117 | lhs.queue_length == rhs.queue_length); 118 | } 119 | 120 | class Status : public mpd::Status { 121 | public: 122 | Status(State state) : state_(state){}; 123 | ~Status() override = default; 124 | 125 | unsigned QueueLength() const override { return state_.queue_length; }; 126 | 127 | bool Single() const override { return state_.single_mode; }; 128 | 129 | std::optional SongPosition() const override { 130 | return state_.song_position; 131 | }; 132 | 133 | bool IsPlaying() const override { return state_.playing; }; 134 | 135 | private: 136 | const State state_; 137 | }; 138 | 139 | class SongReader; 140 | 141 | class MPD : public mpd::MPD { 142 | public: 143 | MPD() = default; 144 | MPD(const MPD& other) = default; 145 | ~MPD() override = default; 146 | 147 | // user_map is a map of password -> vector. 148 | typedef std::unordered_map> user_map; 149 | 150 | std::vector db; 151 | std::vector queue; 152 | State state; 153 | mpd::IdleEventSet (*idle_f)() = [] { return mpd::IdleEventSet(); }; 154 | std::string active_user; 155 | user_map users; 156 | 157 | // Alias the option here so it's easier to refer to in tests. 158 | using mpd::MPD::MetadataOption; 159 | absl::StatusOr> ListAll( 160 | MetadataOption metadata = MetadataOption::kInclude) override; 161 | 162 | absl::Status Pause() override { 163 | dbg() << "call:Play" << std::endl; 164 | state.playing = false; 165 | return absl::OkStatus(); 166 | }; 167 | absl::Status Play() override { 168 | dbg() << "call:Pause" << std::endl; 169 | state.playing = true; 170 | return absl::OkStatus(); 171 | }; 172 | absl::Status PlayAt(unsigned position) override { 173 | dbg() << "call:PlayAt(" << position << ")" << std::endl; 174 | assert(position < queue.size() && "can't play song outside of queue"); 175 | state.song_position = position; 176 | state.playing = true; 177 | return absl::OkStatus(); 178 | }; 179 | absl::StatusOr> CurrentStatus() override { 180 | dbg() << "call:Status" << std::endl; 181 | State snapshot(state); 182 | snapshot.queue_length = queue.size(); 183 | return std::unique_ptr(new Status(snapshot)); 184 | }; 185 | absl::StatusOr> Search( 186 | std::string_view uri) override { 187 | dbg() << "call:Search(" << uri << ")" << std::endl; 188 | std::optional found = SearchInternal(uri); 189 | if (!found) { 190 | return absl::NotFoundError(absl::StrFormat("%s not found", uri)); 191 | } 192 | return std::unique_ptr(new Song(*found)); 193 | }; 194 | absl::StatusOr Idle(__attribute__((unused)) 195 | const mpd::IdleEventSet&) override { 196 | dbg() << "call:Idle" << std::endl; 197 | return idle_f(); 198 | }; 199 | absl::Status Add(const std::string& uri) override { 200 | dbg() << "call:Add(" << uri << ")" << std::endl; 201 | std::optional found = SearchInternal(uri); 202 | assert(found && "cannot add URI not in DB"); 203 | queue.push_back(*found); 204 | return absl::OkStatus(); 205 | }; 206 | absl::StatusOr ApplyPassword( 207 | const std::string& password) override { 208 | dbg() << "call:Password(" << password << ")" << std::endl; 209 | using status = mpd::MPD::PasswordStatus; 210 | if (users.find(password) == users.end()) { 211 | return status::kRejected; 212 | } 213 | active_user = password; 214 | return status::kAccepted; 215 | }; 216 | absl::StatusOr CheckCommands( 217 | const std::vector& cmds) override { 218 | dbg() << "call:CheckCommandsAllowed(" << absl::StrJoin(cmds, ", ") 219 | << ")" << std::endl; 220 | std::vector allowed; 221 | if (auto user = users.find(active_user); 222 | !active_user.empty() && user != users.end()) { 223 | allowed = user->second; 224 | } 225 | // If there is no active user, by default, allow these commands. This 226 | // makes it so we don't have to constantly add these in the mocks. 227 | if (active_user.empty()) { 228 | allowed = {"add", "status", "play", "pause", "idle"}; 229 | } 230 | std::vector missing; 231 | for (auto& cmd : cmds) { 232 | if (std::find(allowed.begin(), allowed.end(), cmd) == 233 | allowed.end()) { 234 | missing.emplace_back(cmd); 235 | } 236 | } 237 | mpd::MPD::Authorization auth; 238 | auth.authorized = missing.empty(); 239 | auth.missing = std::move(missing); 240 | return auth; 241 | }; 242 | 243 | // Playing is a special test-only API to get the currently playing song 244 | // from the queue. If there is no currently playing song, and empty 245 | // option is returned. 246 | std::optional Playing() { 247 | if (!state.playing || !state.song_position) { 248 | return std::nullopt; 249 | } 250 | return queue[*state.song_position]; 251 | }; 252 | 253 | private: 254 | std::optional SearchInternal(std::string_view uri) { 255 | for (Song& song : db) { 256 | if (song.URI() == uri) { 257 | return song; 258 | } 259 | } 260 | return std::nullopt; 261 | }; 262 | }; 263 | 264 | inline bool operator==(const MPD& lhs, const MPD& rhs) { 265 | return (lhs.db == rhs.db && lhs.queue == rhs.queue && 266 | lhs.state == rhs.state && lhs.idle_f == rhs.idle_f && 267 | lhs.users == rhs.users); 268 | } 269 | 270 | std::ostream& operator<<(std::ostream& st, const MPD& mpd) { 271 | std::vector db; 272 | for (auto& song : mpd.db) { 273 | db.push_back(song.uri); 274 | } 275 | std::vector queue; 276 | for (auto& song : mpd.queue) { 277 | queue.push_back(song.uri); 278 | } 279 | std::vector users; 280 | for (auto& user : mpd.users) { 281 | users.push_back( 282 | absl::StrCat(user.first, "=", absl::StrJoin(user.second, ","))); 283 | } 284 | st << "MPD<\n" 285 | << " DB: " << absl::StrJoin(db, ",") << std::endl 286 | << " Queue: " << absl::StrJoin(queue, ",") << std::endl 287 | << " State: " 288 | << absl::StrFormat( 289 | "State(%d, %d, %u, %u)", mpd.state.single_mode, mpd.state.playing, 290 | mpd.state.song_position ? *mpd.state.song_position : 0, 291 | mpd.state.queue_length) 292 | << std::endl 293 | << " Idlef: " << absl::StrFormat("%p", mpd.idle_f) << std::endl 294 | << " Active: " << mpd.active_user << std::endl 295 | << " Users: " 296 | << "\n " << absl::StrJoin(users, "\n ") << std::endl 297 | << ">"; 298 | return st; 299 | } 300 | 301 | class SongReader : public mpd::SongReader { 302 | public: 303 | ~SongReader() override = default; 304 | 305 | SongReader(const MPD& mpd) 306 | : SongReader(mpd, MPD::MetadataOption::kInclude) {} 307 | SongReader(const MPD& mpd, MPD::MetadataOption metadata) 308 | : cur_(mpd.db.begin()), end_(mpd.db.end()), metadata_(metadata) {} 309 | 310 | absl::StatusOr> Next() override { 311 | if (Done()) { 312 | return absl::OutOfRangeError("no more songs to read"); 313 | } 314 | 315 | Song* s = new Song(*cur_++); 316 | if (MPD::MetadataOption::kOmit == metadata_) { 317 | // If we're being asked to omit metadata, then clear out the 318 | // tags on our copied song, before sending it. 319 | s->tags = {}; 320 | } 321 | 322 | return std::unique_ptr(s); 323 | }; 324 | 325 | // Done returns true when there are no more songs to get. After Done 326 | // returns true, future calls to `Next` will return an empty option. 327 | bool Done() override { return cur_ == end_; } 328 | 329 | private: 330 | std::vector::const_iterator cur_; 331 | std::vector::const_iterator end_; 332 | MPD::MetadataOption metadata_; 333 | }; 334 | 335 | absl::StatusOr> MPD::ListAll( 336 | MPD::MetadataOption metadata) { 337 | dbg() << absl::StrFormat("call:ListAll(%d)", metadata) << std::endl; 338 | return std::unique_ptr(new SongReader(*this, metadata)); 339 | } 340 | 341 | class Dialer : public mpd::Dialer { 342 | public: 343 | ~Dialer() override = default; 344 | 345 | Dialer(MPD& m) : mpd_(m){}; 346 | 347 | // Check is the address to check the dialed address against. 348 | mpd::Address check; 349 | 350 | absl::StatusOr> Dial( 351 | const mpd::Address& addr, 352 | __attribute__((unused)) 353 | absl::Duration timeout = mpd::Dialer::kDefaultTimeout) const override { 354 | std::string got = absl::StrFormat("%s:%d", addr.host, addr.port); 355 | std::string want = absl::StrFormat("%s:%d", check.host, check.port); 356 | if (got != want) { 357 | return absl::FailedPreconditionError(absl::StrFormat( 358 | "host '%s' does not match check host '%s'", got, want)); 359 | } 360 | return std::unique_ptr(new MPD(mpd_)); 361 | } 362 | 363 | private: 364 | // mpd_ is the MPD instance to return if the user dials the check address. 365 | MPD& mpd_; 366 | }; 367 | 368 | } // namespace fake 369 | } // namespace ashuffle 370 | 371 | #endif // __ASHUFFLE_T_MPD_FAKE_H__ 372 | -------------------------------------------------------------------------------- /t/mpd_fake_test.cc: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | #include "t/mpd_fake.h" 8 | #include "t/test_asserts.h" 9 | 10 | #include 11 | #include 12 | 13 | using namespace ashuffle; 14 | 15 | using ::testing::Eq; 16 | using ::testing::Optional; 17 | 18 | TEST(MPD, ListAllMetadataOmit) { 19 | // Test that even if we provide songs with metadata to a fake MPD, it 20 | // will hide that metadata in the produced songs. 21 | std::vector songs = { 22 | fake::Song("first", {{MPD_TAG_ALBUM, "album_a"}}), 23 | fake::Song("second", {{MPD_TAG_ALBUM, "album_a"}}), 24 | }; 25 | 26 | fake::MPD mpd; 27 | for (auto &song : songs) { 28 | mpd.db.push_back(song); 29 | } 30 | 31 | absl::StatusOr> reader_or = 32 | mpd.ListAll(fake::MPD::MetadataOption::kOmit); 33 | ASSERT_OK(reader_or.status()) << "failed to list all"; 34 | std::unique_ptr reader = std::move(reader_or.value()); 35 | 36 | ASSERT_FALSE(reader->Done()) << "The reader should have at least one song"; 37 | 38 | while (!reader->Done()) { 39 | auto song = reader->Next(); 40 | ASSERT_OK(song.status()) << "Song returned from Next should be set"; 41 | EXPECT_EQ((*song)->Tag(MPD_TAG_ALBUM), std::nullopt) 42 | << "Song should not have an album tag set"; 43 | } 44 | 45 | EXPECT_THAT(songs[0].Tag(MPD_TAG_ALBUM), Optional(std::string("album_a"))) 46 | << "Original song should not be mutated."; 47 | } 48 | -------------------------------------------------------------------------------- /t/readme.md: -------------------------------------------------------------------------------- 1 | # ashuffle testing 2 | 3 | This directory (`./t`) contains all of the test sources used to test ashuffle. 4 | To validate that ashuffle is working correctly, it has a comprehensive battery 5 | of unit tests, and integration tests. These tests are checked by a continuous 6 | integration system (travis-ci) to ensure that ashuffle stays healthy. New 7 | ashuffle contributions should include comprehensive testing. 8 | 9 | This document describes how the various ashuffle tests work. 10 | 11 | ## unit testing 12 | 13 | The first line of defense from software defects is ashuffle's unit test suite. 14 | These tests run against only ashuffle, not libmpdclient. They attempt to 15 | target individual ashuffle subsystems like "shuffle", or "rule". You can run the 16 | unit tests like so: 17 | 18 | meson -Dtests=enabled build 19 | ninja -C build test 20 | 21 | Unit tests are located in the root of the testing directory. All unit-tests 22 | are writing using googletest (sometimes know as gtest) and googlemock. These 23 | libraries are fairly popular for C++ code, so you may already be familiar 24 | with them. If you're not, you can find documentation for them on the 25 | [googletest github page](https://github.com/google/googletest). If you're 26 | not familiar with the framework, you can also try copying and tweaking an 27 | existing test. 28 | 29 | ashuffle uses a C++ wrapper (`src/mpd.h`) to interact with `libmpdclient`. 30 | This wrapper has a "real" implementation (`src/mpd_client.{h,cc}`) that 31 | proxies to the libmpdclient API, and a "fake" implementation (`t/mpdfake.h`). 32 | ashuffle itself is written against the "generic" API exposed by `src/mpd.h`, so 33 | it's easy to inject fake dependencies (MPD connection, songs, etc.) 34 | from `t/mpdfake.h` when needed. 35 | 36 | As part of ashuffle's continuous integration testing, these unit tests are also 37 | run under Clang's AddressSanitizer, and MemorySanitizer to check for leaks, 38 | and other invalid memory accesses. This somewhat non-trivial to run locally, 39 | and will be run automatically when a pull-request is opened against ashuffle. 40 | If you want to run the sanitizers locally, take a look at 41 | `/scripts/travis/unit-test`. 42 | 43 | ## integration testing 44 | 45 | Since ashuffle's unit-tests are run against fake implementations, additional 46 | work is needed to verify that `ashuffle` works with real libmpdclient and MPD 47 | implementations. ashuffle integration 48 | tests use a real libmpdclient, and a real MPD instance (both built from source). 49 | A Go test harness is used to run MPD, and ashuffle, and also verify that 50 | ashuffle takes the appropriate action for the situation. To increase 51 | reproducibility of these tests, we run them entirely within a docker container. 52 | Since we build MPD and libmpdclient from source for each run, it's easy to 53 | test against different combinations of libmpdclient/MPD version. This allows 54 | us to make sure that ashuffle stays compatible with older libmpdclient and 55 | MPD releases. 56 | 57 | To run the tests, you can use the `run-integration` script: 58 | 59 | scripts/run-integration 60 | 61 | which will test your local version of ashuffle against the lastest MPD and 62 | libmpdclient. You can use the `--mpd-version`, and `--libmpdclient-version` 63 | flags to select other MPD or libmpdclient versions. 64 | 65 | The integration test harness, and tests themselves are stored in 66 | `./integration`. The Dockerfile, test runner, and other docker helpers are 67 | stored in `./docker`. Some static files used in the docker containers are 68 | stored in `./static`. 69 | 70 | A substantial portion of the integration test time comes from building the 71 | "root" docker image. The "root" image contains the buildtools needed to build 72 | ashuffle/libmpdclient/MPD, as well as a substantial music library 73 | (20,000 tracks) that can be used for basic load-testing. This root image is 74 | built separately, and stored on docker-hub: [jkz0/ashuffle-integration-root 75 | ](https://hub.docker.com/r/jkz0/ashuffle-integration-root). This is purely 76 | for convenience, the Dockerfile used to build the image is developed in 77 | [joshkunz/ashuffle-integration-root 78 | ](https://github.com/joshkunz/ashuffle-integration-root), and can be built 79 | independently if so desired. 80 | 81 | On my machine, using a pre-built root image, and a fully cached integration 82 | test container, the integration tests run in ~30s. An uncached run, with 83 | a pre-built root image takes ~60s. 84 | -------------------------------------------------------------------------------- /t/rule_test.cc: -------------------------------------------------------------------------------- 1 | #include "rule.h" 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | 8 | #include "mpd.h" 9 | 10 | #include "t/mpd_fake.h" 11 | 12 | #include 13 | 14 | using namespace ashuffle; 15 | 16 | TEST(Rule, Empty) { 17 | Rule rule; 18 | EXPECT_TRUE(rule.Empty()) << "rule with no matchers should be empty"; 19 | 20 | rule.AddPattern(MPD_TAG_ARTIST, "foo fighters"); 21 | EXPECT_FALSE(rule.Empty()) << "rule with matcher should not be empty"; 22 | } 23 | 24 | TEST(Rule, Accepts) { 25 | Rule rule; 26 | rule.AddPattern(MPD_TAG_ARTIST, "foo fighters"); 27 | 28 | fake::Song matching({{MPD_TAG_ARTIST, "foo fighters"}}); 29 | fake::Song non_matching({{MPD_TAG_ARTIST, "some randy"}}); 30 | 31 | // Remember, these are exclusion rules, so if a song matches, it should 32 | // *not* be accepted by the rule. 33 | EXPECT_FALSE(rule.Accepts(matching)); 34 | EXPECT_TRUE(rule.Accepts(non_matching)); 35 | } 36 | 37 | TEST(Rule, PatternIsSubstring) { 38 | Rule rule; 39 | rule.AddPattern(MPD_TAG_ARTIST, "foo"); 40 | 41 | fake::Song matching({{MPD_TAG_ARTIST, "foo fighters"}}); 42 | fake::Song mid_word_matching({{MPD_TAG_ARTIST, "floofoofaf"}}); 43 | 44 | EXPECT_FALSE(rule.Accepts(matching)); 45 | EXPECT_FALSE(rule.Accepts(mid_word_matching)); 46 | } 47 | 48 | TEST(Rule, PatternCaseInsensitive) { 49 | Rule rule; 50 | rule.AddPattern(MPD_TAG_ARTIST, "foo"); 51 | 52 | fake::Song weird_case({{MPD_TAG_ARTIST, "fLOoFoOfaF"}}); 53 | 54 | EXPECT_FALSE(rule.Accepts(weird_case)) 55 | << "failed to match substring with different case"; 56 | } 57 | 58 | TEST(Rule, MultiplePatterns) { 59 | Rule rule; 60 | rule.AddPattern(MPD_TAG_ALBUM, "__album__"); 61 | rule.AddPattern(MPD_TAG_ARTIST, "__artist__"); 62 | 63 | fake::Song full_match({ 64 | {MPD_TAG_ARTIST, "__artist__"}, 65 | {MPD_TAG_ALBUM, "__album__"}, 66 | }); 67 | 68 | fake::Song partial_match_artist({ 69 | {MPD_TAG_ARTIST, "__artist__"}, 70 | {MPD_TAG_ALBUM, "no match"}, 71 | }); 72 | 73 | fake::Song partial_match_album({ 74 | {MPD_TAG_ARTIST, "no match"}, 75 | {MPD_TAG_ALBUM, "__album__"}, 76 | }); 77 | 78 | fake::Song no_match({ 79 | {MPD_TAG_ARTIST, "no match"}, 80 | {MPD_TAG_ALBUM, "no match"}, 81 | }); 82 | 83 | EXPECT_FALSE(rule.Accepts(full_match)) 84 | << "song accepted even though some fields match a pattern"; 85 | // If any field doesn't match, the rule should consider the song 86 | // as accepted. 87 | EXPECT_TRUE(rule.Accepts(partial_match_artist)); 88 | EXPECT_TRUE(rule.Accepts(partial_match_album)); 89 | EXPECT_TRUE(rule.Accepts(no_match)); 90 | } 91 | 92 | TEST(Rule, SongMissingPatternTag) { 93 | Rule rule; 94 | rule.AddPattern(MPD_TAG_ALBUM, "__album__"); 95 | 96 | // This song does not even have an MPD_TAG_ALBUM tag. 97 | fake::Song missing_pattern_tag({ 98 | {MPD_TAG_ARTIST, "__artist__"}, 99 | }); 100 | 101 | EXPECT_TRUE(rule.Accepts(missing_pattern_tag)) 102 | << "Songs with missing tags should be accepted"; 103 | } 104 | -------------------------------------------------------------------------------- /t/shuffle_test.cc: -------------------------------------------------------------------------------- 1 | #include "shuffle.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include 11 | #include 12 | #include 13 | 14 | using namespace ashuffle; 15 | 16 | using ::testing::ContainerEq; 17 | using ::testing::Each; 18 | using ::testing::ElementsAre; 19 | using ::testing::Range; 20 | using ::testing::Values; 21 | using ::testing::WhenSorted; 22 | 23 | TEST(ShuffleChainTest, AddPick) { 24 | ShuffleChain chain; 25 | std::string test_str("test"); 26 | 27 | chain.Add(test_str); 28 | 29 | EXPECT_EQ(chain.Len(), 1); 30 | EXPECT_EQ(chain.LenURIs(), 1); 31 | EXPECT_THAT(chain.Pick(), ElementsAre(test_str)); 32 | EXPECT_THAT(chain.Pick(), ElementsAre(test_str)) 33 | << "could not double-pick from the same 1-item chain."; 34 | } 35 | 36 | TEST(ShuffleChainTest, AddPickGroup) { 37 | ShuffleChain chain; 38 | std::vector g = {"a", "b", "c"}; 39 | 40 | chain.Add(g); 41 | 42 | EXPECT_EQ(chain.Len(), 1); 43 | EXPECT_EQ(chain.LenURIs(), 3); 44 | EXPECT_THAT(chain.Pick(), ContainerEq(g)); 45 | EXPECT_THAT(chain.Pick(), ContainerEq(g)) 46 | << "could not double-pick from the same 1-item chain."; 47 | } 48 | 49 | MATCHER_P(IsInCollection, c, "") { return c.find(arg) != c.end(); } 50 | 51 | TEST(ShuffleChainTest, PickN) { 52 | constexpr int test_rounds = 5000; 53 | const std::unordered_set test_items{"item 1", "item 2", 54 | "item 3"}; 55 | 56 | ShuffleChain chain; 57 | 58 | for (auto& s : test_items) { 59 | chain.Add(s); 60 | } 61 | 62 | std::vector picked; 63 | for (int i = 0; i < test_rounds; i++) { 64 | const std::vector& got = chain.Pick(); 65 | picked.insert(picked.end(), got.begin(), got.end()); 66 | } 67 | 68 | EXPECT_THAT(picked, Each(IsInCollection(test_items))) 69 | << "ShuffleChain picked item not in chain!"; 70 | } 71 | 72 | class WindowTest : public testing::TestWithParam { 73 | public: 74 | ShuffleChain chain_; 75 | 76 | // This method is purely for documentation. 77 | int WindowSize() { return GetParam(); }; 78 | 79 | void SetUp() override { 80 | chain_ = ShuffleChain(WindowSize()); 81 | for (int i = 0; i < WindowSize(); i++) { 82 | chain_.Add(absl::StrCat("item ", i)); 83 | } 84 | }; 85 | }; 86 | 87 | TEST_P(WindowTest, Repeats) { 88 | // The first window_size items should all be unique, so when we check the 89 | // length of "picked", it should match window_size. 90 | std::unordered_set picked; 91 | for (int i = 0; i < WindowSize(); i++) { 92 | auto got = chain_.Pick(); 93 | picked.insert(got.begin(), got.end()); 94 | } 95 | 96 | EXPECT_EQ(picked.size(), static_cast(WindowSize())) 97 | << absl::StrCat("first ", WindowSize(), " items should be unique"); 98 | 99 | // Since we only put in window_size songs, we should now be forced to get 100 | // a repeat by picking one more song. 101 | auto got = chain_.Pick(); 102 | picked.insert(got.begin(), got.end()); 103 | 104 | EXPECT_EQ(picked.size(), static_cast(WindowSize())) 105 | << "should have gotten a repeat by picking one more song"; 106 | } 107 | 108 | INSTANTIATE_TEST_SUITE_P(SmallWindows, WindowTest, Range(1, 25 + 1)); 109 | INSTANTIATE_TEST_SUITE_P(BigWindows, WindowTest, Values(50, 99, 100, 1000)); 110 | 111 | // In this test we seed rand (srand) with a known value so we have 112 | // deterministic randomness from `rand`. These values are known to be 113 | // random according to `rand()` so we're just validating that `shuffle` is 114 | // actually picking according to rand. 115 | // Note: This test may break if we change how we store items in the list, or 116 | // how we index the song list when picking randomly. It's hard to test 117 | // that something is random :/. 118 | TEST(ShuffleChainTest, IsRandom) { 119 | std::mt19937 rnd_engine(4); 120 | 121 | ShuffleChain chain(2, rnd_engine); 122 | 123 | chain.Add("test a"); 124 | chain.Add("test b"); 125 | chain.Add("test c"); 126 | 127 | std::vector want{"test c", "test b", "test a", "test c"}; 128 | std::vector got; 129 | for (int i = 0; i < 4; i++) { 130 | auto pick = chain.Pick(); 131 | got.insert(got.end(), pick.begin(), pick.end()); 132 | } 133 | 134 | EXPECT_THAT(got, ContainerEq(want)); 135 | } 136 | 137 | TEST(ShuffleChainTest, Items) { 138 | ShuffleChain chain(2); 139 | 140 | const std::vector test_uris{"test a", "test b", "test c"}; 141 | const std::vector test_group{"group a", "group b"}; 142 | 143 | chain.Add(test_uris[0]); 144 | chain.Add(test_uris[1]); 145 | chain.Add(test_uris[2]); 146 | chain.Add(test_group); 147 | 148 | // This is a gross hack to ensure that we've initialized the window pool. 149 | // We want to make sure shuffle_chain also picks up songs in the window. 150 | // If the internel implementation of the chain changes, then this will 151 | // be OK, it just won't do anything. 152 | (void)chain.Pick(); 153 | 154 | std::vector> got = chain.Items(); 155 | std::vector> want = { 156 | test_group, 157 | {"test a"}, 158 | {"test b"}, 159 | {"test c"}, 160 | }; 161 | 162 | EXPECT_THAT(got, WhenSorted(ContainerEq(want))); 163 | } 164 | -------------------------------------------------------------------------------- /t/static/mpd.conf: -------------------------------------------------------------------------------- 1 | music_directory "/music" 2 | playlist_directory "/mpd/playlists" 3 | db_file "/mpd/database" 4 | pid_file "/mpd/pid" 5 | state_file "/mpd/state" 6 | sticker_file "/mpd/sticker.sql" 7 | audio_output { 8 | type "null" 9 | name "null" 10 | } 11 | -------------------------------------------------------------------------------- /t/test_asserts.h: -------------------------------------------------------------------------------- 1 | #ifndef __ASHUFFLE_T_TEST_ASSERTS_H__ 2 | #define __ASHUFFLE_T_TEST_ASSERTS_H__ 3 | 4 | #include 5 | 6 | // Assert that the given status is OK, or print the bad status. 7 | #define ASSERT_OK(x) ASSERT_TRUE((x).ok()) << "Bad status: " << (x) 8 | 9 | #endif // __ASHUFFLE_T_TEST_ASSERTS_H__ 10 | -------------------------------------------------------------------------------- /tools/cmake/inject_project_source_dir.cmake: -------------------------------------------------------------------------------- 1 | # PROJECT_SOURCE_DIR should always be /subprojects/absl, 2 | # so ../.. to get back to the true root. 3 | get_filename_component(PROJECT_SOURCE_DIR ${PROJECT_SOURCE_DIR}/../.. ABSOLUTE) 4 | -------------------------------------------------------------------------------- /tools/meta/commands/libmpdclient/libmpdclient.go: -------------------------------------------------------------------------------- 1 | // Package libmpdclient provides a subcommand that is capable of installing 2 | // and configuring libmpdclient in a target system. 3 | package libmpdclient 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "os" 11 | "strings" 12 | 13 | "github.com/urfave/cli/v3" 14 | 15 | "meta/exec" 16 | "meta/fetch" 17 | "meta/project" 18 | "meta/versions/libmpdclientver" 19 | "meta/workspace" 20 | ) 21 | 22 | func install(ctx context.Context, cmd *cli.Command) error { 23 | ws, err := workspace.New() 24 | if err != nil { 25 | return fmt.Errorf("workspace: %v", err) 26 | } 27 | defer ws.Cleanup() 28 | 29 | v, err := libmpdclientver.Resolve(cmd.String("version")) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | log.Printf("Using libmpdclient version %s", v) 35 | 36 | if v.Major != 2 { 37 | return fmt.Errorf("unexpected major version in %s, only 2.x is supported", v) 38 | } 39 | 40 | if v.Minor == 12 { 41 | return fmt.Errorf("version %s not supported", v) 42 | } 43 | 44 | if err := fetch.URL(v.ReleaseURL(), "libmpdclient.tar.xz"); err != nil { 45 | return err 46 | } 47 | 48 | tar := exec.Command("tar", "-xJ", "--strip-components=1", "-f", "libmpdclient.tar.xz") 49 | if err := tar.Run(); err != nil { 50 | return errors.New("failed to unpack") 51 | } 52 | 53 | if err := os.Chdir(ws.Root); err != nil { 54 | return fmt.Errorf("cd to workspace root: %w", err) 55 | } 56 | 57 | var proj project.Project 58 | if v.Minor < 12 { 59 | if cmd.String("cross_file") != "" { 60 | return errors.New("cross compilation via --cross_file not supported with this version of libmpdclient") 61 | } 62 | p, err := project.NewAutomake(ws.Root) 63 | if err != nil { 64 | return err 65 | } 66 | proj = p 67 | } else { 68 | var opts project.MesonOptions 69 | if cf := cmd.String("cross_file"); cf != "" { 70 | opts.Extra = append(opts.Extra, "--cross-file", cf) 71 | } 72 | p, err := project.NewMeson(ws.Root, opts) 73 | if err != nil { 74 | return err 75 | } 76 | proj = p 77 | } 78 | return project.Install(proj, cmd.String("prefix")) 79 | } 80 | 81 | var Command = &cli.Command{ 82 | Name: "libmpdclient", 83 | Usage: "Install libmpdclient.", 84 | Flags: []cli.Flag{ 85 | &cli.StringFlag{ 86 | Name: "version", 87 | Value: "latest", 88 | Usage: strings.Join([]string{ 89 | "version of libmpdclient to install, or 'latest' to", 90 | "automatically query for the latest released version, and", 91 | "install that.", 92 | }, " "), 93 | }, 94 | &cli.StringFlag{ 95 | Name: "prefix", 96 | Value: "", 97 | Usage: "The root of the target installation path.", 98 | Required: true, 99 | }, 100 | &cli.StringFlag{ 101 | Name: "cross_file", 102 | Value: "", 103 | Usage: "The Meson 'cross-file' to use when cross-compiling", 104 | }, 105 | }, 106 | Action: install, 107 | } 108 | -------------------------------------------------------------------------------- /tools/meta/commands/mpd/mpd.go: -------------------------------------------------------------------------------- 1 | package mpd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "slices" 10 | "strings" 11 | 12 | "github.com/urfave/cli/v3" 13 | 14 | "meta/exec" 15 | "meta/fetch" 16 | "meta/project" 17 | "meta/versions/mpdver" 18 | "meta/workspace" 19 | ) 20 | 21 | func applyPatches(dir string) error { 22 | patches, err := filepath.Glob(filepath.Join(dir, "*.patch")) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | slices.Sort(patches) 28 | 29 | for _, patch := range patches { 30 | log.Printf("Applying patch %q", patch) 31 | f, err := os.Open(patch) 32 | if err != nil { 33 | return fmt.Errorf("failed to open patch %q: %w", patch, err) 34 | } 35 | defer f.Close() 36 | cmd := exec.Command("patch", "-p1") 37 | cmd.Stdin = f 38 | if err := cmd.Run(); err != nil { 39 | return fmt.Errorf("failed to apply %q: %w", patch, err) 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | func install(ctx context.Context, cmd *cli.Command) error { 46 | // Note: We do this first, so we can get the path before workspace.New() 47 | // moves us. 48 | var patchRoot string 49 | patchRoot, err := filepath.Abs(cmd.String("patch_root")) 50 | if err != nil { 51 | return fmt.Errorf("failed to find patch root %q: %w", cmd.String("patch_root"), err) 52 | } 53 | // Make sure we actually have a patch root. 54 | if patchRoot == "" { 55 | return fmt.Errorf("patch root is empty") 56 | } 57 | 58 | ws, err := workspace.New() 59 | if err != nil { 60 | return err 61 | } 62 | defer ws.Cleanup() 63 | 64 | v, err := mpdver.Resolve(cmd.String("version")) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | if v.Major != 0 { 70 | return fmt.Errorf("unsupported MPD Major version > 0: %s", v) 71 | } 72 | 73 | log.Printf("Picked MPD version %s", v) 74 | 75 | if err := fetch.URL(v.ReleaseURL(), "mpd.tar.xz"); err != nil { 76 | return err 77 | } 78 | 79 | if err := exec.Command("tar", "--strip-components=1", "-xJf", "mpd.tar.xz").Run(); err != nil { 80 | return err 81 | } 82 | 83 | versionPatchDir := filepath.Join(patchRoot, "mpd", v.String()) 84 | if stat, err := os.Stat(versionPatchDir); err == nil { 85 | // If the patch dir exists, then try to apply all the patches 86 | if !stat.IsDir() { 87 | return fmt.Errorf("patch dir path %q is not a directory", versionPatchDir) 88 | } 89 | if err := applyPatches(versionPatchDir); err != nil { 90 | return fmt.Errorf("failed to apply patches: %w", err) 91 | } 92 | } else if os.IsNotExist(err) { 93 | // If it doesn't exist, then just log that we're not applying anything 94 | log.Printf("Patch dir %q not found, no patches to apply", versionPatchDir) 95 | } else { 96 | return fmt.Errorf("failed to locate patch dir: %w", err) 97 | } 98 | 99 | proj, err := project.NewMeson(ws.Root, project.MesonOptions{ 100 | BuildType: project.BuildDebugOptimized, 101 | BuildDirectory: "build/release", 102 | Extra: []string{"-Db_ndebug=true"}, 103 | }) 104 | if err != nil { 105 | return err 106 | } 107 | return project.Install(proj, cmd.String("prefix")) 108 | } 109 | 110 | var Command = &cli.Command{ 111 | Name: "mpd", 112 | Usage: "Install mpd.", 113 | Flags: []cli.Flag{ 114 | &cli.StringFlag{ 115 | Name: "version", 116 | Value: "latest", 117 | Usage: strings.Join([]string{ 118 | "version of mpd to install, or 'latest' to", 119 | "automatically query for the latest released version, and", 120 | "install that.", 121 | }, " "), 122 | }, 123 | &cli.StringFlag{ 124 | Name: "prefix", 125 | Value: "", 126 | Usage: "The root of the target installation path.", 127 | Required: true, 128 | }, 129 | &cli.StringFlag{ 130 | Name: "patch_root", 131 | Value: "", 132 | Usage: "The root directory for MPD patches.", 133 | }, 134 | }, 135 | Action: install, 136 | } 137 | -------------------------------------------------------------------------------- /tools/meta/commands/release/release.go: -------------------------------------------------------------------------------- 1 | package release 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/urfave/cli/v3" 12 | 13 | "meta/crosstool" 14 | "meta/fileutil" 15 | "meta/project" 16 | "meta/workspace" 17 | ) 18 | 19 | func crossFile(crosstool *crosstool.Crosstool) (string, error) { 20 | cf, err := os.CreateTemp("", "cross-"+crosstool.CPU.Triple().Architecture+"-*.txt") 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | if err := crosstool.WriteCrossFile(cf); err != nil { 26 | cf.Close() 27 | os.Remove(cf.Name()) 28 | return "", err 29 | } 30 | 31 | cf.Close() 32 | return cf.Name(), nil 33 | } 34 | 35 | func releaseCross(ctx context.Context, cmd *cli.Command, out string, cpu crosstool.CPU) error { 36 | src, err := os.Getwd() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | crosstool, err := crosstool.For(cpu, crosstool.Options{ 42 | CC: cmd.String("cross_cc"), 43 | CXX: cmd.String("cross_cxx"), 44 | }) 45 | if err != nil { 46 | return err 47 | } 48 | defer crosstool.Cleanup() 49 | 50 | crossF, err := crossFile(crosstool) 51 | if err != nil { 52 | return err 53 | } 54 | defer os.Remove(crossF) 55 | 56 | libmpdclientArgs := []string{ 57 | "meta", "install", "libmpdclient", 58 | fmt.Sprintf("--cross_file=%s", crossF), 59 | // Install into the crosstool root, so that our `--sysroot` works 60 | // when building ashuffle. 61 | fmt.Sprintf("--prefix=%s", crosstool.Root), 62 | } 63 | if ver := cmd.String("libmpdclient_version"); ver != "" { 64 | flag := fmt.Sprintf("--version=%s", ver) 65 | libmpdclientArgs = append(libmpdclientArgs, flag) 66 | } 67 | // XXX: In v3, this results in an infinite loop, likely because "release" 68 | // becomes its own parent. Not exactly sure what context is _supposed_ 69 | // to be used here. 70 | if err := cmd.Root().Run(context.TODO(), libmpdclientArgs); err != nil { 71 | return fmt.Errorf("failed to build libmpdclient: %w", err) 72 | } 73 | 74 | build, err := workspace.New(workspace.NoCD) 75 | if err != nil { 76 | return err 77 | } 78 | defer build.Cleanup() 79 | 80 | p, err := project.NewMeson(src, project.MesonOptions{ 81 | BuildType: project.BuildDebugOptimized, 82 | BuildDirectory: build.Root, 83 | Extra: []string{"--cross-file", crossF}, 84 | }) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | if err := p.Configure(""); err != nil { 90 | return err 91 | } 92 | 93 | if err := p.Build("ashuffle"); err != nil { 94 | return fmt.Errorf("failed to build ashuffle: %w", err) 95 | } 96 | 97 | if err := fileutil.RemoveRPath(build.Path("ashuffle")); err != nil { 98 | return fmt.Errorf("failed to remove rpath: %w", err) 99 | } 100 | 101 | return fileutil.Copy(build.Path("ashuffle"), out) 102 | } 103 | 104 | func releasex86(out string) error { 105 | cwd, err := os.Getwd() 106 | if err != nil { 107 | return err 108 | } 109 | 110 | build, err := workspace.New(workspace.NoCD) 111 | if err != nil { 112 | return err 113 | } 114 | defer build.Cleanup() 115 | 116 | p, err := project.NewMeson(cwd, project.MesonOptions{ 117 | BuildType: project.BuildDebugOptimized, 118 | BuildDirectory: build.Root, 119 | }) 120 | if err != nil { 121 | return err 122 | } 123 | 124 | if err := p.Configure(""); err != nil { 125 | return err 126 | } 127 | 128 | if err := p.Build("ashuffle"); err != nil { 129 | return err 130 | } 131 | 132 | if err := fileutil.RemoveRPath(build.Path("ashuffle")); err != nil { 133 | return fmt.Errorf("failed to remove rpath: %w", err) 134 | } 135 | 136 | return fileutil.Copy(build.Path("ashuffle"), out) 137 | } 138 | 139 | func release(ctx context.Context, cmd *cli.Command) error { 140 | if !cmd.Args().Present() { 141 | return errors.New("an architecture (`ARCH`) must be provided") 142 | } 143 | 144 | out := cmd.String("output") 145 | if out == "" { 146 | o, err := filepath.Abs("./ashuffle") 147 | if err != nil { 148 | return err 149 | } 150 | out = o 151 | } 152 | 153 | arch := cmd.Args().First() 154 | switch arch { 155 | case "x86_64": 156 | return releasex86(out) 157 | case "aarch64": 158 | // Processors used on 3B+ support this arch, but RPi OS does not. 159 | // These are probably OK defaults for aarch64 though. 160 | return releaseCross(ctx, cmd, out, crosstool.CortexA53) 161 | case "armv7h": 162 | // Used on Raspberry Pi 2B+. Should also work for newer 163 | // chips running 32-bit RPi OS. 164 | return releaseCross(ctx, cmd, out, crosstool.CortexA7) 165 | case "armv6h": 166 | // Used on Raspberry Pi 0/1. 167 | return releaseCross(ctx, cmd, out, crosstool.ARM1176JZF_S) 168 | } 169 | 170 | return fmt.Errorf("architecture %q not supported", cmd.Args().First()) 171 | } 172 | 173 | var Command = &cli.Command{ 174 | Name: "release", 175 | Usage: "Build release binaries for ashuffle for `ARCH`.", 176 | Flags: []cli.Flag{ 177 | &cli.StringFlag{ 178 | Name: "libmpdclient_version", 179 | Value: "", 180 | Usage: strings.Join([]string{ 181 | "Version of libmpdclient to build against, or 'latest' to", 182 | "automatically query for the latest released version, and", 183 | "build against that. If unset, the system version (whether", 184 | "present or not) will be used. Currently only supported by", 185 | "AArch64 release.", 186 | }, " "), 187 | }, 188 | &cli.StringFlag{ 189 | Name: "cross_cc", 190 | Value: "clang", 191 | Usage: strings.Join([]string{ 192 | "Name of the C compiler driver to use during cross compilation.", 193 | "Defaults to 'clang'. The driver must support the `--target`", 194 | "option.", 195 | }, " "), 196 | }, 197 | &cli.StringFlag{ 198 | Name: "cross_cxx", 199 | Value: "clang++", 200 | Usage: strings.Join([]string{ 201 | "Name of the C++ compiler driver to use during cross", 202 | "compilation. Defaults to 'clang'. The driver must support the", 203 | "`--target` option.", 204 | }, " "), 205 | }, 206 | &cli.StringFlag{ 207 | Name: "output", 208 | Aliases: []string{"o"}, 209 | Value: "", 210 | Usage: "If set, the built binary will be written to this location.", 211 | }, 212 | }, 213 | Action: release, 214 | } 215 | -------------------------------------------------------------------------------- /tools/meta/commands/resolveversions/resolve_versions.go: -------------------------------------------------------------------------------- 1 | package resolveversions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "meta/versions/libmpdclientver" 7 | "meta/versions/mpdver" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/urfave/cli/v3" 12 | ) 13 | 14 | func resolve(ctx context.Context, cmd *cli.Command) error { 15 | var out []string 16 | 17 | { 18 | v, err := mpdver.Resolve(cmd.String("mpd")) 19 | if err != nil { 20 | return err 21 | } 22 | out = append(out, "MPD_VERSION="+v.String()) 23 | } 24 | 25 | { 26 | v, err := libmpdclientver.Resolve(cmd.String("libmpdclient")) 27 | if err != nil { 28 | return err 29 | } 30 | out = append(out, "LIBMPDCLIENT_VERSION="+v.String()) 31 | } 32 | 33 | sort.Strings(out) 34 | fmt.Println(strings.Join(out, "\n")) 35 | 36 | return nil 37 | } 38 | 39 | var Command = &cli.Command{ 40 | Name: "resolve-versions", 41 | Usage: "resolve-versions --mpd latest --libmpdclient latest", 42 | Flags: []cli.Flag{ 43 | &cli.StringFlag{ 44 | Name: "mpd", 45 | Value: "latest", 46 | Usage: strings.Join([]string{ 47 | "version of mpd to resolve, or 'latest' to", 48 | "automatically query for the latest released version, and", 49 | "resolve that.", 50 | }, " "), 51 | }, 52 | &cli.StringFlag{ 53 | Name: "libmpdclient", 54 | Value: "latest", 55 | Usage: strings.Join([]string{ 56 | "version of libmpdclient to resolve, or 'latest' to", 57 | "automatically query for the latest released version.", 58 | }, " "), 59 | }, 60 | }, 61 | Action: resolve, 62 | } 63 | -------------------------------------------------------------------------------- /tools/meta/commands/testbuild/testbuild.go: -------------------------------------------------------------------------------- 1 | package testbuild 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/urfave/cli/v3" 9 | 10 | "meta/fileutil" 11 | "meta/project" 12 | "meta/workspace" 13 | ) 14 | 15 | func testbuild(ctx context.Context, cmd *cli.Command) error { 16 | out := cmd.String("output") 17 | if out == "" { 18 | o, err := filepath.Abs("./ashuffle") 19 | if err != nil { 20 | return err 21 | } 22 | out = o 23 | } 24 | 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | return err 28 | } 29 | 30 | build, err := workspace.New(workspace.NoCD) 31 | if err != nil { 32 | return err 33 | } 34 | defer build.Cleanup() 35 | 36 | p, err := project.NewMeson(cwd, project.MesonOptions{ 37 | BuildType: project.BuildDebugOptimized, 38 | BuildDirectory: build.Root, 39 | }) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | if err := p.Configure(""); err != nil { 45 | return err 46 | } 47 | 48 | if err := p.Build("ashuffle"); err != nil { 49 | return err 50 | } 51 | 52 | return fileutil.Copy(build.Path("ashuffle"), out) 53 | } 54 | 55 | var Command = &cli.Command{ 56 | Name: "testbuild", 57 | Usage: "Build ashuffle for integration tests.", 58 | Flags: []cli.Flag{ 59 | &cli.StringFlag{ 60 | Name: "output", 61 | Aliases: []string{"o"}, 62 | Value: "", 63 | Usage: "If set, the built binary will be written to this location.", 64 | }, 65 | }, 66 | Action: testbuild, 67 | } 68 | -------------------------------------------------------------------------------- /tools/meta/crosstool/crosstool.go: -------------------------------------------------------------------------------- 1 | // Package crosstool contains routiles for constructing cross-compilation 2 | // environments for various (ARM) CPUs. 3 | package crosstool 4 | 5 | import ( 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path" 11 | "strings" 12 | "text/template" 13 | 14 | "meta/fetch" 15 | "meta/project" 16 | "meta/workspace" 17 | ) 18 | 19 | var ( 20 | raspbianRoot = fetch.RemoteArchive{ 21 | URL: "http://downloads.raspberrypi.org/raspbian_lite/archive/2019-04-09-22:48/root.tar.xz", 22 | SHA256: "64af252aed817429e760cd3aa10f8b54713e678828f65fca8a1a76afe495ac61", 23 | Format: fetch.TarXz, 24 | ExtraOptions: []string{ 25 | "--exclude=./dev/*", 26 | }, 27 | } 28 | 29 | ubuntuAArch64Root = fetch.RemoteArchive{ 30 | URL: "https://storage.googleapis.com/ashuffle-data/ubuntu_16.04_aarch64_root.tar.xz", 31 | SHA256: "441d94b8e8ab42bf31bf98a04c87dd1de3e84586090d200d4bb4974960385605", 32 | Format: fetch.TarXz, 33 | ExtraOptions: []string{ 34 | "--strip-components=1", 35 | }, 36 | } 37 | 38 | llvmSource = fetch.RemoteArchive{ 39 | URL: "https://github.com/llvm/llvm-project/releases/download/llvmorg-13.0.0/llvm-project-13.0.0.src.tar.xz", 40 | SHA256: "6075ad30f1ac0e15f07c1bf062c1e1268c241d674f11bd32cdf0e040c71f2bf3", 41 | Format: fetch.TarXz, 42 | ExtraOptions: []string{ 43 | "--strip-components=1", 44 | }, 45 | } 46 | ) 47 | 48 | // Triple represents a target triple for a particular platform. 49 | type Triple struct { 50 | Architecture string 51 | Vendor string 52 | System string 53 | ABI string 54 | } 55 | 56 | // String implements fmt.Stringer for Triple. 57 | func (t Triple) String() string { 58 | return strings.Join([]string{ 59 | t.Architecture, 60 | t.Vendor, 61 | t.System, 62 | t.ABI, 63 | }, "-") 64 | } 65 | 66 | // CPU represents a CPU for which we can build a crosstool. 67 | type CPU string 68 | 69 | const ( 70 | CortexA53 CPU = "cortex-a53" 71 | CortexA7 CPU = "cortex-a7" 72 | ARM1176JZF_S CPU = "arm1176jzf-s" 73 | ) 74 | 75 | // Triple returns the triple for the CPU. 76 | func (c CPU) Triple() Triple { 77 | switch c { 78 | case CortexA53: 79 | return Triple{ 80 | Architecture: "aarch64", 81 | Vendor: "none", 82 | System: "linux", 83 | ABI: "gnu", 84 | } 85 | case CortexA7, ARM1176JZF_S: 86 | return Triple{ 87 | Architecture: "arm", 88 | Vendor: "none", 89 | System: "linux", 90 | ABI: "gnueabihf", 91 | } 92 | } 93 | panic("unreachable") 94 | } 95 | 96 | // String implements fmt.Stringer for this CPU. 97 | func (c CPU) String() string { 98 | return string(c) 99 | } 100 | 101 | // Options which control how the crosstool is built. 102 | type Options struct { 103 | // CC and CXX are the host-targeted compiler binaries used by the 104 | // crosstool. If unset, then "clang", and "clang++" are used. 105 | CC, CXX string 106 | } 107 | 108 | var crossfileTmpl = template.Must( 109 | template.New("arm-crossfile"). 110 | Funcs(map[string]interface{}{ 111 | "joinSpace": func(strs []string) string { 112 | return strings.Join(strs, " ") 113 | }, 114 | }). 115 | Parse(strings.Join([]string{ 116 | "[binaries]", 117 | "c = '{{ .CC }}'", 118 | "cpp = '{{ .CXX }}'", 119 | "c_ld = 'lld'", 120 | "cpp_ld = 'lld'", 121 | // We have to set pkgconfig and cmake explicitly here, otherwise 122 | // meson will not be able to find them. We just re-use the 123 | // system versions, since we don't need any special arch-specific 124 | // handing. 125 | "pkg-config = '{{ .PkgConfig }}'", 126 | "cmake = '{{ .CMake }}'", 127 | "", 128 | "[properties]", 129 | "sys_root = '{{ .Root }}'", 130 | "pkg_config_libdir = '{{ .Root }}/lib/pkgconfig'", 131 | "", 132 | "[cmake]", 133 | "CMAKE_C_COMPILER = '{{ .CC }}'", 134 | "CMAKE_C_FLAGS = '-mcpu={{ .CPU }} {{ .CFlags | joinSpace }}'", 135 | "CMAKE_CXX_COMPILER = '{{ .CC }}'", 136 | "CMAKE_CXX_FLAGS = '-mcpu={{ .CPU }} {{ .CXXFlags | joinSpace }}'", 137 | "CMAKE_EXE_LINKER_FLAGS = '-fuse-ld=lld'", 138 | "", 139 | "[built-in options]", 140 | "c_args = '-mcpu={{ .CPU }} {{ .CFlags | joinSpace }}'", 141 | "c_link_args = '{{ .LDFlags | joinSpace }}'", 142 | "cpp_args = '-mcpu={{ .CPU }} {{ .CXXFlags | joinSpace }}'", 143 | "cpp_link_args = '{{ .CXXLDFlags | joinSpace }}'", 144 | "", 145 | "[host_machine]", 146 | "system = '{{ .CPU.Triple.System }}'", 147 | "cpu_family = '{{ .CPU.Triple.Architecture }}'", 148 | "cpu = '{{ .CPU }}'", 149 | "endian = 'little'", 150 | }, "\n")), 151 | ) 152 | 153 | // Crosstool represents a cross-compiler environment. These can be created via 154 | // the `For` function for a specific CPU. 155 | type Crosstool struct { 156 | *workspace.Workspace 157 | libCXX *workspace.Workspace 158 | 159 | CPU CPU 160 | PkgConfig string 161 | CMake string 162 | CC string 163 | CXX string 164 | CFlags []string 165 | CXXFlags []string 166 | LDFlags []string 167 | CXXLDFlags []string 168 | } 169 | 170 | // WriteCrossFile writes a Meson cross file for this crosstool to the given 171 | // io.Writer. 172 | func (c *Crosstool) WriteCrossFile(w io.Writer) error { 173 | return crossfileTmpl.Execute(w, c) 174 | } 175 | 176 | // Cleanup cleans up this crosstool, removing any downloaded or built artifacts. 177 | func (c *Crosstool) Cleanup() error { 178 | lErr := c.libCXX.Cleanup() 179 | if err := c.Workspace.Cleanup(); err != nil { 180 | if lErr != nil { 181 | return fmt.Errorf("multiple cleanup errors: %v, %v", lErr, err) 182 | } 183 | return err 184 | } 185 | return lErr 186 | } 187 | 188 | func installLibCXX(cpu CPU, sysroot string, opts Options, into *workspace.Workspace) error { 189 | flags := []string{ 190 | "--target=" + cpu.Triple().String(), 191 | "-mcpu=" + cpu.String(), 192 | "-fuse-ld=lld", 193 | "--sysroot=" + sysroot, 194 | } 195 | 196 | if cpu == CortexA7 || cpu == ARM1176JZF_S { 197 | flags = append(flags, "-marm") 198 | } 199 | 200 | base := project.CMakeOptions{ 201 | CCompiler: opts.CC, 202 | CXXCompiler: opts.CXX, 203 | CFlags: flags, 204 | CXXFlags: flags, 205 | Extra: project.CMakeVariables{ 206 | "CMAKE_CROSSCOMPILING": "YES", 207 | "LLVM_TARGETS_TO_BUILD": "ARM", 208 | }, 209 | } 210 | 211 | src, err := workspace.New(workspace.NoCD) 212 | if err != nil { 213 | return err 214 | } 215 | defer src.Cleanup() 216 | 217 | if err := llvmSource.FetchTo(src.Root); err != nil { 218 | return err 219 | } 220 | 221 | libBuild, err := workspace.New(workspace.NoCD) 222 | if err != nil { 223 | return err 224 | } 225 | defer libBuild.Cleanup() 226 | 227 | lib := base 228 | lib.BuildDirectory = libBuild.Root 229 | lib.Extra["LIBCXX_CXX_ABI"] = "libcxxabi" 230 | lib.Extra["LIBCXX_ENABLE_SHARED"] = "NO" 231 | lib.Extra["LIBCXX_STANDALONE_BUILD"] = "YES" 232 | lib.Extra["LIBCXX_CXX_ABI_INCLUDE_PATHS"] = path.Join(src.Root, "libcxxabi/include") 233 | 234 | libProject, err := project.NewCMake(path.Join(src.Root, "libcxx"), lib) 235 | if err != nil { 236 | return err 237 | } 238 | 239 | if err := project.Install(libProject, into.Root); err != nil { 240 | return err 241 | } 242 | 243 | abiBuild, err := workspace.New(workspace.NoCD) 244 | if err != nil { 245 | return err 246 | } 247 | defer abiBuild.Cleanup() 248 | 249 | abi := base 250 | abi.BuildDirectory = abiBuild.Root 251 | abi.Extra["LIBCXXABI_ENABLE_SHARED"] = "NO" 252 | // Since we're building standalone, we need to manually set-up the libcxx 253 | // includes to the ones we just installed. 254 | abi.Extra["LIBCXXABI_LIBCXX_INCLUDES"] = path.Join(into.Root, "include/c++/v1") 255 | 256 | abiProject, err := project.NewCMake(path.Join(src.Root, "libcxxabi"), abi) 257 | if err != nil { 258 | return err 259 | } 260 | 261 | return project.Install(abiProject, into.Root) 262 | } 263 | 264 | func fixupRaspbian(sys *workspace.Workspace) error { 265 | // The rapbian root we're using has an absolute symlink to libm.so. This 266 | // causes issues when we use libm, because lld will try to link against 267 | // libm.a instead which won't work, because we're doing a static build. 268 | // So, we need to fix the symlink. 269 | 270 | // First delete the old symlink. 271 | if err := os.Remove(sys.Path("usr/lib/arm-linux-gnueabihf/libm.so")); err != nil { 272 | return err 273 | } 274 | 275 | // Then setup the correct symlink. 276 | return os.Symlink( 277 | sys.Path("lib/arm-linux-gnueabihf/libm.so.6"), 278 | sys.Path("usr/lib/arm-linux-gnueabihf/libm.so"), 279 | ) 280 | } 281 | 282 | func fetchSysroot(cpu CPU) (*workspace.Workspace, error) { 283 | sys, err := workspace.New(workspace.NoCD) 284 | if err != nil { 285 | return nil, err 286 | } 287 | 288 | var fixup func(*workspace.Workspace) error 289 | 290 | var root fetch.RemoteArchive 291 | switch cpu { 292 | case CortexA53: 293 | root = ubuntuAArch64Root 294 | case CortexA7, ARM1176JZF_S: 295 | root = raspbianRoot 296 | fixup = fixupRaspbian 297 | } 298 | 299 | if err := root.FetchTo(sys.Root); err != nil { 300 | sys.Cleanup() 301 | return nil, err 302 | } 303 | 304 | if fixup != nil { 305 | if err := fixup(sys); err != nil { 306 | return nil, err 307 | } 308 | } 309 | 310 | return sys, nil 311 | } 312 | 313 | // For creates a new crosstool for the given CPU, using the given options. 314 | func For(cpu CPU, opts Options) (*Crosstool, error) { 315 | if opts.CC == "" { 316 | opts.CC = "clang" 317 | } 318 | if opts.CXX == "" { 319 | opts.CXX = "clang++" 320 | } 321 | wantBins := []string{ 322 | "pkg-config", 323 | "cmake", 324 | opts.CC, 325 | opts.CXX, 326 | } 327 | 328 | binPaths := make(map[string]string) 329 | for _, bin := range wantBins { 330 | path, err := exec.LookPath(bin) 331 | if err != nil { 332 | return nil, err 333 | } 334 | binPaths[bin] = path 335 | } 336 | 337 | sys, err := fetchSysroot(cpu) 338 | if err != nil { 339 | return nil, err 340 | } 341 | 342 | libcxx, err := workspace.New(workspace.NoCD) 343 | if err != nil { 344 | sys.Cleanup() 345 | return nil, err 346 | } 347 | 348 | if err := installLibCXX(cpu, sys.Root, opts, libcxx); err != nil { 349 | sys.Cleanup() 350 | libcxx.Cleanup() 351 | return nil, err 352 | } 353 | 354 | commonFlags := []string{ 355 | "--sysroot=" + sys.Root, 356 | "--target=" + cpu.Triple().String(), 357 | } 358 | 359 | ct := &Crosstool{ 360 | Workspace: sys, 361 | libCXX: libcxx, 362 | CPU: cpu, 363 | PkgConfig: binPaths["pkg-config"], 364 | CMake: binPaths["cmake"], 365 | CC: binPaths[opts.CC], 366 | CXX: binPaths[opts.CXX], 367 | CFlags: commonFlags, 368 | CXXFlags: append(commonFlags, 369 | "-nostdinc++", 370 | "-I"+path.Join(libcxx.Root, "include/c++/v1"), 371 | ), 372 | LDFlags: append(commonFlags, 373 | "-fuse-ld=lld", 374 | "-lpthread", 375 | ), 376 | CXXLDFlags: append(commonFlags, 377 | "-fuse-ld=lld", 378 | "-nostdlib++", 379 | "-L"+path.Join(libcxx.Root, "lib"), 380 | // Use the -l:lib...a form to avoid accidentally linking the 381 | // libraries dynamically. 382 | "-l:libc++.a", 383 | "-l:libc++abi.a", 384 | "-lpthread", 385 | ), 386 | } 387 | 388 | if cpu == CortexA7 || cpu == ARM1176JZF_S { 389 | ct.CFlags = append(ct.CFlags, "-marm") 390 | ct.CXXFlags = append(ct.CXXFlags, "-marm") 391 | } 392 | 393 | return ct, nil 394 | } 395 | -------------------------------------------------------------------------------- /tools/meta/exec/exec.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | type Cmd struct { 10 | *exec.Cmd 11 | 12 | silent bool 13 | } 14 | 15 | func (c *Cmd) Run() error { 16 | if !c.silent { 17 | log.Printf("+ %s", c) 18 | } 19 | return c.Cmd.Run() 20 | } 21 | 22 | func Command(path string, args ...string) *Cmd { 23 | c := exec.Command(path, args...) 24 | c.Stdout = os.Stdout 25 | c.Stderr = os.Stderr 26 | return &Cmd{Cmd: c} 27 | } 28 | 29 | func Silent(path string, args ...string) *Cmd { 30 | c := exec.Command(path, args...) 31 | return &Cmd{Cmd: c, silent: true} 32 | } 33 | -------------------------------------------------------------------------------- /tools/meta/fetch/fetch.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | 15 | "meta/exec" 16 | "meta/fileutil" 17 | "meta/semver" 18 | "meta/workspace" 19 | ) 20 | 21 | // URL fetches the content at the given URL and stores it in the destination 22 | // file. It returns an error if any steps fail. 23 | func URL(url, dest string) error { 24 | f, err := os.Create(dest) 25 | if err != nil { 26 | return fmt.Errorf("could not open dest file %q: %w", dest, err) 27 | } 28 | defer f.Close() 29 | 30 | log.Printf("FETCH %q -> %q", url, dest) 31 | 32 | resp, err := http.Get(url) 33 | if err != nil { 34 | return fmt.Errorf("failed to fetch %q: %w", url, err) 35 | } 36 | defer resp.Body.Close() 37 | if resp.StatusCode != 200 { 38 | return fmt.Errorf("bad status for %q: %s", url, resp.Status) 39 | } 40 | if _, err := io.Copy(f, resp.Body); err != nil { 41 | return fmt.Errorf("failed to write file: %w", err) 42 | } 43 | return nil 44 | } 45 | 46 | // GitVersions clones the Git repo at the given URL, and returns all tag names 47 | // in the repo. Returns an error if it's not able to clone the Git repo, or 48 | // read the tags. 49 | func GitVersions(url string) ([]string, error) { 50 | ws, err := workspace.New() 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer ws.Cleanup() 55 | 56 | if err := exec.Silent("git", "init").Run(); err != nil { 57 | return nil, fmt.Errorf("failed to init: %w", err) 58 | } 59 | 60 | log.Printf("GIT FETCH %q", url) 61 | if err := exec.Silent("git", "fetch", "--tags", "--depth=1", url).Run(); err != nil { 62 | return nil, fmt.Errorf("failed to fetch: %w", err) 63 | } 64 | 65 | tagCmd := exec.Silent("git", "tag") 66 | stdout, err := tagCmd.StdoutPipe() 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to get git tag pipe: %w", err) 69 | } 70 | if err := tagCmd.Start(); err != nil { 71 | return nil, err 72 | } 73 | 74 | tagsRaw, err := ioutil.ReadAll(stdout) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | if err := tagCmd.Wait(); err != nil { 80 | return nil, err 81 | } 82 | 83 | return strings.Split(string(tagsRaw), "\n"), nil 84 | } 85 | 86 | // GitLatest fetches the git tags from the repository at the given URL, and 87 | // returns the latest tag following Semver versioning semantics. 88 | func GitLatest(url string) (semver.Version, error) { 89 | tags, err := GitVersions(url) 90 | if err != nil { 91 | return semver.Version{}, fmt.Errorf("failed to fetch git versions: %w", err) 92 | } 93 | 94 | var versions []semver.Version 95 | for _, tag := range tags { 96 | parsed, err := semver.Parse(tag) 97 | if err != nil { 98 | continue 99 | } 100 | versions = append(versions, parsed) 101 | } 102 | 103 | if len(versions) < 1 { 104 | return semver.Version{}, errors.New("found no (semver-compliant) git tags") 105 | } 106 | 107 | sort.Slice(versions, func(i, j int) bool { 108 | return semver.Less(versions[i], versions[j]) 109 | }) 110 | 111 | return versions[len(versions)-1], nil 112 | } 113 | 114 | type ArchiveFormat int 115 | 116 | const ( 117 | TarGz ArchiveFormat = iota 118 | TarXz 119 | ) 120 | 121 | type RemoteArchive struct { 122 | URL string 123 | SHA256 string 124 | Format ArchiveFormat 125 | ExtraOptions []string 126 | } 127 | 128 | func (r RemoteArchive) FetchTo(dest string) error { 129 | d, err := filepath.Abs(dest) 130 | if err != nil { 131 | return err 132 | } 133 | 134 | ws, err := workspace.New() 135 | if err != nil { 136 | return err 137 | } 138 | defer ws.Cleanup() 139 | 140 | if err := URL(r.URL, ws.Path("archive")); err != nil { 141 | return err 142 | } 143 | 144 | if err := fileutil.Verify(ws.Path("archive"), r.SHA256); err != nil { 145 | return fmt.Errorf("failed to verify: %w", err) 146 | } 147 | 148 | tar := func(decompressOpt string) *exec.Cmd { 149 | args := []string{ 150 | "-C", d, 151 | decompressOpt, 152 | "-xf", 153 | ws.Path("archive"), 154 | } 155 | args = append(args, r.ExtraOptions...) 156 | return exec.Command("tar", args...) 157 | } 158 | 159 | var decompress *exec.Cmd 160 | switch r.Format { 161 | case TarGz: 162 | decompress = tar("-z") 163 | case TarXz: 164 | decompress = tar("-J") 165 | default: 166 | return fmt.Errorf("unrecognized format: %+v", r.Format) 167 | } 168 | 169 | if err := decompress.Run(); err != nil { 170 | return fmt.Errorf("failed to unpack: %w", err) 171 | } 172 | 173 | return nil 174 | } 175 | -------------------------------------------------------------------------------- /tools/meta/fileutil/fileutil.go: -------------------------------------------------------------------------------- 1 | // Package fileutil provides utilities for working with files. 2 | package fileutil 3 | 4 | import ( 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | 12 | "meta/exec" 13 | ) 14 | 15 | // Copy the src file to the destination. 16 | func Copy(src, dest string) error { 17 | // There's probably a better way to do this, but we know that cp will 18 | // handle permission and mode bits correctly. So just use that. 19 | return exec.Command("cp", src, dest).Run() 20 | } 21 | 22 | // Verify the given file has the given sha256 hashsum. 23 | func Verify(file, want string) error { 24 | log.Printf("VERIFY %q (%s)", file, want) 25 | 26 | f, err := os.Open(file) 27 | if err != nil { 28 | return err 29 | } 30 | defer f.Close() 31 | 32 | h := sha256.New() 33 | if _, err := io.Copy(h, f); err != nil { 34 | return err 35 | } 36 | 37 | hs := hex.EncodeToString(h.Sum(nil)) 38 | if hs != want { 39 | return fmt.Errorf("hashes do not match got %q, but wanted %q", hs, want) 40 | } 41 | 42 | log.Printf("VERIFY OK %q ", file) 43 | return nil 44 | } 45 | 46 | // RemoveRPath removes the DT_RPATH entry from the given ELF file using 47 | // `patchelf` from the build system. 48 | func RemoveRPath(file string) error { 49 | return exec.Command("patchelf", "--remove-rpath", file).Run() 50 | } 51 | -------------------------------------------------------------------------------- /tools/meta/go.mod: -------------------------------------------------------------------------------- 1 | module meta 2 | 3 | go 1.22 4 | 5 | toolchain go1.24.3 6 | 7 | require github.com/urfave/cli/v3 v3.3.2 8 | -------------------------------------------------------------------------------- /tools/meta/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 6 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= 8 | github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /tools/meta/meta.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/urfave/cli/v3" 9 | 10 | "meta/commands/libmpdclient" 11 | "meta/commands/mpd" 12 | "meta/commands/release" 13 | "meta/commands/resolveversions" 14 | "meta/commands/testbuild" 15 | ) 16 | 17 | func main() { 18 | log.SetOutput(os.Stderr) 19 | app := &cli.Command{ 20 | Commands: []*cli.Command{ 21 | { 22 | Name: "install", 23 | Commands: []*cli.Command{ 24 | libmpdclient.Command, 25 | mpd.Command, 26 | }, 27 | }, 28 | resolveversions.Command, 29 | release.Command, 30 | testbuild.Command, 31 | }, 32 | } 33 | if err := app.Run(context.Background(), os.Args); err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tools/meta/project/project.go: -------------------------------------------------------------------------------- 1 | // Package project provides utilities for working with different build 2 | // systems used by different projects. Current Automake and Meson are 3 | // supported. 4 | package project 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "os" 10 | "path" 11 | "strings" 12 | 13 | "meta/exec" 14 | ) 15 | 16 | // Project is a helpful interface for structures that can be built and 17 | // installed. 18 | type Project interface { 19 | // Configure configures the project with the given install prefix. 20 | Configure(prefix string) error 21 | // Build builds the given target (but does not install). If target 22 | // is empty string, the default target for the project is built. 23 | Build(target string) error 24 | // Install installs the project to the prefix given in the Configure 25 | // stage. 26 | Install() error 27 | } 28 | 29 | // Install installs the given project to the given prefix. It executes the 30 | // needed steps to configure and install. 31 | func Install(p Project, prefix string) error { 32 | if err := p.Configure(prefix); err != nil { 33 | return err 34 | } 35 | return p.Install() 36 | } 37 | 38 | func cd(to string) func() { 39 | cwd, err := os.Getwd() 40 | if err != nil { 41 | log.Fatalf("failed to Getwd: %v", err) 42 | } 43 | if err := os.Chdir(to); err != nil { 44 | log.Fatalf("failed to chdir to %q: %v", to, err) 45 | } 46 | return func() { 47 | _ = os.Chdir(cwd) 48 | } 49 | } 50 | 51 | // MesonBuildType represents the various types of build that can be performed 52 | // with meson. These can be provided as MesonOptions when a meson project 53 | // is created. 54 | type MesonBuildType int 55 | 56 | const ( 57 | BuildPlain MesonBuildType = iota 58 | BuildDebug 59 | BuildDebugOptimized 60 | BuildRelease 61 | ) 62 | 63 | // String implements fmt.Stringer for MesonBuildType. 64 | func (m MesonBuildType) String() string { 65 | switch m { 66 | case BuildPlain: 67 | return "plain" 68 | case BuildDebug: 69 | return "debug" 70 | case BuildDebugOptimized: 71 | return "debugoptimized" 72 | case BuildRelease: 73 | return "release" 74 | } 75 | return "" 76 | } 77 | 78 | func (m MesonBuildType) flag() string { 79 | return "--buildtype=" + m.String() 80 | } 81 | 82 | type Env map[string]string 83 | 84 | type MesonOptions struct { 85 | // BuildType the build type (essentially optimization level) to perform. 86 | BuildType MesonBuildType 87 | // BuildDirectory is the directory where the build should take place. By 88 | // default, the directory "build" in the project root is used. 89 | BuildDirectory string 90 | // Extra provides additional flags that should be provided to meson as 91 | // part of the configure step. 92 | Extra []string 93 | // Environment contains additional environment variables that will be 94 | // supplied to meson during the configuration step. 95 | Environment Env 96 | } 97 | 98 | // Meson represents a meson project. 99 | type Meson struct { 100 | // Root is the Meson project root (where meson.build is). 101 | Root string 102 | 103 | opts MesonOptions 104 | } 105 | 106 | // Make sure Meson implements the Project interface. 107 | var _ Project = (*Meson)(nil) 108 | 109 | // Configure implements Project.Configure for Meson. 110 | func (m *Meson) Configure(dest string) error { 111 | cleanup := cd(m.Root) 112 | defer cleanup() 113 | cmd := exec.Command( 114 | "meson", 115 | ".", m.opts.BuildDirectory, 116 | m.opts.BuildType.flag(), 117 | ) 118 | env := os.Environ() 119 | for k, v := range m.opts.Environment { 120 | env = append(env, fmt.Sprintf("%s=%s", k, v)) 121 | } 122 | cmd.Env = env 123 | if dest != "" { 124 | cmd.Args = append(cmd.Args, "--prefix="+dest) 125 | } 126 | cmd.Args = append(cmd.Args, m.opts.Extra...) 127 | return cmd.Run() 128 | } 129 | 130 | // Build implements Project.Build for Meson. 131 | func (m *Meson) Build(target string) error { 132 | cleanup := cd(m.Root) 133 | defer cleanup() 134 | cmd := exec.Command("ninja", "-C", m.opts.BuildDirectory) 135 | if target != "" { 136 | cmd.Args = append(cmd.Args, target) 137 | } 138 | return cmd.Run() 139 | } 140 | 141 | // Install implements Project.Install for Meson. 142 | func (m *Meson) Install() error { 143 | cleanup := cd(m.Root) 144 | defer cleanup() 145 | return exec.Command("ninja", "-C", m.opts.BuildDirectory, "install").Run() 146 | } 147 | 148 | // NewMeson creates a new meson project rooted in the given directory. 149 | func NewMeson(dir string, opts ...MesonOptions) (*Meson, error) { 150 | var o MesonOptions 151 | if len(opts) > 0 { 152 | o = opts[0] 153 | } 154 | if o.BuildDirectory == "" { 155 | o.BuildDirectory = "build" 156 | } 157 | return &Meson{ 158 | Root: dir, 159 | opts: o, 160 | }, nil 161 | } 162 | 163 | // Automake represents a project that uses Automake. 164 | type Automake struct { 165 | Root string 166 | opts AutomakeOptions 167 | } 168 | 169 | // Make sure Automake implements the Project interface. 170 | var _ Project = (*Automake)(nil) 171 | 172 | // Configure implements Project.Configure for Automake. 173 | func (a *Automake) Configure(prefix string) error { 174 | cleanup := cd(a.Root) 175 | defer cleanup() 176 | cmd := exec.Command( 177 | "./configure", 178 | "--quiet", "--enable-silent-rules", "--disable-documentation", 179 | "--prefix="+prefix, 180 | ) 181 | 182 | if a.opts.CCompiler != "" { 183 | cmd.Env = append(cmd.Environ(), "CC="+a.opts.CCompiler) 184 | } 185 | 186 | if a.opts.CXXCompiler != "" { 187 | cmd.Env = append(cmd.Environ(), "CXX="+a.opts.CXXCompiler) 188 | } 189 | 190 | return cmd.Run() 191 | } 192 | 193 | // Build implements Project.Build for Automake. 194 | func (a *Automake) Build(target string) error { 195 | cleanup := cd(a.Root) 196 | defer cleanup() 197 | cmd := exec.Command("make", "-j", "16") 198 | if target != "" { 199 | cmd.Args = append(cmd.Args, target) 200 | } 201 | return cmd.Run() 202 | } 203 | 204 | // Install implements Project.Install for Automake. 205 | func (a *Automake) Install() error { 206 | cleanup := cd(a.Root) 207 | defer cleanup() 208 | return exec.Command("make", "-j", "16", "install").Run() 209 | } 210 | 211 | type AutomakeOptions struct { 212 | CCompiler, CXXCompiler string 213 | } 214 | 215 | // NewAutomake returns a new Automake project rooted at the given directory. 216 | func NewAutomake(dir string, opts ...AutomakeOptions) (*Automake, error) { 217 | var opt AutomakeOptions 218 | if len(opts) > 0 { 219 | opt = opts[0] 220 | } 221 | return &Automake{ 222 | Root: dir, 223 | opts: opt, 224 | }, nil 225 | } 226 | 227 | // CMakeVariables represents a collection of variables to be passed to 228 | // CMake (these are typically passed via -D=). 229 | type CMakeVariables map[string]string 230 | 231 | // CMakeOptions represents CMake options for a build. 232 | type CMakeOptions struct { 233 | // BuildDirectory is the directory where the build is run from. If unset 234 | // then a "build" directory within the project root is used. 235 | BuildDirectory string 236 | // CCompiler and CXXCompiler are the C and C++ compiler binaries 237 | // respectively. 238 | CCompiler, CXXCompiler string 239 | // CFlags and CXXFlags are extra C and C++ that will be passed to the 240 | // C and C++ compilers respectively. 241 | CFlags, CXXFlags []string 242 | // Extra contains additional CMake variables that will be passed to CMake. 243 | Extra CMakeVariables 244 | } 245 | 246 | // CMake represents a project that uses the CMake build system. 247 | type CMake struct { 248 | Root string 249 | 250 | opts CMakeOptions 251 | } 252 | 253 | // Make sure CMake implements the project interface. 254 | var _ Project = (*CMake)(nil) 255 | 256 | // Configure implements Project.Configure for CMake. 257 | func (c *CMake) Configure(prefix string) error { 258 | cleanup := cd(c.opts.BuildDirectory) 259 | defer cleanup() 260 | cmd := exec.Command("cmake", "-GNinja") 261 | if prefix != "" { 262 | cmd.Args = append(cmd.Args, "-DCMAKE_INSTALL_PREFIX="+prefix) 263 | } 264 | if c.opts.CCompiler != "" { 265 | cmd.Args = append(cmd.Args, "-DCMAKE_C_COMPILER="+c.opts.CCompiler) 266 | } 267 | if c.opts.CXXCompiler != "" { 268 | cmd.Args = append(cmd.Args, "-DCMAKE_CXX_COMPILER="+c.opts.CXXCompiler) 269 | } 270 | if len(c.opts.CFlags) > 0 { 271 | cmd.Args = append(cmd.Args, "-DCMAKE_C_FLAGS="+strings.Join(c.opts.CFlags, " ")) 272 | } 273 | if len(c.opts.CXXFlags) > 0 { 274 | cmd.Args = append(cmd.Args, "-DCMAKE_CXX_FLAGS="+strings.Join(c.opts.CXXFlags, " ")) 275 | } 276 | for varname, value := range c.opts.Extra { 277 | cmd.Args = append(cmd.Args, fmt.Sprintf("-D%s=%s", varname, value)) 278 | } 279 | cmd.Args = append(cmd.Args, c.Root) 280 | return cmd.Run() 281 | } 282 | 283 | // Build implements Project.Build for CMake. 284 | func (c *CMake) Build(target string) error { 285 | cleanup := cd(c.opts.BuildDirectory) 286 | defer cleanup() 287 | cmd := exec.Command("ninja") 288 | if target != "" { 289 | cmd.Args = append(cmd.Args, target) 290 | } 291 | return cmd.Run() 292 | } 293 | 294 | // Install implements Project.Install for CMake. 295 | func (c *CMake) Install() error { 296 | cleanup := cd(c.opts.BuildDirectory) 297 | defer cleanup() 298 | return exec.Command("ninja", "install").Run() 299 | } 300 | 301 | // NewCMake creates a new CMake object representing the CMake project at the 302 | // given directory `dir`. The optional `opts` argument can be used to control 303 | // the configuration of the project's build. 304 | func NewCMake(dir string, opts ...CMakeOptions) (*CMake, error) { 305 | cmake := CMake{Root: dir} 306 | if len(opts) > 0 { 307 | cmake.opts = opts[0] 308 | } 309 | if cmake.opts.BuildDirectory == "" { 310 | buildDir := path.Join(dir, "build") 311 | if err := os.Mkdir(buildDir, os.ModePerm); err != nil { 312 | return nil, err 313 | } 314 | cmake.opts.BuildDirectory = buildDir 315 | } 316 | return &cmake, nil 317 | } 318 | -------------------------------------------------------------------------------- /tools/meta/semver/semver.go: -------------------------------------------------------------------------------- 1 | // Package semver provides utilities for working with semver-like versions. 2 | package semver 3 | 4 | import ( 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | // Version is the type of "semver" versions. 11 | type Version struct { 12 | Major, Minor, Patch int 13 | } 14 | 15 | // String implements fmt.Stringer for Version. 16 | func (v Version) String() string { 17 | return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 18 | } 19 | 20 | // Version should be a stringer. 21 | var _ fmt.Stringer = (*Version)(nil) 22 | 23 | var versionRe = regexp.MustCompile(`v?(\d+)(\.(\d+))?(\.(\d+))?`) 24 | 25 | // Parse parses the given version string into a Semver version if possible. 26 | // It allows some components to be missing (interpreted as zeros), and it 27 | // allows a leading `v` to be present in the version string. 28 | func Parse(s string) (Version, error) { 29 | match := versionRe.FindStringSubmatch(s) 30 | if match == nil { 31 | return Version{}, fmt.Errorf("%q did not match %q", s, versionRe) 32 | } 33 | var nums []int 34 | for _, part := range []string{match[1], match[3], match[5]} { 35 | if part == "" { 36 | nums = append(nums, 0) 37 | continue 38 | } 39 | num, err := strconv.ParseInt(part, 10, 32) 40 | if err != nil { 41 | return Version{}, fmt.Errorf("failed to parse %q in %q as int: %v", s, part, err) 42 | } 43 | nums = append(nums, int(num)) 44 | } 45 | return Version{nums[0], nums[1], nums[2]}, nil 46 | } 47 | 48 | // Less returns true if version a is a lower version than version b. 49 | func Less(a, b Version) bool { 50 | switch { 51 | case a.Major != b.Major: 52 | return a.Major < b.Major 53 | case a.Minor != b.Minor: 54 | return a.Minor < b.Minor 55 | case a.Patch != b.Patch: 56 | return a.Patch < b.Patch 57 | } 58 | // If we got this far, they are equal. 59 | return false 60 | } 61 | -------------------------------------------------------------------------------- /tools/meta/versions/libmpdclientver/libmpdclientver.go: -------------------------------------------------------------------------------- 1 | // Package libmpdclientver provides a version type for libmpdclient, and a 2 | // resolver for looking up the latest version. 3 | package libmpdclientver 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | 9 | "meta/fetch" 10 | "meta/semver" 11 | ) 12 | 13 | // GitURL is the URL of the libmpdclient git project. 14 | const GitURL = "https://github.com/MusicPlayerDaemon/libmpdclient.git" 15 | 16 | // Version is the type of a libmpdclient version. 17 | type Version semver.Version 18 | 19 | func (v Version) String() string { 20 | return fmt.Sprintf("%d.%d", v.Major, v.Minor) 21 | } 22 | 23 | // ReleaseURL returns the release URL for this version of libmpdclient. 24 | func (v Version) ReleaseURL() string { 25 | return fmt.Sprintf("https://www.musicpd.org/download/libmpdclient/%d/libmpdclient-%s.tar.xz", v.Major, v) 26 | } 27 | 28 | // Parse parses a given version string into a version. 29 | func Parse(v string) (Version, error) { 30 | parsed, err := semver.Parse(v) 31 | if err != nil { 32 | return Version{}, err 33 | } 34 | return Version(parsed), nil 35 | } 36 | 37 | // Resolve either looks up, or parses the given version. 38 | func Resolve(v string) (Version, error) { 39 | if v == "latest" { 40 | log.Printf("version == latest, searching for latest version") 41 | latest, err := fetch.GitLatest(GitURL) 42 | if err != nil { 43 | return Version{}, err 44 | } 45 | return Version(latest), err 46 | } 47 | return Parse(v) 48 | } 49 | -------------------------------------------------------------------------------- /tools/meta/versions/mpdver/mpdver.go: -------------------------------------------------------------------------------- 1 | // Package mpdver provides version resolvers, and a version definition for 2 | // MPD. 3 | package mpdver 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | 9 | "meta/fetch" 10 | "meta/semver" 11 | ) 12 | 13 | // GitURL is the git repo URL for MPD 14 | const GitURL = "https://github.com/MusicPlayerDaemon/MPD.git" 15 | 16 | // Version is the type of an MPD version. It is based on semver with some 17 | // additional printing changes. 18 | type Version semver.Version 19 | 20 | func (v Version) String() string { 21 | if v.Patch == 0 { 22 | return fmt.Sprintf("%d.%d", v.Major, v.Minor) 23 | } 24 | return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 25 | } 26 | 27 | // ReleaseURL is the release URL for this version. 28 | func (v Version) ReleaseURL() string { 29 | return fmt.Sprintf("http://www.musicpd.org/download/mpd/%d.%d/mpd-%s.tar.xz", v.Major, v.Minor, v) 30 | } 31 | 32 | // Parse parses the given version. 33 | func Parse(s string) (Version, error) { 34 | parsed, err := semver.Parse(s) 35 | if err != nil { 36 | return Version{}, err 37 | } 38 | return Version(parsed), nil 39 | } 40 | 41 | // Resolve resolves the latest version or parses the given given version. 42 | func Resolve(s string) (Version, error) { 43 | if s == "latest" { 44 | log.Printf("version == latest, looking up latest version") 45 | latest, err := fetch.GitLatest(GitURL) 46 | if err != nil { 47 | return Version{}, err 48 | } 49 | return Version(latest), nil 50 | } 51 | 52 | return Parse(s) 53 | } 54 | -------------------------------------------------------------------------------- /tools/meta/workspace/workspace.go: -------------------------------------------------------------------------------- 1 | package workspace 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | // Workspace represents a directory controlled by the application, that can 12 | // be used for building software. 13 | type Workspace struct { 14 | // Root is the (absolute) path to the root of the workspace. 15 | Root string 16 | 17 | prevDir string 18 | cleaned bool 19 | } 20 | 21 | // Path returns the absolute path to the given path, in this workspace. 22 | func (w *Workspace) Path(rel ...string) string { 23 | paths := append([]string{w.Root}, rel...) 24 | return filepath.Join(paths...) 25 | } 26 | 27 | // Cleanup should be called when the workspace is no longer needed. It deletes 28 | // all files in the workspace, as well as the workspace root itself. 29 | func (w *Workspace) Cleanup() error { 30 | if err := os.Chdir(w.prevDir); err != nil { 31 | log.Printf("Unable to move to previous dir %q: %v", w.prevDir, err) 32 | } 33 | log.Printf("CD %q", w.prevDir) 34 | if w.cleaned { 35 | return errors.New("workspace already cleaned up") 36 | } 37 | w.cleaned = true 38 | return os.RemoveAll(w.Root) 39 | } 40 | 41 | type options struct { 42 | skipCD bool 43 | } 44 | 45 | type Option func(*options) 46 | 47 | func NoCD(o *options) { 48 | o.skipCD = true 49 | } 50 | 51 | // New creates and returns a new workspace. The working directory is moved to 52 | // new workspace when it is created, and moved back to the original 53 | // working directory when Workspace.Cleanup() is called. 54 | func New(opts ...Option) (*Workspace, error) { 55 | var wopts options 56 | for _, o := range opts { 57 | o(&wopts) 58 | } 59 | 60 | path, err := ioutil.TempDir("", "workspace-*") 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | path, err = filepath.Abs(path) 66 | if err != nil { 67 | os.RemoveAll(path) 68 | return nil, err 69 | } 70 | 71 | cwd, err := os.Getwd() 72 | if err != nil { 73 | os.RemoveAll(path) 74 | return nil, err 75 | } 76 | 77 | if !wopts.skipCD { 78 | if err := os.Chdir(path); err != nil { 79 | os.RemoveAll(path) 80 | return nil, err 81 | } 82 | log.Printf("CD %q", path) 83 | } 84 | 85 | return &Workspace{Root: path, prevDir: cwd}, nil 86 | } 87 | --------------------------------------------------------------------------------