├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── controller.yaml │ ├── release.yaml │ └── runner.yaml ├── .gitignore ├── .markdownlint.jsonc ├── .vscode ├── launch.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── controller ├── Dockerfile ├── cmd │ ├── main.go │ └── options │ │ ├── options.go │ │ └── parseCLArgs.go ├── errors.go ├── file_metadata.go ├── globals │ └── globals.go ├── go.mod ├── go.sum ├── httpserver │ └── httpserver.go ├── interfaces.go ├── jobhealth │ ├── interfaces.go │ ├── job_health.go │ ├── job_health_test.go │ ├── mock.go │ └── structs.go ├── library │ ├── cache.go │ ├── commanddecider │ │ ├── cmd_decider.go │ │ └── cmd_decider_test.go │ ├── file_discovery.go │ ├── file_discovery_test.go │ ├── interfaces.go │ ├── manager.go │ └── mediainfo │ │ ├── interfaces.go │ │ ├── mediainfo.go │ │ └── structs.go ├── mocks.go ├── run.go ├── run_test.go ├── runnercommunicator │ ├── queue.go │ ├── runner_http_api_v1.go │ └── util.go ├── settings │ ├── mock.go │ ├── settings.go │ └── settings_test.go ├── sqlite │ ├── database.go │ ├── file_cache.go │ ├── health_checker.go │ ├── health_checker_test.go │ ├── library_manager.go │ ├── migrations │ │ ├── 000001_init_schema.down.sql │ │ ├── 000001_init_schema.up.sql │ │ ├── 000002_pipeline_to_cmd_decider.down.sql │ │ └── 000002_pipeline_to_cmd_decider.up.sql │ ├── runner_comm.go │ └── user_interfacer.go ├── structs.go ├── userinterfacer │ ├── structs.go │ ├── util.go │ ├── web_http_v1.go │ └── webfiles │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── asset-manifest.json │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── manifest.json │ │ ├── mstile-150x150.png │ │ ├── robots.txt │ │ ├── safari-pinned-tab.svg │ │ └── static │ │ ├── css │ │ ├── 2.62793d63.chunk.css │ │ ├── 2.62793d63.chunk.css.map │ │ ├── 2.a49f93ba.chunk.css │ │ ├── 2.a49f93ba.chunk.css.map │ │ ├── main.386e5276.chunk.css │ │ ├── main.386e5276.chunk.css.map │ │ ├── main.d1f77bb4.chunk.css │ │ └── main.d1f77bb4.chunk.css.map │ │ ├── js │ │ ├── 2.87856085.chunk.js │ │ ├── 2.87856085.chunk.js.LICENSE.txt │ │ ├── 2.87856085.chunk.js.map │ │ ├── 2.b4ebe8d9.chunk.js │ │ ├── 2.b4ebe8d9.chunk.js.LICENSE.txt │ │ ├── 2.b4ebe8d9.chunk.js.map │ │ ├── 3.0271195d.chunk.js │ │ ├── 3.0271195d.chunk.js.map │ │ ├── 3.a09c83ab.chunk.js │ │ ├── 3.a09c83ab.chunk.js.map │ │ ├── main.5a15e6fa.chunk.js │ │ ├── main.5a15e6fa.chunk.js.map │ │ ├── main.633a313d.chunk.js │ │ ├── main.633a313d.chunk.js.map │ │ ├── runtime-main.055bee68.js │ │ ├── runtime-main.055bee68.js.map │ │ ├── runtime-main.d9007f64.js │ │ └── runtime-main.d9007f64.js.map │ │ └── media │ │ ├── Encodarr-Logo.4b0cc1bf.svg │ │ ├── Info-I.ffc9d3a2.svg │ │ ├── addLibraryIcon.dd5f1d29.svg │ │ └── terminalIcon.5147de0e.svg ├── util.go └── util_test.go ├── frontend ├── .gitignore ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ ├── mstile-150x150.png │ ├── robots.txt │ └── safari-pinned-tab.svg ├── resources │ ├── Encodarr-Logo.svg │ ├── Info-I.svg │ ├── addLibraryIcon.svg │ ├── headphones.svg │ ├── play_button.svg │ └── terminalIcon.svg ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── EncodarrLogo.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ ├── spacers.css │ └── tabs │ │ ├── AboutSection.css │ │ ├── AboutSection.tsx │ │ ├── AddLibraryIcon.tsx │ │ ├── HistoryTab.tsx │ │ ├── InfoIIcon.tsx │ │ ├── LibrariesTab.css │ │ ├── LibrariesTab.tsx │ │ ├── RunningTab.css │ │ ├── RunningTab.tsx │ │ ├── SettingsTab.css │ │ ├── SettingsTab.tsx │ │ └── shared │ │ ├── AudioImage.tsx │ │ ├── HeadphonesIcon.tsx │ │ ├── PlayButton.tsx │ │ ├── TerminalIcon.tsx │ │ ├── TerminalIconSvg.tsx │ │ └── VideoImage.tsx └── tsconfig.json ├── images └── Encodarr-Text-Logo.png └── runner ├── .vscode └── settings.json ├── Dockerfile ├── cmd └── EncodarrRunner │ └── main.go ├── cmdrunner ├── cmd_runner.go ├── cmd_runner_test.go ├── interfaces.go ├── mocks.go ├── structs.go ├── util.go └── util_test.go ├── errors.go ├── go.mod ├── go.sum ├── http ├── http.go ├── http_test.go ├── interfaces.go ├── mocks.go ├── structs.go ├── util.go └── util_test.go ├── interfaces.go ├── logging.go ├── mocks.go ├── options ├── options.go ├── options_test.go ├── parse_cl_args.go └── parse_cl_args_test.go ├── run.go ├── run_test.go └── structs.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser (if applicable) [e.g. chrome, safari] 31 | - Controller Version [e.g. 0.1.0] 32 | - Runner Version (If applicable) [e.g. 0.1.0] 33 | 34 | **Smartphone (please complete the following information):** 35 | 36 | - Device: [e.g. iPhone6] 37 | - OS: [e.g. iOS8.1] 38 | - Browser [e.g. stock browser, safari] 39 | - Version [e.g. 22] 40 | 41 | **Additional context** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: push 4 | 5 | jobs: 6 | create-release: 7 | name: Create Release 8 | runs-on: ubuntu-latest 9 | if: startsWith(github.ref, 'refs/tags/') 10 | 11 | steps: 12 | - name: Set PREREL environment variable 13 | env: 14 | TAG: ${{ github.ref }} 15 | run: echo "PREREL=$(if [[ $TAG =~ "alpha" ]] || [[ $TAG =~ "beta" ]] || [[ $TAG =~ "rc" ]]; then echo "true"; else echo "false"; fi;)" >> $GITHUB_ENV 16 | 17 | - name: Sanitize github.ref 18 | run: echo "TAG_USED=$(echo ${GITHUB_REF:10})" >> $GITHUB_ENV 19 | 20 | - name: Create Release with Assets 21 | id: release 22 | uses: softprops/action-gh-release@v1 23 | with: 24 | name: Version ${{ env.TAG_USED }} 25 | draft: false 26 | prerelease: ${{ env.PREREL }} 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Video files 2 | **/*.mp4 3 | **/*.mkv 4 | **/*.mka 5 | **/*.mks 6 | **/*.avi 7 | **/*.m4v 8 | 9 | # Created by https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 10 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,visualstudiocode 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .nox/ 56 | .coverage 57 | .coverage.* 58 | .cache 59 | nosetests.xml 60 | coverage.xml 61 | *.cover 62 | *.py,cover 63 | .hypothesis/ 64 | .pytest_cache/ 65 | 66 | # Translations 67 | *.mo 68 | *.pot 69 | 70 | # Django stuff: 71 | *.log 72 | local_settings.py 73 | db.sqlite3 74 | db.sqlite3-journal 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | target/ 88 | 89 | # Jupyter Notebook 90 | .ipynb_checkpoints 91 | 92 | # IPython 93 | profile_default/ 94 | ipython_config.py 95 | 96 | # pyenv 97 | .python-version 98 | 99 | # pipenv 100 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 101 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 102 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 103 | # install all needed dependencies. 104 | #Pipfile.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # Celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | 125 | # Spyder project settings 126 | .spyderproject 127 | .spyproject 128 | 129 | # Rope project settings 130 | .ropeproject 131 | 132 | # mkdocs documentation 133 | /site 134 | 135 | # mypy 136 | .mypy_cache/ 137 | .dmypy.json 138 | dmypy.json 139 | 140 | # Pyre type checker 141 | .pyre/ 142 | 143 | # pytype static type analyzer 144 | .pytype/ 145 | 146 | ### VisualStudioCode ### 147 | .vscode/* 148 | !.vscode/settings.json 149 | !.vscode/tasks.json 150 | !.vscode/launch.json 151 | !.vscode/extensions.json 152 | *.code-workspace 153 | 154 | ### VisualStudioCode Patch ### 155 | # Ignore all local history of files 156 | .history 157 | 158 | # End of https://www.toptal.com/developers/gitignore/api/python,visualstudiocode 159 | -------------------------------------------------------------------------------- /.markdownlint.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD033": false, 4 | "MD041": false 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "encodarr", 4 | "mediainfo", 5 | "ncodarr", 6 | "popen", 7 | "pymediainfo" 8 | ], 9 | "python.analysis.extraPaths": [ 10 | "runner" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | Thanks for considering to contribute to Encodarr! It means a lot. 4 | 5 | ## Starting off 6 | 7 | If you've noticed a bug or have a feature request, [file an issue](https://github.com/BrenekH/encodarr/issues/new/choose)! 8 | It's generally best if you get confirmation of your bug or approval for your feature request before starting to work on code. 9 | 10 | If you have a general question about Encodarr, you can post it to [GitHub Discussions](https://github.com/BrenekH/encodarr/discussions), the issue tracker is only for bugs and feature requests. 11 | 12 | ## Code Structure 13 | 14 | The repo is broken up into 3 main folders: `controller`, `runner`, and `frontend`. 15 | 16 | `controller` contains code relating to the Controller, `runner` is for the Runner, and `frontend` holds the React project for the Web UI that is embedded into the Controller. 17 | 18 | ## Controller 19 | 20 | The Controller can be built locally and ran using the standard Go build commands, `go build cmd/main.go` and `go run cmd/main.go`, from within the `controller` folder. 21 | Unit tests can be ran using `go test ./...`. 22 | 23 | ## Runner 24 | 25 | Like the Controller, the Runner can be built locally and ran using the standard Go build commands, `go build cmd/EncodarrRunner/main.go` and `go run cmd/EncodarrRunner/main.go`, from within the `runner` folder. 26 | Unit tests can be ran using `go test ./...`. 27 | 28 | ## Frontend 29 | 30 | The frontend code can be run in debug mode using `npm run start`. 31 | It is recommended to run a Controller using the default port on your local machine so that the development Web UI can make requests to it. 32 | If you want to point React at a different Controller, you can modify the `proxy` field in the `package.json` file, just make sure to change it back before committing anything or submitting changes. 33 | 34 | To "deploy" the frontend to the Controller, run `npm run build` and copy the generated `build` folder to `controller/userinterfacer/webfiles`. 35 | To perform both actions with one command on Linux systems, use `npm run build && cp -r build/. ../controller/userinterfacer/webfiles/` from within the `frontend` directory. 36 | 37 | ## Docker 38 | 39 | Both the Controller and the Runner have `Dockerfile`s in their folders that can be built into container images using `docker build .` from the appropriate directory. 40 | 41 | ## Continuous Integration and Continuous Deployment 42 | 43 | This repository uses GitHub Actions to continuously test commits and deploy them to Docker Hub and the GitHub Container Registry. 44 | 45 | Stand-alone builds as a result of action workflows can be found in the artifacts section of the workflow run's page. 46 | Artifacts are kept for 90 days, after which they are removed. 47 | 48 | When a Git tag is pushed to the repo, special actions are run that deploy the code as a release, both to the docker registries(Docker Hub and GitHub Container Registry) and the [GitHub Releases page](https://github.com/BrenekH/encodarr/releases). 49 | 50 | ## Code of Conduct 51 | 52 | This project holds all maintainers, contributors, and participants to the standards outlined by the Contributor Covenant, a copy of which can be found in [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). 53 | -------------------------------------------------------------------------------- /controller/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.20-buster AS builder 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | ARG LDFLAGS_VERSION=development 7 | 8 | WORKDIR /go/src/encodarr/controller 9 | 10 | COPY . . 11 | 12 | # Disable CGO so that we have a static binary, set the platform for multi-arch builds, and embed the Version into globals.Version. 13 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o encodarr -ldflags="-X 'github.com/BrenekH/encodarr/controller/globals.Version=${LDFLAGS_VERSION}'" cmd/main.go 14 | 15 | 16 | # Run stage 17 | FROM ubuntu:20.04 18 | 19 | ENV TZ=Etc/GMT \ 20 | ENCODARR_CONFIG_DIR="/config" 21 | 22 | WORKDIR /usr/src/app 23 | 24 | RUN chmod 777 /usr/src/app \ 25 | && apt-get update -qq \ 26 | && DEBIAN_FRONTEND="noninteractive" apt-get install -qq -y tzdata mediainfo 27 | 28 | COPY --from=builder /go/src/encodarr/controller/encodarr ./encodarr 29 | 30 | RUN chmod 777 ./encodarr 31 | 32 | EXPOSE 8123 33 | 34 | CMD ["./encodarr"] 35 | -------------------------------------------------------------------------------- /controller/cmd/options/options.go: -------------------------------------------------------------------------------- 1 | // Package options is a centralized location for all supported command-line/environment variable options for the Encodarr Controller 2 | package options 3 | 4 | import ( 5 | "fmt" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type optionConst struct { 11 | EnvVar string 12 | CmdLine string 13 | Description string 14 | Usage string 15 | } 16 | 17 | var portConst optionConst = optionConst{"ENCODARR_PORT", "port", "Sets the port of the HTTP server.", "--port "} 18 | var port string = "8123" 19 | 20 | var configDirConst optionConst = optionConst{"ENCODARR_CONFIG_DIR", "config-dir", "Sets the location that configuration files are saved to.", "--config-dir "} 21 | var configDir string = "" 22 | 23 | var inputsParsed bool = false 24 | 25 | func init() { 26 | cDir, err := os.UserConfigDir() 27 | if err != nil { 28 | log.Fatalln(err) 29 | } 30 | configDir = cDir + "/encodarr/controller/config" 31 | } 32 | 33 | // parseInputs parses the command line and environment variables into Golang variables 34 | func parseInputs() { 35 | if inputsParsed { 36 | return 37 | } 38 | 39 | // HTTP Server port 40 | stringVarFromEnv(&port, portConst.EnvVar) 41 | stringVar(&port, portConst.CmdLine, portConst.Description, portConst.Usage) 42 | 43 | // Config directory 44 | stringVarFromEnv(&configDir, configDirConst.EnvVar) 45 | stringVar(&configDir, configDirConst.CmdLine, configDirConst.Description, configDirConst.Usage) 46 | 47 | makeConfigDir() 48 | 49 | parseCL() 50 | 51 | inputsParsed = true 52 | } 53 | 54 | // stringVarFromEnv applies the string value found from environment variables to the passed variable 55 | // but only if the returned value is not an empty string 56 | func stringVarFromEnv(s *string, key string) { 57 | v := os.Getenv(key) 58 | if v != "" { 59 | *s = v 60 | } 61 | } 62 | 63 | // Port returns the parsed HTTP server port 64 | func Port() string { 65 | parseInputs() 66 | return port 67 | } 68 | 69 | // ConfigDir returns the passed config directory 70 | func ConfigDir() string { 71 | parseInputs() 72 | return configDir 73 | } 74 | 75 | // makeConfigDir creates the options.configDir 76 | func makeConfigDir() { 77 | err := os.MkdirAll(configDir, 0777) 78 | if err != nil { 79 | log.Fatalln(fmt.Sprintf("Failed to create config directory '%v' because of error: %v", configDir, err.Error())) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /controller/cmd/options/parseCLArgs.go: -------------------------------------------------------------------------------- 1 | // The purpose of this file to provide an API similar to the flag package for parsing command-line arguments 2 | // without impacting the testing package(see https://github.com/golang/go/issues/31859 and https://github.com/golang/go/issues/39093). 3 | 4 | package options 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/BrenekH/encodarr/controller/globals" 13 | ) 14 | 15 | // flagger defines a type agnostic interface to parse out flags. 16 | type flagger interface { 17 | Name() string 18 | Description() string 19 | Usage() string 20 | Parse(string) error 21 | } 22 | 23 | var flags []flagger 24 | 25 | // stringVar replaces flag.StringVar, but without the default value. 26 | // That functionality is provided by the rest of the options package. 27 | func stringVar(p *string, name, description, usage string) { 28 | flags = append(flags, stringFlag{ 29 | name: name, 30 | description: description, 31 | usage: usage, 32 | pointer: p, 33 | }) 34 | } 35 | 36 | // parseCL parses the command-line arguments into the registered options. 37 | // Replaces flag.Parse. 38 | func parseCL() { 39 | var args []string = os.Args[1:] 40 | 41 | for k, v := range args { 42 | if v == "--help" { 43 | helpStr := fmt.Sprintf("Encodarr Controller %v Help\n\n", globals.Version) 44 | 45 | for _, f := range flags { 46 | helpStr += fmt.Sprintf(" --%v - %v\n Usage: \"%v\"\n\n", 47 | f.Name(), 48 | f.Description(), 49 | f.Usage(), 50 | ) 51 | } 52 | 53 | fmt.Println(strings.TrimRight(helpStr, "\n")) 54 | os.Exit(0) 55 | } else if v == "--version" { 56 | fmt.Printf("Encodarr Controller %v %v/%v", globals.Version, runtime.GOOS, runtime.GOARCH) 57 | os.Exit(0) 58 | } 59 | 60 | for _, f := range flags { 61 | if strings.Replace(v, "--", "", 1) == f.Name() { 62 | if i := k + 1; i >= len(args) { 63 | fmt.Printf("Can not parse %v, EOL reached", v) 64 | } else { 65 | f.Parse(args[k+1]) 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | type stringFlag struct { 73 | name string 74 | description string 75 | usage string 76 | pointer *string 77 | } 78 | 79 | func (f stringFlag) Parse(s string) error { 80 | *f.pointer = s 81 | return nil 82 | } 83 | 84 | func (f stringFlag) Description() string { 85 | return f.description 86 | } 87 | 88 | func (f stringFlag) Name() string { 89 | return f.name 90 | } 91 | 92 | func (f stringFlag) Usage() string { 93 | return f.usage 94 | } 95 | -------------------------------------------------------------------------------- /controller/errors.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import "errors" 4 | 5 | // ErrEmptyQueue represents when the operation cannot be completed because the queue is empty 6 | var ErrEmptyQueue error = errors.New("queue is empty") 7 | 8 | // ErrClosed is used when a struct is closed but an operation was attempted anyway. 9 | var ErrClosed = errors.New("attempted operation on closed struct") 10 | -------------------------------------------------------------------------------- /controller/file_metadata.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | // FileMetadata contains information about a video file. 4 | type FileMetadata struct { 5 | General General `json:"general"` 6 | VideoTracks []VideoTrack `json:"video_tracks"` 7 | AudioTracks []AudioTrack `json:"audio_tracks"` 8 | SubtitleTracks []SubtitleTrack `json:"subtitle_tracks"` 9 | } 10 | 11 | // NOTE: Track type determined by "@type" for MediaInfo and "codec_type" for FFProbe 12 | 13 | // General contains the general information about a media file. 14 | type General struct { 15 | // It looks like any non-string field will have to be parsed 16 | Duration float32 `json:"duration"` 17 | } 18 | 19 | // VideoTrack contains information about a singular video stream in a media file. 20 | type VideoTrack struct { 21 | Index int `json:"index"` // "StreamOrder" (MI), "index" (FF) 22 | Codec string `json:"codec"` // Either "AVC", "HEVC", etc. 23 | // Bitrate int `json:"bitrate"` // "BitRate" (MI), "bit_rate" (FF) // Not implemented for now because I want bitrate per stream, not overall file. 24 | Width int `json:"width"` // "Width" (MI), "width" (FF) 25 | Height int `json:"height"` // "Height" (MI), "height" (FF) 26 | ColorPrimaries string `json:"color_primaries"` // "colour_primaries" (MI), "color_primaries" (FF) Will be different based on which MetadataReader is being used (FF gives "bt2020" while MI gives "BT.2020") 27 | } 28 | 29 | // AudioTrack contains information about a singular audio stream in a media file. 30 | type AudioTrack struct { 31 | Index int `json:"index"` // "StreamOrder" (MI), "index" (FF) 32 | Channels int `json:"channels"` // "Channels" (MI), "channels" (FF) 33 | } 34 | 35 | // SubtitleTrack contains information about a singular text stream in a media file. 36 | type SubtitleTrack struct { 37 | Index int `json:"index"` // "StreamOrder" (MI), "index" (FF) 38 | Language string `json:"language"` // "Language" (MI), "tags.language" 39 | } 40 | -------------------------------------------------------------------------------- /controller/globals/globals.go: -------------------------------------------------------------------------------- 1 | // Package globals is the location of read-only constants such as Version, which is set at build time for release binaries. 2 | package globals 3 | 4 | // Version is a read-only constant that specifies the software version. 5 | // Using ldflags, Version can be set at build time. If it is not set using ldflags, its value will be 'develop'. 6 | var Version string = "develop" 7 | -------------------------------------------------------------------------------- /controller/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrenekH/encodarr/controller 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/BrenekH/logange v0.7.1 7 | github.com/golang-migrate/migrate/v4 v4.15.2 8 | github.com/google/uuid v1.3.0 9 | modernc.org/sqlite v1.21.0 10 | ) 11 | 12 | require ( 13 | github.com/dustin/go-humanize v1.0.1 // indirect 14 | github.com/hashicorp/errwrap v1.1.0 // indirect 15 | github.com/hashicorp/go-multierror v1.1.1 // indirect 16 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 17 | github.com/mattn/go-isatty v0.0.17 // indirect 18 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 19 | go.uber.org/atomic v1.10.0 // indirect 20 | golang.org/x/mod v0.8.0 // indirect 21 | golang.org/x/sys v0.5.0 // indirect 22 | golang.org/x/tools v0.6.0 // indirect 23 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 24 | lukechampine.com/uint128 v1.2.0 // indirect 25 | modernc.org/cc/v3 v3.40.0 // indirect 26 | modernc.org/ccgo/v3 v3.16.13 // indirect 27 | modernc.org/libc v1.22.3 // indirect 28 | modernc.org/mathutil v1.5.0 // indirect 29 | modernc.org/memory v1.5.0 // indirect 30 | modernc.org/opt v0.1.3 // indirect 31 | modernc.org/strutil v1.1.3 // indirect 32 | modernc.org/token v1.1.0 // indirect 33 | ) 34 | -------------------------------------------------------------------------------- /controller/httpserver/httpserver.go: -------------------------------------------------------------------------------- 1 | package httpserver 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | "time" 10 | 11 | "github.com/BrenekH/encodarr/controller" 12 | "github.com/BrenekH/encodarr/controller/globals" 13 | ) 14 | 15 | // NewServer returns a new Server. 16 | func NewServer(logger controller.Logger, port string, webAPIVersions, runnerAPIVersions []string) Server { 17 | registerVersionHandlers(webAPIVersions, runnerAPIVersions) 18 | 19 | return Server{ 20 | port: port, 21 | logger: logger, 22 | } 23 | } 24 | 25 | // Server allows multiple locations to start the same HTTP server. 26 | type Server struct { 27 | serverAlreadyStarted bool 28 | port string 29 | logger controller.Logger 30 | 31 | srv *http.Server 32 | } 33 | 34 | // Start starts the http server which will exit when ctx is closed. Calling Start more than once results in a no-op. 35 | // The passed sync.WaitGroup should not have the Add method called before passing to Start. 36 | func (s *Server) Start(ctx *context.Context, wg *sync.WaitGroup) { 37 | if s.serverAlreadyStarted { 38 | return 39 | } 40 | s.serverAlreadyStarted = true 41 | 42 | httpServerExitDone := sync.WaitGroup{} 43 | 44 | httpServerExitDone.Add(1) 45 | s.srv = startListenAndServer(wg, s.logger, s.port) 46 | 47 | wg.Add(1) 48 | go func() { 49 | defer wg.Done() 50 | 51 | <-(*ctx).Done() 52 | 53 | shutdownCtx, ctxCancel := context.WithTimeout(context.Background(), time.Duration(10*time.Second)) 54 | defer ctxCancel() 55 | if err := s.srv.Shutdown(shutdownCtx); err != nil { 56 | s.logger.Critical("%v", err) // Failure/timeout shutting down the server gracefully 57 | } 58 | 59 | httpServerExitDone.Wait() 60 | }() 61 | } 62 | 63 | // Handle wraps net/http.Handle. 64 | func (s *Server) Handle(pattern string, handler http.Handler) { 65 | http.Handle(pattern, handler) 66 | } 67 | 68 | // HandleFunc wraps net/http.HandleFunc. 69 | func (s *Server) HandleFunc(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) { 70 | http.HandleFunc(pattern, handlerFunc) 71 | } 72 | 73 | func startListenAndServer(wg *sync.WaitGroup, logger controller.Logger, port string) *http.Server { 74 | srv := &http.Server{Addr: fmt.Sprintf(":%v", port)} 75 | 76 | go func() { 77 | defer wg.Done() 78 | 79 | // Always returns error. ErrServerClosed on graceful close 80 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { 81 | // Unexpected error. port in use? 82 | logger.Error("unexpected error: %v\n", err) 83 | } 84 | }() 85 | 86 | // Returning reference so caller can call Shutdown() 87 | return srv 88 | } 89 | 90 | func registerVersionHandlers(webVersions, runnerVersions []string) { 91 | // Controller version 92 | http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { 93 | w.Write([]byte(globals.Version)) 94 | }) 95 | 96 | // Web and Runner versions 97 | http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) { 98 | respStruct := struct { 99 | Web struct { 100 | Versions []string `json:"versions"` 101 | } `json:"web"` 102 | Runner struct { 103 | Versions []string `json:"versions"` 104 | } `json:"runner"` 105 | }{ 106 | Web: struct { 107 | Versions []string `json:"versions"` 108 | }{webVersions}, 109 | Runner: struct { 110 | Versions []string `json:"versions"` 111 | }{runnerVersions}, 112 | } 113 | 114 | b, _ := json.Marshal(respStruct) 115 | w.Write(b) 116 | }) 117 | 118 | // Web versions 119 | http.HandleFunc("/api/web", func(w http.ResponseWriter, r *http.Request) { 120 | respStruct := struct { 121 | Versions []string `json:"versions"` 122 | }{ 123 | Versions: webVersions, 124 | } 125 | 126 | b, _ := json.Marshal(respStruct) 127 | w.Write(b) 128 | }) 129 | 130 | // Runner versions 131 | http.HandleFunc("/api/runner", func(w http.ResponseWriter, r *http.Request) { 132 | respStruct := struct { 133 | Versions []string `json:"versions"` 134 | }{ 135 | Versions: webVersions, 136 | } 137 | 138 | b, _ := json.Marshal(respStruct) 139 | w.Write(b) 140 | }) 141 | } 142 | -------------------------------------------------------------------------------- /controller/jobhealth/interfaces.go: -------------------------------------------------------------------------------- 1 | package jobhealth 2 | 3 | import "time" 4 | 5 | type nowSincer interface { 6 | Now() time.Time 7 | Since(time.Time) time.Duration 8 | } 9 | -------------------------------------------------------------------------------- /controller/jobhealth/job_health.go: -------------------------------------------------------------------------------- 1 | package jobhealth 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/BrenekH/encodarr/controller" 8 | ) 9 | 10 | // NewChecker returns a new Checker. 11 | func NewChecker(ds controller.HealthCheckerDataStorer, ss controller.SettingsStorer, logger controller.Logger) Checker { 12 | return Checker{ 13 | ds: ds, 14 | ss: ss, 15 | 16 | lastCheckTime: time.Unix(0, 0), 17 | nowSincer: timeNowSince{}, 18 | 19 | logger: logger, 20 | } 21 | } 22 | 23 | // Checker implements the controller.HealthChecker interface. 24 | type Checker struct { 25 | ds controller.HealthCheckerDataStorer 26 | ss controller.SettingsStorer 27 | 28 | lastCheckTime time.Time 29 | nowSincer nowSincer 30 | 31 | logger controller.Logger 32 | } 33 | 34 | // Run loops through the provided slice of dispatched jobs and checks if any have 35 | // surpassed the allowed time between updates, if the Health Check timing interval has expired. 36 | func (c *Checker) Run() (uuidsToNull []controller.UUID) { 37 | if c.nowSincer.Since(c.lastCheckTime) >= time.Duration(c.ss.HealthCheckInterval()) { 38 | c.lastCheckTime = c.nowSincer.Now() 39 | 40 | djs := c.ds.DispatchedJobs() 41 | 42 | for _, v := range djs { 43 | if c.nowSincer.Since(v.LastUpdated) >= time.Duration(c.ss.HealthCheckTimeout()) { 44 | // Since DeleteJob may be blocked by an IO error of some sort attempt to delete 45 | // the job up to a hundred times (SQLiteDB.SetMaxOpenConns should've fixed this issue but just in case). 46 | jobDeleted := false 47 | for i := 0; i < 100; i++ { 48 | if err := c.ds.DeleteJob(v.UUID); err == nil { 49 | jobDeleted = true 50 | break 51 | } else { 52 | c.logger.Warn("%v", err) 53 | } 54 | time.Sleep(time.Microsecond * 2) 55 | } 56 | 57 | if jobDeleted { 58 | uuidsToNull = append(uuidsToNull, v.UUID) 59 | c.logger.Warn("Nullified job for %v because the %v runner was unresponsive", v.Job.Path, v.Runner) 60 | } 61 | } 62 | } 63 | } 64 | return 65 | } 66 | 67 | // Start just satisfies the controller.HealthChecker interface. 68 | // There is no implemented functionality. 69 | func (c *Checker) Start(ctx *context.Context) {} 70 | -------------------------------------------------------------------------------- /controller/jobhealth/mock.go: -------------------------------------------------------------------------------- 1 | package jobhealth 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/BrenekH/encodarr/controller" 8 | ) 9 | 10 | type mockNowSincer struct { 11 | nowResp time.Time 12 | sinceResp time.Duration 13 | sinceResp2 time.Duration 14 | 15 | nowCalled bool 16 | sinceCalled bool 17 | sinceTimesCalled int 18 | } 19 | 20 | func (m *mockNowSincer) Now() time.Time { 21 | m.nowCalled = true 22 | return m.nowResp 23 | } 24 | 25 | func (m *mockNowSincer) Since(time.Time) time.Duration { 26 | m.sinceTimesCalled++ 27 | if m.sinceCalled { 28 | return m.sinceResp2 29 | } 30 | m.sinceCalled = true 31 | return m.sinceResp 32 | } 33 | 34 | type mockDataStorer struct { 35 | dJobsCalled bool 36 | 37 | dJobs []controller.DispatchedJob 38 | 39 | deleteErrAmount int 40 | } 41 | 42 | func (m *mockDataStorer) DispatchedJobs() []controller.DispatchedJob { 43 | m.dJobsCalled = true 44 | return m.dJobs 45 | } 46 | 47 | func (m *mockDataStorer) DeleteJob(uuid controller.UUID) error { 48 | if m.deleteErrAmount == 0 { 49 | return nil 50 | } 51 | m.deleteErrAmount-- 52 | return fmt.Errorf("random error") 53 | } 54 | 55 | type mockSettingsStorer struct { 56 | healthCheckIntCalled bool 57 | 58 | healthCheckInt uint64 59 | healthCheckTimeout uint64 60 | } 61 | 62 | func (m *mockSettingsStorer) HealthCheckInterval() uint64 { 63 | m.healthCheckIntCalled = true 64 | return m.healthCheckInt 65 | } 66 | 67 | func (m *mockSettingsStorer) HealthCheckTimeout() uint64 { 68 | return m.healthCheckTimeout 69 | } 70 | 71 | func (m *mockSettingsStorer) Load() (err error) { return } 72 | func (m *mockSettingsStorer) Save() (err error) { return } 73 | func (m *mockSettingsStorer) Close() (err error) { return } 74 | func (m *mockSettingsStorer) SetHealthCheckInterval(uint64) {} 75 | func (m *mockSettingsStorer) SetHealthCheckTimeout(uint64) {} 76 | func (m *mockSettingsStorer) LogVerbosity() (s string) { return } 77 | func (m *mockSettingsStorer) SetLogVerbosity(string) {} 78 | 79 | type mockLogger struct{} 80 | 81 | func (m *mockLogger) Trace(s string, i ...interface{}) {} 82 | func (m *mockLogger) Debug(s string, i ...interface{}) {} 83 | func (m *mockLogger) Info(s string, i ...interface{}) {} 84 | func (m *mockLogger) Warn(s string, i ...interface{}) {} 85 | func (m *mockLogger) Error(s string, i ...interface{}) {} 86 | func (m *mockLogger) Critical(s string, i ...interface{}) {} 87 | -------------------------------------------------------------------------------- /controller/jobhealth/structs.go: -------------------------------------------------------------------------------- 1 | package jobhealth 2 | 3 | import "time" 4 | 5 | type timeNowSince struct{} 6 | 7 | func (t timeNowSince) Now() time.Time { 8 | return time.Now() 9 | } 10 | 11 | func (t timeNowSince) Since(tt time.Time) time.Duration { 12 | return time.Since(tt) 13 | } 14 | -------------------------------------------------------------------------------- /controller/library/cache.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "database/sql" 5 | "io/fs" 6 | "os" 7 | "time" 8 | 9 | "github.com/BrenekH/encodarr/controller" 10 | ) 11 | 12 | // NewCache returns a new Cache. 13 | func NewCache(m MetadataReader, f controller.FileCacheDataStorer, l controller.Logger) Cache { 14 | return Cache{ 15 | metadataReader: m, 16 | ds: f, 17 | logger: l, 18 | stater: osStater{}, 19 | } 20 | } 21 | 22 | // Cache sits in front of a MetadataReader and only calls it for 23 | // a Read call when the file has updated(based on the modtime) 24 | type Cache struct { 25 | metadataReader MetadataReader 26 | ds controller.FileCacheDataStorer 27 | logger controller.Logger 28 | stater stater 29 | } 30 | 31 | // Read uses the data storer and file.Stat to determine whether or not to call the MetadataReader or return from the cache. 32 | func (c *Cache) Read(path string) (controller.FileMetadata, error) { 33 | fileInfo, err := c.stater.Stat(path) 34 | if err != nil { 35 | c.logger.Error("Failed to stat %v, disabling caching for this call: %v", path, err) 36 | return c.metadataReader.Read(path) 37 | } 38 | 39 | storedModtime, err := c.ds.Modtime(path) 40 | if err != nil { 41 | if err != sql.ErrNoRows { 42 | c.logger.Error("Failed to read stored modtime for %v, disabling caching for this call: %v", path, err) 43 | return c.metadataReader.Read(path) 44 | } 45 | storedModtime = time.Unix(0, 0) 46 | } 47 | 48 | // We have to set the mod times to UTC because the db returns a different time zone format than os.Stat() 49 | if fileInfo.ModTime().UTC() == storedModtime.UTC() { 50 | storedMetadata, err := c.ds.Metadata(path) 51 | if err != nil { 52 | c.logger.Error("Failed to read stored metadata for %v, disabling caching for this call: %v", path, err) 53 | return c.metadataReader.Read(path) 54 | } 55 | 56 | return storedMetadata, nil 57 | } 58 | 59 | newMetadata, err := c.metadataReader.Read(path) 60 | if err == nil { 61 | err = c.ds.SaveMetadata(path, newMetadata) 62 | if err != nil { 63 | c.logger.Error("Failed to save new metadata for %v: %v", path, err) 64 | } 65 | 66 | err = c.ds.SaveModtime(path, fileInfo.ModTime()) 67 | if err != nil { 68 | c.logger.Error("Failed to save new modtime for %v: %v", path, err) 69 | } 70 | } 71 | 72 | return newMetadata, err 73 | } 74 | 75 | type osStater struct{} 76 | 77 | func (o osStater) Stat(name string) (fs.FileInfo, error) { 78 | return os.Stat(name) 79 | } 80 | -------------------------------------------------------------------------------- /controller/library/commanddecider/cmd_decider.go: -------------------------------------------------------------------------------- 1 | package commanddecider 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/BrenekH/encodarr/controller" 8 | ) 9 | 10 | // codecParams is a map which correlates the TargetVideoCodec settings to the actual parameter to pass to FFMpeg 11 | var codecParams map[string]string = map[string]string{"HEVC": "hevc", "AVC": "libx264", "VP9": "libvpx-vp9"} 12 | 13 | // New returns a new CmdDecider. 14 | func New(logger controller.Logger) CmdDecider { 15 | return CmdDecider{logger: logger} 16 | } 17 | 18 | // CmdDecider satisfies the library.CommandDecider interface. 19 | type CmdDecider struct { 20 | logger controller.Logger 21 | } 22 | 23 | // DefaultSettings returns the default settings string. 24 | func (c *CmdDecider) DefaultSettings() string { 25 | return `{"target_video_codec": "HEVC", "create_stereo_audio": true, "skip_hdr": true, "use_hardware": false, "hardware_codec": "", "hw_device": ""}` 26 | } 27 | 28 | // Decide uses the file metadata and settings to decide on a command to run, if any is required. 29 | func (c *CmdDecider) Decide(m controller.FileMetadata, sSettings string) ([]string, error) { 30 | settings := CmdDeciderSettings{} 31 | err := json.Unmarshal([]byte(sSettings), &settings) 32 | if err != nil { 33 | c.logger.Error(err.Error()) 34 | return []string{}, err 35 | } 36 | 37 | stereoAudioTrackExists := true 38 | if settings.CreateStereoAudio { 39 | stereoAudioTrackExists = false 40 | for _, v := range m.AudioTracks { 41 | if v.Channels == 2 { 42 | stereoAudioTrackExists = true 43 | } 44 | } 45 | } 46 | 47 | var alreadyTargetVideoCodec bool 48 | if len(m.VideoTracks) > 0 { 49 | alreadyTargetVideoCodec = m.VideoTracks[0].Codec == settings.TargetVideoCodec 50 | } else { 51 | // Just because there are no video tracks, doesn't mean that the audio can't be adjusted. 52 | // So tell the system that the video is already the target and move on. 53 | alreadyTargetVideoCodec = true 54 | } 55 | 56 | if stereoAudioTrackExists && alreadyTargetVideoCodec { 57 | return []string{}, fmt.Errorf("file already matches requirements") 58 | } 59 | 60 | var ffmpegCodecParam string 61 | if settings.UseHardware { 62 | ffmpegCodecParam = settings.HardwareCodec 63 | } else { 64 | var ok bool 65 | ffmpegCodecParam, ok = codecParams[settings.TargetVideoCodec] 66 | if !ok { 67 | return []string{}, fmt.Errorf("couldn't identify ffmpeg parameter for '%v' target codec", settings.TargetVideoCodec) 68 | } 69 | } 70 | 71 | cmd := genFFmpegCmd(!stereoAudioTrackExists, !alreadyTargetVideoCodec, ffmpegCodecParam, settings.UseHardware, settings.HWDevice) 72 | 73 | return cmd, nil 74 | } 75 | 76 | // CmdDeciderSettings defines the structure to unmarshal the settings string into. 77 | type CmdDeciderSettings struct { 78 | TargetVideoCodec string `json:"target_video_codec"` 79 | CreateStereoAudio bool `json:"create_stereo_audio"` 80 | SkipHDR bool `json:"skip_hdr"` 81 | UseHardware bool `bool:"use_hardware"` 82 | HardwareCodec string `json:"hardware_codec"` 83 | HWDevice string `json:"hw_device"` 84 | } 85 | 86 | // genFFmpegCmd creates the correct ffmpeg arguments for the input/output filenames and the job parameters. 87 | func genFFmpegCmd(stereo, encode bool, codec string, useHW bool, hwDevice string) []string { 88 | var s []string 89 | 90 | if stereo && encode { 91 | s = []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", codec, "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"} 92 | } else if stereo { 93 | s = []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "copy", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"} 94 | } else if encode { 95 | s = []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", codec} 96 | } 97 | 98 | if hwDevice != "" && useHW { 99 | s = append([]string{"-hwaccel_device", hwDevice}, s...) 100 | } 101 | 102 | return s 103 | } 104 | -------------------------------------------------------------------------------- /controller/library/commanddecider/cmd_decider_test.go: -------------------------------------------------------------------------------- 1 | package commanddecider 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | // TODO: Test CmdDecider.Decide 10 | 11 | func TestGenFFmpegCmd(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | params jobParameters 15 | expected []string 16 | }{ 17 | { 18 | name: "Encode to HEVC", 19 | params: jobParameters{Encode: true, Codec: "hevc", Stereo: false, UseHW: false, HWDevice: ""}, 20 | expected: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc"}, 21 | }, 22 | { 23 | name: "Add Stereo Audio Track", 24 | params: jobParameters{Stereo: true, Encode: false, Codec: "", UseHW: false, HWDevice: ""}, 25 | expected: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "copy", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"}, 26 | }, 27 | { 28 | name: "Encode to HEVC and Add Stereo Audio Track", 29 | params: jobParameters{Encode: true, Codec: "hevc", Stereo: true, UseHW: false, HWDevice: ""}, 30 | expected: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "hevc", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"}, 31 | }, 32 | { 33 | name: "Encode to HEVC using hardware", 34 | params: jobParameters{Encode: true, Codec: "hevc_vaapi", Stereo: false, UseHW: true, HWDevice: "/dev/dri/renderD128"}, 35 | expected: []string{"-hwaccel_device", "/dev/dri/renderD128", "-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc_vaapi"}, 36 | }, 37 | { 38 | name: "Don't add hwaccel_device if not using hardware encoding", 39 | params: jobParameters{Encode: true, Codec: "hevc_vaapi", Stereo: false, UseHW: false, HWDevice: "/dev/dri/renderD128"}, 40 | expected: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc_vaapi"}, 41 | }, 42 | { 43 | name: "All False Params", 44 | params: jobParameters{Encode: false, Codec: "", Stereo: false, UseHW: false, HWDevice: ""}, 45 | expected: nil, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | testname := fmt.Sprintf("%v", tt.name) 51 | 52 | t.Run(testname, func(t *testing.T) { 53 | ans := genFFmpegCmd(tt.params.Stereo, tt.params.Encode, tt.params.Codec, tt.params.UseHW, tt.params.HWDevice) 54 | 55 | if !reflect.DeepEqual(ans, tt.expected) { 56 | t.Errorf("got %v, expected %v", ans, tt.expected) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | type jobParameters struct { 63 | Stereo bool 64 | Encode bool 65 | Codec string 66 | UseHW bool 67 | HWDevice string 68 | } 69 | -------------------------------------------------------------------------------- /controller/library/file_discovery.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // GetVideoFilesFromDir returns a string slice of video files, found recursively from dirToSearch. 9 | func GetVideoFilesFromDir(dirToSearch string) ([]string, error) { 10 | allFiles, err := getFilesFromDir(dirToSearch) 11 | if err != nil { 12 | return nil, err 13 | } 14 | return filterNonVideoExts(allFiles), nil 15 | } 16 | 17 | // getFilesFromDir returns all files in a directory. 18 | func getFilesFromDir(dirToSearch string) ([]string, error) { 19 | cleanSlashedPath := filepath.ToSlash(filepath.Clean(dirToSearch)) 20 | files := make([]string, 0) 21 | 22 | filepath.Walk(cleanSlashedPath, func(path string, info os.FileInfo, err error) error { 23 | if err != nil { 24 | return nil 25 | } 26 | if !info.IsDir() { 27 | files = append(files, path) 28 | } 29 | return nil 30 | }) 31 | 32 | return files, nil 33 | } 34 | 35 | // filterNonVideoExts removes any filepath from the provided slice that doesn't end with a known video file extension. 36 | func filterNonVideoExts(toFilter []string) []string { 37 | // A named return value is not used here because it initializes a nil slice instead of an empty one 38 | filtered := make([]string, 0) 39 | 40 | for _, i := range toFilter { 41 | fileExt := filepath.Ext(i) 42 | if isVideoFileExt(fileExt) { 43 | filtered = append(filtered, i) 44 | } 45 | } 46 | return filtered 47 | } 48 | 49 | // isVideoFileExt returns a bool representing whether or not a file extension is a video file extension. 50 | func isVideoFileExt(a string) bool { 51 | validExts := []string{".m4v", ".mp4", ".mkv", ".avi", ".mov", ".webm", ".ogg", ".m4p", ".wmv", ".qt"} 52 | 53 | for _, b := range validExts { 54 | if b == a { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | -------------------------------------------------------------------------------- /controller/library/file_discovery_test.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestFilterNonVideoExts(t *testing.T) { 10 | var tests = []struct { 11 | a []string 12 | want []string 13 | }{ 14 | {[]string{"/input/name.txt"}, []string{}}, 15 | {[]string{"/input/many.mp4", "/input/not this one though.notmp4"}, []string{"/input/many.mp4"}}, 16 | } 17 | 18 | for _, tt := range tests { 19 | testname := fmt.Sprintf("%v", tt.a) 20 | t.Run(testname, func(t *testing.T) { 21 | ans := filterNonVideoExts(tt.a) 22 | if !reflect.DeepEqual(ans, tt.want) { 23 | t.Errorf("got %v, want %v", ans, tt.want) 24 | } 25 | }) 26 | } 27 | } 28 | 29 | func TestIsVideoFileExt(t *testing.T) { 30 | var tests = []struct { 31 | a string 32 | want bool 33 | }{ 34 | {".mkv", true}, 35 | {".mp4", true}, 36 | {".avi", true}, 37 | {".mka", false}, 38 | {".mks", false}, 39 | {".txt", false}, 40 | } 41 | 42 | for _, tt := range tests { 43 | testname := fmt.Sprintf("%v", tt.a) 44 | t.Run(testname, func(t *testing.T) { 45 | ans := isVideoFileExt(tt.a) 46 | if ans != tt.want { 47 | t.Errorf("got %v, want %v", ans, tt.want) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /controller/library/interfaces.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "io/fs" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | // The MetadataReader interface defines how a MetadataReader should behave. 10 | type MetadataReader interface { 11 | Read(path string) (controller.FileMetadata, error) 12 | } 13 | 14 | // The CommandDecider interface defines how a CommandDecider should behave. 15 | type CommandDecider interface { 16 | Decide(m controller.FileMetadata, cmdDeciderSettings string) (cmd []string, err error) 17 | DefaultSettings() string 18 | } 19 | 20 | // stater is an interface that allows for the mocking of os.Stat for testing. 21 | type stater interface { 22 | Stat(name string) (fs.FileInfo, error) 23 | } 24 | 25 | // videoFileser is an interface that allows for the mocking of GetVideoFilesFromDir for testing. 26 | type videoFileser interface { 27 | VideoFiles(dir string) ([]string, error) 28 | } 29 | 30 | type fileRemover interface { 31 | Remove(path string) error 32 | } 33 | 34 | type fileMover interface { 35 | Move(from string, to string) error 36 | } 37 | 38 | type fileStater interface { 39 | Stat(path string) (fs.FileInfo, error) 40 | } 41 | -------------------------------------------------------------------------------- /controller/library/mediainfo/interfaces.go: -------------------------------------------------------------------------------- 1 | package mediainfo 2 | 3 | // Commander is an interface that allows for mocking out the os/exec package for testing. 4 | type Commander interface { 5 | Command(name string, args ...string) Cmder 6 | } 7 | 8 | // Cmder is an interface for mocking out the exec.Cmd struct. 9 | type Cmder interface { 10 | Output() ([]byte, error) 11 | } 12 | -------------------------------------------------------------------------------- /controller/library/mediainfo/mediainfo.go: -------------------------------------------------------------------------------- 1 | package mediainfo 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | 7 | "github.com/BrenekH/encodarr/controller" 8 | ) 9 | 10 | // NewMetadataReader returns a new MetadataReader. 11 | func NewMetadataReader(logger controller.Logger) MetadataReader { 12 | return MetadataReader{ 13 | logger: logger, 14 | cmdr: execCommander{}, 15 | } 16 | } 17 | 18 | // MetadataReader satisfies the library.MetadataReader interface using MediaInfo. 19 | type MetadataReader struct { 20 | logger controller.Logger 21 | 22 | cmdr Commander 23 | } 24 | 25 | // Read uses MediaInfo to read the file metadata. 26 | func (m *MetadataReader) Read(path string) (controller.FileMetadata, error) { 27 | cmd := m.cmdr.Command("mediainfo", "--Output=JSON", "--Full", path) 28 | b, err := cmd.Output() 29 | if err != nil { 30 | return controller.FileMetadata{}, err 31 | } 32 | 33 | mi := mediaInfo{} 34 | err = json.Unmarshal(b, &mi) 35 | if err != nil { 36 | return controller.FileMetadata{}, err 37 | } 38 | 39 | var generalDuration float64 40 | vidTracks := make([]controller.VideoTrack, 0) 41 | audioTracks := make([]controller.AudioTrack, 0) 42 | subtitleTracks := make([]controller.SubtitleTrack, 0) 43 | 44 | for _, v := range mi.Media.Tracks { 45 | switch v.Type { 46 | case "General": 47 | if generalDuration, err = strconv.ParseFloat(v.Duration, 32); err != nil { 48 | m.logger.Debug("error while parsing general duration (%v) for %v: %v", path, v.Duration, err) 49 | return controller.FileMetadata{}, err 50 | } 51 | case "Video": 52 | vidTrack := controller.VideoTrack{} 53 | 54 | switch v.Format { 55 | case "AVC": 56 | vidTrack.Codec = "AVC" 57 | case "HEVC": 58 | vidTrack.Codec = "HEVC" 59 | case "VP9": 60 | vidTrack.Codec = "VP9" 61 | case "AV1": 62 | vidTrack.Codec = "AV1" 63 | default: 64 | vidTrack.Codec = "" 65 | } 66 | 67 | vidTrack.ColorPrimaries = v.ColourPrimaries 68 | 69 | if vidTrack.Index, err = strconv.Atoi(v.StreamOrder); err != nil { 70 | m.logger.Debug("error while converting vidTrack.Index (StreamOrder) for %v: %v", path, err) 71 | return controller.FileMetadata{}, err 72 | } 73 | 74 | if vidTrack.Width, err = strconv.Atoi(v.Width); err != nil { 75 | m.logger.Debug("error while converting vidTrack.Width for %v: %v", path, err) 76 | return controller.FileMetadata{}, err 77 | } 78 | 79 | if vidTrack.Height, err = strconv.Atoi(v.Height); err != nil { 80 | m.logger.Debug("error while converting vidTrack.Height for %v: %v", path, err) 81 | return controller.FileMetadata{}, err 82 | } 83 | 84 | vidTracks = append(vidTracks, vidTrack) 85 | case "Audio": 86 | audioTrack := controller.AudioTrack{} 87 | 88 | if audioTrack.Index, err = strconv.Atoi(v.StreamOrder); err != nil { 89 | m.logger.Debug("error while converting audioTrack.Index (StreamOrder) for %v: %v", path, err) 90 | return controller.FileMetadata{}, err 91 | } 92 | 93 | if audioTrack.Channels, err = strconv.Atoi(v.Channels); err != nil { 94 | m.logger.Debug("error while converting audioTrack.Channels for %v: %v", path, err) 95 | return controller.FileMetadata{}, err 96 | } 97 | 98 | audioTracks = append(audioTracks, audioTrack) 99 | case "Text": 100 | textTrack := controller.SubtitleTrack{} 101 | 102 | if textTrack.Index, err = strconv.Atoi(v.StreamOrder); err != nil { 103 | if textTrack.Index, err = strconv.Atoi(v.UniqueID); err != nil { 104 | m.logger.Warn("error while converting textTrack.Index (StreamOrder, UniqueID) for %v: %v", path, err) 105 | continue 106 | } 107 | } 108 | 109 | textTrack.Language = v.Language 110 | 111 | subtitleTracks = append(subtitleTracks, textTrack) 112 | case "Menu": 113 | default: 114 | } 115 | } 116 | 117 | return controller.FileMetadata{ 118 | General: controller.General{ 119 | Duration: float32(generalDuration), 120 | }, 121 | VideoTracks: vidTracks, 122 | AudioTracks: audioTracks, 123 | SubtitleTracks: subtitleTracks, 124 | }, nil 125 | } 126 | -------------------------------------------------------------------------------- /controller/mocks.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | type mockHealthChecker struct { 9 | runCalled bool 10 | startCalled bool 11 | } 12 | 13 | func (m *mockHealthChecker) Start(ctx *context.Context) { 14 | m.startCalled = true 15 | } 16 | 17 | func (m *mockHealthChecker) Run() (uuidsToNull []UUID) { 18 | m.runCalled = true 19 | return 20 | } 21 | 22 | type mockLibraryManager struct { 23 | importCalled bool 24 | libSettingsCalled bool 25 | popJobCalled bool 26 | updateLibSettingsCalled bool 27 | startCalled bool 28 | } 29 | 30 | func (m *mockLibraryManager) Start(ctx *context.Context, wg *sync.WaitGroup) { 31 | m.startCalled = true 32 | } 33 | 34 | func (m *mockLibraryManager) ImportCompletedJobs([]CompletedJob) { 35 | m.importCalled = true 36 | } 37 | 38 | func (m *mockLibraryManager) LibrarySettings() (ls []Library, err error) { 39 | m.libSettingsCalled = true 40 | return 41 | } 42 | 43 | func (m *mockLibraryManager) PopNewJob() (j Job, err error) { 44 | m.popJobCalled = true 45 | return 46 | } 47 | 48 | func (m *mockLibraryManager) UpdateLibrarySettings(map[int]Library) { 49 | m.updateLibSettingsCalled = true 50 | } 51 | 52 | type mockRunnerCommunicator struct { 53 | completedJobsCalled bool 54 | newJobCalled bool 55 | needNewJobCalled bool 56 | nullUUIDsCalled bool 57 | waitingRunnersCalled bool 58 | startCalled bool 59 | } 60 | 61 | func (m *mockRunnerCommunicator) Start(ctx *context.Context, wg *sync.WaitGroup) { 62 | m.startCalled = true 63 | } 64 | 65 | func (m *mockRunnerCommunicator) CompletedJobs() (j []CompletedJob) { 66 | m.completedJobsCalled = true 67 | return 68 | } 69 | 70 | func (m *mockRunnerCommunicator) NewJob(Job) { 71 | m.newJobCalled = true 72 | } 73 | 74 | func (m *mockRunnerCommunicator) NeedNewJob() bool { 75 | m.needNewJobCalled = true 76 | return true 77 | } 78 | 79 | func (m *mockRunnerCommunicator) NullifyUUIDs([]UUID) { 80 | m.nullUUIDsCalled = true 81 | } 82 | 83 | func (m *mockRunnerCommunicator) WaitingRunners() (runnerNames []string) { 84 | m.waitingRunnersCalled = true 85 | runnerNames = append(runnerNames, "TestRunner") 86 | return 87 | } 88 | 89 | type mockUserInterfacer struct { 90 | newLibSettingsCalled bool 91 | setLibSettingsCalled bool 92 | setWaitingRunnersCalled bool 93 | startCalled bool 94 | } 95 | 96 | func (m *mockUserInterfacer) Start(ctx *context.Context, wg *sync.WaitGroup) { 97 | m.startCalled = true 98 | } 99 | 100 | func (m *mockUserInterfacer) NewLibrarySettings() (ls map[int]Library) { 101 | m.newLibSettingsCalled = true 102 | return 103 | } 104 | 105 | func (m *mockUserInterfacer) SetLibrarySettings([]Library) { 106 | m.setLibSettingsCalled = true 107 | } 108 | 109 | func (m *mockUserInterfacer) SetWaitingRunners(runnerNames []string) { 110 | m.setWaitingRunnersCalled = true 111 | } 112 | 113 | type mockLogger struct{} 114 | 115 | func (m *mockLogger) Trace(s string, i ...interface{}) {} 116 | func (m *mockLogger) Debug(s string, i ...interface{}) {} 117 | func (m *mockLogger) Info(s string, i ...interface{}) {} 118 | func (m *mockLogger) Warn(s string, i ...interface{}) {} 119 | func (m *mockLogger) Error(s string, i ...interface{}) {} 120 | func (m *mockLogger) Critical(s string, i ...interface{}) {} 121 | -------------------------------------------------------------------------------- /controller/run.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // Run is the "top-level" function for running the Encodarr Controller. It calls all of the injected 10 | // dependencies in order to operate. 11 | func Run(ctx *context.Context, logger Logger, hc HealthChecker, lm LibraryManager, rc RunnerCommunicator, ui UserInterfacer, setLogLvl func(), testMode bool) { 12 | wg := sync.WaitGroup{} 13 | hc.Start(ctx) 14 | lm.Start(ctx, &wg) 15 | rc.Start(ctx, &wg) 16 | ui.Start(ctx, &wg) 17 | looped := false 18 | 19 | loopsPerSec := 20 20 | ticker := time.NewTicker(time.Second / time.Duration(loopsPerSec)) 21 | 22 | for range ticker.C { 23 | // A while loop will skip if its condition is false even on the first run. 24 | // Using the looped var allows a do-while run for testing. 25 | if testMode && looped { 26 | break 27 | } 28 | if testMode { 29 | looped = true 30 | } 31 | 32 | if IsContextFinished(ctx) { 33 | break 34 | } 35 | 36 | // Run health check and null any unresponsive Runners 37 | uuidsToNull := hc.Run() 38 | rc.NullifyUUIDs(uuidsToNull) 39 | 40 | // Update the UserInterfacer library settings cache 41 | if ls, err := lm.LibrarySettings(); err == nil { 42 | ui.SetLibrarySettings(ls) 43 | } 44 | 45 | // Apply user changes to library settings 46 | lsUserChanges := ui.NewLibrarySettings() 47 | lm.UpdateLibrarySettings(lsUserChanges) 48 | 49 | // Update waiting runners to be shown to the user 50 | wr := rc.WaitingRunners() 51 | ui.SetWaitingRunners(wr) 52 | 53 | // Send new job to the RunnerCommunicator if there is a waiting Runner 54 | if rc.NeedNewJob() { 55 | if nj, err := lm.PopNewJob(); err == nil { 56 | rc.NewJob(nj) 57 | } 58 | } 59 | 60 | // Import completed jobs 61 | cj := rc.CompletedJobs() 62 | lm.ImportCompletedJobs(cj) 63 | 64 | // Apply the log level to the actual handler 65 | setLogLvl() 66 | } 67 | 68 | // Wait for goroutines to shut down 69 | wg.Wait() 70 | } 71 | -------------------------------------------------------------------------------- /controller/run_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestRunFuncsCalled(t *testing.T) { 9 | ctx := context.Background() 10 | 11 | mLogger := mockLogger{} 12 | mHealthChecker := mockHealthChecker{} 13 | mLibraryManager := mockLibraryManager{} 14 | mRunnerCommunicator := mockRunnerCommunicator{} 15 | mUserInterfacer := mockUserInterfacer{} 16 | 17 | Run(&ctx, &mLogger, &mHealthChecker, &mLibraryManager, &mRunnerCommunicator, &mUserInterfacer, func() {}, true) 18 | 19 | // Check that HealthChecker methods were run 20 | if !mHealthChecker.startCalled { 21 | t.Errorf("HealthChecker.Start() wasn't called") 22 | } 23 | if !mHealthChecker.runCalled { 24 | t.Errorf("HealthChecker.Run() wasn't called") 25 | } 26 | 27 | // Check that LibraryManager methods were run 28 | if !mLibraryManager.startCalled { 29 | t.Errorf("LibraryManager.Start() wasn't called") 30 | } 31 | if !mLibraryManager.importCalled { 32 | t.Errorf("LibraryManager.ImportCompletedJobs() wasn't called") 33 | } 34 | if !mLibraryManager.libSettingsCalled { 35 | t.Errorf("LibraryManager.LibrarySettings() wasn't called") 36 | } 37 | if !mLibraryManager.popJobCalled { 38 | t.Errorf("LibraryManager.PopNewJob() wasn't called") 39 | } 40 | if !mLibraryManager.updateLibSettingsCalled { 41 | t.Errorf("LibraryManager.UpdateLibrarySettings wasn't called") 42 | } 43 | 44 | // Check that RunnerCommunicator methods were run 45 | if !mRunnerCommunicator.startCalled { 46 | t.Errorf("RunnerCommunicator.Start() wasn't called") 47 | } 48 | if !mRunnerCommunicator.completedJobsCalled { 49 | t.Errorf("RunnerCommunicator.CompletedJobs() wasn't called") 50 | } 51 | if !mRunnerCommunicator.newJobCalled { 52 | t.Errorf("RunnerCommunicator.NewJob() wasn't called") 53 | } 54 | if !mRunnerCommunicator.needNewJobCalled { 55 | t.Errorf("RunnerCommunicator.NeedNewJob() wasn't called") 56 | } 57 | if !mRunnerCommunicator.nullUUIDsCalled { 58 | t.Errorf("RunnerCommunicator.NullifyUUIDs() wasn't called") 59 | } 60 | if !mRunnerCommunicator.waitingRunnersCalled { 61 | t.Errorf("RunnerCommunicator.WaitingRunners() wasn't called") 62 | } 63 | 64 | // Check that UserInterfacer methods were run 65 | if !mUserInterfacer.startCalled { 66 | t.Errorf("UserInterfacer.Start() wasn't called") 67 | } 68 | if !mUserInterfacer.newLibSettingsCalled { 69 | t.Errorf("UserInterfacer.NewLibrarySettings() wasn't called") 70 | } 71 | if !mUserInterfacer.setLibSettingsCalled { 72 | t.Errorf("UserInterfacer.SetLibrarySettings() wasn't called") 73 | } 74 | if !mUserInterfacer.setWaitingRunnersCalled { 75 | t.Errorf("UserInterfacer.SetWaitingRunners() wasn't called") 76 | } 77 | } 78 | 79 | // Test to write 80 | // - rc.NullifyUUIDs() is called with the return value of hc.Run() 81 | // - ui.SetLibrarySettings() is called with the return value of lm.LibrarySettings() 82 | // - lm.UpdateLibrarySettings() is called with the return value of ui.NewLibrarySettings() 83 | // - ui.SetLibraryQueues() is called with the return value of lm.LibraryQueues() 84 | // - ui.SetWaitingRunners() is called with the return value of rc.WaitingRunners() 85 | // - rc.NewJob() is called with the return value of lm.PopNewJob() only when rc.NeedNewJob() returns true 86 | // - lm.ImportCompletedJobs() is called with the return value of rc.CompletedJobs() 87 | -------------------------------------------------------------------------------- /controller/runnercommunicator/queue.go: -------------------------------------------------------------------------------- 1 | package runnercommunicator 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | type waitingRunner struct { 10 | Name string 11 | CallbackChan chan controller.Job 12 | UUID string 13 | } 14 | 15 | func newQueue() queue { 16 | return queue{ 17 | items: make([]waitingRunner, 0), 18 | } 19 | } 20 | 21 | // queue represents a singular queue belonging to one library. 22 | type queue struct { 23 | sync.Mutex 24 | items []waitingRunner 25 | } 26 | 27 | // Push appends an item to the end of a LibraryQueue. 28 | func (q *queue) Push(item waitingRunner) { 29 | q.Lock() 30 | defer q.Unlock() 31 | q.items = append(q.items, item) 32 | } 33 | 34 | // Pop removes and returns the first item of a LibraryQueue. 35 | func (q *queue) Pop() (waitingRunner, error) { 36 | q.Lock() 37 | defer q.Unlock() 38 | if len(q.items) == 0 { 39 | return waitingRunner{}, controller.ErrEmptyQueue 40 | } 41 | item := q.items[0] 42 | q.items[0] = waitingRunner{} // Hopefully this garbage collects properly 43 | q.items = q.items[1:] 44 | return item, nil 45 | } 46 | 47 | // Remove deletes the first item that has the uuid provided. 48 | func (q *queue) Remove(uuid string) { 49 | q.Lock() 50 | defer q.Unlock() 51 | for index, v := range q.items { 52 | if v.UUID == uuid { 53 | q.items = append(q.items[:index], q.items[index+1:]...) 54 | return 55 | } 56 | } 57 | } 58 | 59 | // Dequeue returns a copy of the underlying slice in the Queue. 60 | func (q *queue) Dequeue() []waitingRunner { 61 | q.Lock() 62 | defer q.Unlock() 63 | return append(make([]waitingRunner, 0, len(q.items)), q.items...) 64 | } 65 | 66 | // Empty returns a boolean representing whether or not the queue is empty 67 | func (q *queue) Empty() bool { 68 | q.Lock() 69 | defer q.Unlock() 70 | return len(q.items) == 0 71 | } 72 | -------------------------------------------------------------------------------- /controller/runnercommunicator/util.go: -------------------------------------------------------------------------------- 1 | package runnercommunicator 2 | 3 | func inferMIMETypeFromExt(ext string) string { 4 | switch ext { 5 | case "mp4": 6 | return "video/mp4" 7 | case "m4v": 8 | return "video/m4v" 9 | case "avi": 10 | return "video/x-msvideo" 11 | case "mov", "qt": 12 | return "video/quicktime" 13 | case "wmv": 14 | return "video/x-ms-wmv" 15 | case "mkv": 16 | return "video/x-matroska" 17 | case "ogg": 18 | return "application/ogg" 19 | case "webm": 20 | return "video/webm" 21 | case "m4p": 22 | return "application/octet-stream" 23 | default: 24 | return "application/octet-stream" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /controller/settings/mock.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | ) 7 | 8 | type mockReadWriteSeekCloser struct { 9 | readCalled bool 10 | writeCalled bool 11 | seekCalled bool 12 | closeCalled bool 13 | 14 | bR *bytes.Reader 15 | } 16 | 17 | func (m *mockReadWriteSeekCloser) Read(p []byte) (int, error) { 18 | if !m.readCalled { 19 | m.bR = bytes.NewReader([]byte("{}")) 20 | } 21 | m.readCalled = true 22 | return m.bR.Read(p) 23 | } 24 | 25 | func (m *mockReadWriteSeekCloser) Write(p []byte) (int, error) { 26 | m.writeCalled = true 27 | return io.Discard.Write(p) 28 | } 29 | 30 | func (m *mockReadWriteSeekCloser) Seek(offset int64, whence int) (int64, error) { 31 | m.seekCalled = true 32 | if !m.readCalled { 33 | return 0, nil 34 | } 35 | return m.bR.Seek(offset, whence) 36 | } 37 | 38 | func (m *mockReadWriteSeekCloser) Truncate(size int64) error { 39 | m.readCalled = false // Effectively resets the byteReader 40 | return nil 41 | } 42 | 43 | func (m *mockReadWriteSeekCloser) Close() error { 44 | m.closeCalled = true 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /controller/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "time" 9 | 10 | "github.com/BrenekH/encodarr/controller" 11 | ) 12 | 13 | type readWriteSeekCloser interface { 14 | io.ReadWriteCloser 15 | io.Seeker 16 | Truncate(size int64) error 17 | } 18 | 19 | // Store satisfies the controller.SettingsStorer interface using a JSON file. 20 | type Store struct { 21 | healthCheckInterval uint64 22 | healthCheckTimeout uint64 23 | logVerbosity string 24 | 25 | file readWriteSeekCloser 26 | closed bool 27 | } 28 | 29 | // settings a marshaling struct used for converting between a slice of bytes and the parsed values 30 | // in SettingsStore. 31 | type settings struct { 32 | HealthCheckInterval uint64 33 | HealthCheckTimeout uint64 34 | LogVerbosity string 35 | } 36 | 37 | // Load loads the settings from the file. 38 | func (s *Store) Load() error { 39 | if s.closed { 40 | return controller.ErrClosed 41 | } 42 | 43 | s.file.Seek(0, io.SeekStart) 44 | b, err := io.ReadAll(s.file) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | se := settings{} 50 | err = json.Unmarshal(b, &se) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | s.healthCheckInterval = se.HealthCheckInterval 56 | s.healthCheckTimeout = se.HealthCheckTimeout 57 | s.logVerbosity = se.LogVerbosity 58 | 59 | return nil 60 | } 61 | 62 | // Save saves the settings to the file. 63 | func (s *Store) Save() error { 64 | if s.closed { 65 | return controller.ErrClosed 66 | } 67 | 68 | // Erase current contents 69 | s.file.Truncate(0) 70 | 71 | // Move file pointer to start 72 | s.file.Seek(0, io.SeekStart) 73 | 74 | se := settings{ 75 | HealthCheckInterval: s.healthCheckInterval, 76 | HealthCheckTimeout: s.healthCheckTimeout, 77 | LogVerbosity: s.logVerbosity, 78 | } 79 | b, err := json.MarshalIndent(se, "", "\t") 80 | if err != nil { 81 | return err 82 | } 83 | 84 | io.Copy(s.file, bytes.NewReader(b)) 85 | 86 | return nil 87 | } 88 | 89 | // Close closes the underlying file 90 | func (s *Store) Close() error { 91 | s.closed = true 92 | return s.file.Close() 93 | } 94 | 95 | // SettingsStore Getters and Setters 96 | 97 | // HealthCheckInterval returns the currently set health check interval. 98 | func (s *Store) HealthCheckInterval() uint64 { 99 | return s.healthCheckInterval 100 | } 101 | 102 | // SetHealthCheckInterval sets the health check interval to the provided value. 103 | func (s *Store) SetHealthCheckInterval(n uint64) { 104 | s.healthCheckInterval = n 105 | } 106 | 107 | // HealthCheckTimeout returns the currently set health check timeout value. 108 | func (s *Store) HealthCheckTimeout() uint64 { 109 | return s.healthCheckTimeout 110 | } 111 | 112 | // SetHealthCheckTimeout sets the health check timeout to the provided value. 113 | func (s *Store) SetHealthCheckTimeout(n uint64) { 114 | s.healthCheckTimeout = n 115 | } 116 | 117 | // LogVerbosity returns the currently set log verbosity value. 118 | func (s *Store) LogVerbosity() string { 119 | return s.logVerbosity 120 | } 121 | 122 | // SetLogVerbosity sets the log verbosity to the provided value. 123 | func (s *Store) SetLogVerbosity(n string) { 124 | s.logVerbosity = n 125 | } 126 | 127 | // NewStore returns an instantiated SettingsStore. 128 | func NewStore(configDir string) (Store, error) { 129 | // Setup a SettingsStore struct with sensible defaults 130 | s := defaultSettings() 131 | 132 | var err error 133 | s.file, err = os.OpenFile(configDir+"/settings.json", os.O_RDWR|os.O_CREATE, 0777) 134 | if err != nil { 135 | return s, err 136 | } 137 | 138 | // Save to the file if the file is empty 139 | var b []byte 140 | b, err = io.ReadAll(s.file) 141 | if err != nil { 142 | return s, err 143 | } 144 | if len(b) == 0 { 145 | err = s.Save() 146 | if err != nil { 147 | return s, err 148 | } 149 | } 150 | 151 | err = s.Load() 152 | return s, err 153 | } 154 | 155 | // defaultSettings returns a SettingsStore struct with sensible defaults applied. 156 | func defaultSettings() Store { 157 | return Store{ 158 | healthCheckInterval: uint64(1 * time.Minute), 159 | healthCheckTimeout: uint64(1 * time.Hour), 160 | logVerbosity: "INFO", 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /controller/settings/settings_test.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | func TestFileReadCalled(t *testing.T) { 10 | ss := defaultSettings() 11 | mRWC := mockReadWriteSeekCloser{} 12 | ss.file = &mRWC 13 | 14 | if err := ss.Load(); err != nil { 15 | t.Errorf("unexpected error from SettingsStore.Read(): %v", err) 16 | } 17 | 18 | if !mRWC.readCalled { 19 | t.Errorf("expected SettingsStore.file.Read() to be called") 20 | } 21 | } 22 | 23 | func TestFileWriteCalled(t *testing.T) { 24 | ss := defaultSettings() 25 | mRWC := mockReadWriteSeekCloser{} 26 | ss.file = &mRWC 27 | 28 | if err := ss.Save(); err != nil { 29 | t.Errorf("unexpected error from SettingsStore.Save(): %v", err) 30 | } 31 | 32 | if !mRWC.writeCalled { 33 | t.Errorf("expected SettingsStore.file.Write() to be called") 34 | } 35 | } 36 | 37 | func TestFileCloseCalled(t *testing.T) { 38 | ss := defaultSettings() 39 | mRWC := mockReadWriteSeekCloser{} 40 | ss.file = &mRWC 41 | 42 | if err := ss.Close(); err != nil { 43 | t.Errorf("unexpected error from SettingsStore.Close(): %v", err) 44 | } 45 | 46 | if !mRWC.closeCalled { 47 | t.Errorf("expected SettingsStore.file.Close() to be called") 48 | } 49 | } 50 | 51 | func TestCloseSetsClosed(t *testing.T) { 52 | ss := defaultSettings() 53 | mRWC := mockReadWriteSeekCloser{} 54 | ss.file = &mRWC 55 | 56 | if err := ss.Close(); err != nil { 57 | t.Errorf("unexpected error from SettingsStore.Close(): %v", err) 58 | } 59 | 60 | if !ss.closed { 61 | t.Errorf("expected SettingsStore.closed to be true, but it was false") 62 | } 63 | } 64 | 65 | func TestErrorReturnedAfterFileIsClosed(t *testing.T) { 66 | ss := defaultSettings() 67 | mRWC := mockReadWriteSeekCloser{} 68 | ss.file = &mRWC 69 | 70 | if err := ss.Close(); err != nil { 71 | t.Errorf("unexpected error from SettingsStore.Close(): %v", err) 72 | } 73 | 74 | if err := ss.Load(); err != controller.ErrClosed { 75 | t.Errorf("expected controller.ErrClosed error from Load() after Close() is called, but got %v instead", err) 76 | } 77 | 78 | if err := ss.Save(); err != controller.ErrClosed { 79 | t.Errorf("expected controller.ErrClosed error from Save() after Close() is called, but got %v instead", err) 80 | } 81 | } 82 | 83 | // TODO: Test that file is truncated on call to ss.Save 84 | -------------------------------------------------------------------------------- /controller/sqlite/database.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "embed" 6 | "errors" 7 | "io" 8 | "os" 9 | 10 | _ "modernc.org/sqlite" // The SQLite database driver 11 | 12 | "github.com/BrenekH/encodarr/controller" 13 | "github.com/golang-migrate/migrate/v4" 14 | _ "github.com/golang-migrate/migrate/v4/database/sqlite" // Add the sqlite database source to golang-migrate 15 | _ "github.com/golang-migrate/migrate/v4/source/file" // Add the file migrations source to golang-migrate 16 | ) 17 | 18 | //go:embed migrations 19 | var migrations embed.FS 20 | 21 | const targetMigrationVersion uint = 2 22 | 23 | // Database is a wrapper around the database driver client 24 | type Database struct { 25 | Client *sql.DB 26 | } 27 | 28 | // NewDatabase returns an instantiated SQLiteDatabase. 29 | func NewDatabase(configDir string, logger controller.Logger) (Database, error) { 30 | dbFilename := configDir + "/data.db" 31 | dbBackupFilename := configDir + "/data.db.backup" 32 | 33 | client, err := sql.Open("sqlite", dbFilename) 34 | if err != nil { 35 | return Database{Client: client}, err 36 | } 37 | 38 | // Set max connections to 1 to prevent "database is locked" errors 39 | client.SetMaxOpenConns(1) 40 | 41 | err = gotoDBVer(dbFilename, targetMigrationVersion, configDir, dbBackupFilename, logger) 42 | 43 | return Database{Client: client}, err 44 | } 45 | 46 | // gotoDBVer uses github.com/golang-migrate/migrate to move the db version up or down to the passed target version. 47 | func gotoDBVer(dbFilename string, targetVersion uint, configDir string, backupFilename string, logger controller.Logger) error { 48 | // Instead of directly using the embedded files, write them out to {configDir}/migrations. This allows the files for downgrading the 49 | // database to be present even when the executable doesn't contain them. 50 | fsMigrationsDir := configDir + "/migrations" 51 | 52 | if err := os.MkdirAll(fsMigrationsDir, 0777); err != nil { 53 | return err 54 | } 55 | 56 | dirEntries, err := migrations.ReadDir("migrations") 57 | if err != nil { 58 | return err 59 | } 60 | 61 | var copyErred bool 62 | for _, v := range dirEntries { 63 | f, err := os.Create(fsMigrationsDir + "/" + v.Name()) 64 | if err != nil { 65 | logger.Error("%v", err) 66 | copyErred = true 67 | continue 68 | } 69 | 70 | embeddedFile, err := migrations.Open("migrations/" + v.Name()) 71 | if err != nil { 72 | logger.Error("%v", err) 73 | copyErred = true 74 | f.Close() 75 | continue 76 | } 77 | 78 | if _, err := io.Copy(f, embeddedFile); err != nil { 79 | logger.Error("%v", err) 80 | copyErred = true 81 | // Don't continue right here so that the files are closed before looping again 82 | } 83 | 84 | f.Close() 85 | embeddedFile.Close() 86 | } 87 | if copyErred { 88 | return errors.New("error(s) while copying migrations, check logs for more details") 89 | } 90 | 91 | mig, err := migrate.New("file://"+configDir+"/migrations", "sqlite://"+dbFilename) 92 | if err != nil { 93 | return err 94 | } 95 | defer mig.Close() 96 | 97 | currentVer, _, err := mig.Version() 98 | if err != nil { 99 | if err == migrate.ErrNilVersion { 100 | // DB is likely before golang-migrate was introduced. Upgrade to new version 101 | logger.Warn("Database does not have a schema version. Attempting to migrate up.") 102 | err = backupFile(dbFilename, backupFilename, logger) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | return mig.Migrate(targetVersion) 108 | } 109 | return err 110 | } 111 | 112 | if currentVer == targetVersion { 113 | return nil 114 | } 115 | 116 | err = backupFile(dbFilename, backupFilename, logger) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | logger.Info("Migrating database to schema version %v.", targetVersion) 122 | return mig.Migrate(targetVersion) 123 | } 124 | 125 | // backupFile backups a file to an io.Writer and logs about it. 126 | func backupFile(from, to string, logger controller.Logger) error { 127 | fromReader, err := os.Open(from) 128 | if err != nil { 129 | return err 130 | } 131 | 132 | toWriter, err := os.Create(to) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | logger.Info("Backing up database.") 138 | _, err = io.Copy(toWriter, fromReader) 139 | return err 140 | } 141 | -------------------------------------------------------------------------------- /controller/sqlite/file_cache.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | 7 | "github.com/BrenekH/encodarr/controller" 8 | ) 9 | 10 | // NewFileCacheAdapter returns an instantiated FileCacheAdapter. 11 | func NewFileCacheAdapter(db *Database) FileCacheAdapter { 12 | return FileCacheAdapter{db: db} 13 | } 14 | 15 | // FileCacheAdapter satisfies the controller.FilesCacheDataStorer interface by turning interface 16 | // requests into SQL requests that are passed on to an underlying SQLiteDatabase. 17 | type FileCacheAdapter struct { 18 | db *Database 19 | } 20 | 21 | // Modtime uses a SQL SELECT statement to obtain the modtime associated with the provided path. 22 | func (a *FileCacheAdapter) Modtime(path string) (time.Time, error) { 23 | row := a.db.Client.QueryRow("SELECT modtime FROM files WHERE path = $1;", path) 24 | 25 | var storedModtime time.Time 26 | 27 | err := row.Scan(&storedModtime) 28 | if err != nil { 29 | return time.Now(), err 30 | } 31 | 32 | return storedModtime, nil 33 | } 34 | 35 | // Metadata uses a SQL SELECT statement to obtain the metadata associated with the provided path. 36 | func (a *FileCacheAdapter) Metadata(path string) (controller.FileMetadata, error) { 37 | row := a.db.Client.QueryRow("SELECT metadata FROM files WHERE path = $1;", path) 38 | 39 | var storedMetadataBytes []byte 40 | 41 | err := row.Scan(&storedMetadataBytes) 42 | if err != nil { 43 | return controller.FileMetadata{}, err 44 | } 45 | 46 | var storedMetadata controller.FileMetadata 47 | 48 | err = json.Unmarshal(storedMetadataBytes, &storedMetadata) 49 | if err != nil { 50 | return controller.FileMetadata{}, err 51 | } 52 | 53 | return storedMetadata, nil 54 | } 55 | 56 | // SaveModtime uses the UPSERT syntax to update the modtime that is associated with the provided path in the database. 57 | func (a *FileCacheAdapter) SaveModtime(path string, t time.Time) error { 58 | _, err := a.db.Client.Exec("INSERT INTO files (path, modtime) VALUES ($1, $2) ON CONFLICT(path) DO UPDATE SET path=$1, modtime=$2;", 59 | path, 60 | t, 61 | ) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // SaveMetadata uses the UPSERT syntax to update the metadata that is associated with the provided path in the database. 70 | func (a *FileCacheAdapter) SaveMetadata(path string, f controller.FileMetadata) error { 71 | b, err := json.Marshal(f) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | _, err = a.db.Client.Exec("INSERT INTO files (path, metadata) VALUES ($1, $2) ON CONFLICT(path) DO UPDATE SET path=$1, metadata=$2;", 77 | path, 78 | b, 79 | ) 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /controller/sqlite/health_checker.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | // NewHealthCheckerAdapter returns a new instantiated HealthCheckerAdapter. 10 | func NewHealthCheckerAdapter(db *Database, logger controller.Logger) HealthCheckerAdapter { 11 | return HealthCheckerAdapter{db: db, logger: logger} 12 | } 13 | 14 | // HealthCheckerAdapter satisfies the controller.HealthCheckerDataStorer interface by turning interface 15 | // requests into SQL requests that are passed on to an underlying SQLiteDatabase. 16 | type HealthCheckerAdapter struct { 17 | db *Database 18 | logger controller.Logger 19 | } 20 | 21 | // DispatchedJobs returns all of the dispatched jobs in the database. 22 | func (h *HealthCheckerAdapter) DispatchedJobs() []controller.DispatchedJob { 23 | returnSlice := make([]controller.DispatchedJob, 0) 24 | 25 | rows, err := h.db.Client.Query("SELECT uuid, runner, job, status, last_updated FROM dispatched_jobs;") 26 | if err != nil { 27 | h.logger.Error("%v", err) 28 | return returnSlice 29 | } 30 | 31 | for rows.Next() { 32 | // Variables to scan into 33 | dj := controller.DispatchedJob{} 34 | bJ := []byte("") // bytesJob. For intermediate loading into when scanning the rows 35 | bS := []byte("") // bytesStatus. For intermediate loading into when scanning the rows 36 | 37 | err = rows.Scan(&dj.UUID, &dj.Runner, &bJ, &bS, &dj.LastUpdated) 38 | if err != nil { 39 | h.logger.Error("%v", err) 40 | continue 41 | } 42 | 43 | err = json.Unmarshal(bJ, &dj.Job) 44 | if err != nil { 45 | h.logger.Error("%v", err) 46 | continue 47 | } 48 | 49 | err = json.Unmarshal(bS, &dj.Status) 50 | if err != nil { 51 | h.logger.Error("%v", err) 52 | continue 53 | } 54 | 55 | returnSlice = append(returnSlice, dj) 56 | } 57 | rows.Close() 58 | 59 | return returnSlice 60 | } 61 | 62 | // DeleteJob deletes a specific job from the database. 63 | func (h *HealthCheckerAdapter) DeleteJob(uuid controller.UUID) error { 64 | _, err := h.db.Client.Exec("DELETE FROM dispatched_jobs WHERE uuid = $1;", uuid) 65 | return err 66 | } 67 | -------------------------------------------------------------------------------- /controller/sqlite/health_checker_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | // TODO: Write tests 4 | -------------------------------------------------------------------------------- /controller/sqlite/migrations/000001_init_schema.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE libraries; 2 | DROP TABLE dispatched_jobs; 3 | DROP TABLE files; 4 | DROP TABLE history; 5 | -------------------------------------------------------------------------------- /controller/sqlite/migrations/000001_init_schema.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS libraries ( 2 | ID integer PRIMARY KEY, 3 | folder text, 4 | priority integer, 5 | fs_check_interval text, 6 | pipeline binary, 7 | queue binary, 8 | file_cache binary, 9 | path_masks binary 10 | ); 11 | 12 | CREATE TABLE IF NOT EXISTS files ( 13 | path text UNIQUE, 14 | modtime timestamp, 15 | mediainfo binary 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS history ( 19 | time_completed timestamp, 20 | filename text, 21 | warnings binary, 22 | errors binary 23 | ); 24 | 25 | CREATE TABLE IF NOT EXISTS dispatched_jobs ( 26 | uuid text NOT NULL UNIQUE, 27 | job binary, 28 | status binary, 29 | runner text, 30 | last_updated timestamp 31 | ); 32 | -------------------------------------------------------------------------------- /controller/sqlite/migrations/000002_pipeline_to_cmd_decider.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE libraries DROP COLUMN cmd_decider_settings; 2 | 3 | ALTER TABLE libraries ADD COLUMN pipeline binary; 4 | 5 | ALTER TABLE libraries ADD COLUMN file_cache binary; 6 | 7 | DROP TABLE files; 8 | 9 | CREATE TABLE IF NOT EXISTS files ( 10 | path text, 11 | modtime timestamp, 12 | mediainfo binary 13 | ); 14 | -------------------------------------------------------------------------------- /controller/sqlite/migrations/000002_pipeline_to_cmd_decider.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE libraries DROP COLUMN pipeline; 2 | 3 | ALTER TABLE libraries ADD COLUMN cmd_decider_settings text DEFAULT ''; 4 | 5 | ALTER TABLE libraries DROP COLUMN file_cache; 6 | 7 | DELETE FROM files; 8 | 9 | ALTER TABLE files DROP COLUMN mediainfo; 10 | 11 | ALTER TABLE files ADD COLUMN metadata binary; 12 | -------------------------------------------------------------------------------- /controller/sqlite/runner_comm.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | // NewRunnerCommunicatorAdapter returns an instantiated RunnerCommunicatorAdapter. 10 | func NewRunnerCommunicatorAdapter(db *Database, logger controller.Logger) RunnerCommunicatorAdapter { 11 | return RunnerCommunicatorAdapter{db: db, logger: logger} 12 | } 13 | 14 | // RunnerCommunicatorAdapter is a struct that satisfies the interface that connects a RunnerCommunicator 15 | // to a storage medium. 16 | type RunnerCommunicatorAdapter struct { 17 | db *Database 18 | logger controller.Logger 19 | } 20 | 21 | // DispatchedJob uses the provided uuid to retrieve a dispatched job from the database. 22 | func (r *RunnerCommunicatorAdapter) DispatchedJob(uuid controller.UUID) (controller.DispatchedJob, error) { 23 | row := r.db.Client.QueryRow("SELECT job, status, runner, last_updated FROM dispatched_jobs WHERE uuid = $1;", uuid) 24 | 25 | d := controller.DispatchedJob{UUID: uuid} 26 | bJob := []byte{} 27 | bStatus := []byte{} 28 | 29 | row.Scan( 30 | &bJob, 31 | &bStatus, 32 | &d.Runner, 33 | &d.LastUpdated, 34 | ) 35 | 36 | if err := json.Unmarshal(bJob, &d.Job); err != nil { 37 | return d, err 38 | } 39 | 40 | if err := json.Unmarshal(bStatus, &d.Status); err != nil { 41 | return d, err 42 | } 43 | 44 | return d, nil 45 | } 46 | 47 | // SaveDispatchedJob saves the provided dispatched job to the database. 48 | func (r *RunnerCommunicatorAdapter) SaveDispatchedJob(dJob controller.DispatchedJob) error { 49 | bJob, err := json.Marshal(dJob.Job) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | bStatus, err := json.Marshal(dJob.Status) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | _, err = r.db.Client.Exec("INSERT INTO dispatched_jobs (uuid, job, status, runner, last_updated) VALUES ($1, $2, $3, $4, $5) ON CONFLICT(uuid) DO UPDATE SET uuid=$1, job=$2, status=$3, runner=$4, last_updated=$5;", 60 | dJob.UUID, 61 | bJob, 62 | bStatus, 63 | dJob.Runner, 64 | dJob.LastUpdated, 65 | ) 66 | return err 67 | } 68 | -------------------------------------------------------------------------------- /controller/sqlite/user_interfacer.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/BrenekH/encodarr/controller" 7 | ) 8 | 9 | // NewUserInterfacerAdapter returns an instantiated UserInterfacerAdapter. 10 | func NewUserInterfacerAdapter(db *Database, logger controller.Logger) UserInterfacerAdapter { 11 | return UserInterfacerAdapter{db: db, logger: logger} 12 | } 13 | 14 | // UserInterfacerAdapter is a struct that satisfies the interface that connects a UserInterfacer 15 | // to a storage medium. 16 | type UserInterfacerAdapter struct { 17 | db *Database 18 | logger controller.Logger 19 | } 20 | 21 | // DispatchedJobs returns the content of the dispatched jobs table. 22 | func (u *UserInterfacerAdapter) DispatchedJobs() ([]controller.DispatchedJob, error) { 23 | returnSlice := make([]controller.DispatchedJob, 0) 24 | 25 | rows, err := u.db.Client.Query("SELECT uuid, runner, job, status, last_updated FROM dispatched_jobs;") 26 | if err != nil { 27 | return returnSlice, err 28 | } 29 | 30 | for rows.Next() { 31 | // Variables to scan into 32 | dj := controller.DispatchedJob{} 33 | bJ := []byte("") // bytesJob. For intermediate loading into when scanning the rows 34 | bS := []byte("") // bytesStatus. For intermediate loading into when scanning the rows 35 | 36 | err = rows.Scan(&dj.UUID, &dj.Runner, &bJ, &bS, &dj.LastUpdated) 37 | if err != nil { 38 | u.logger.Error(err.Error()) 39 | continue 40 | } 41 | 42 | err = json.Unmarshal(bJ, &dj.Job) 43 | if err != nil { 44 | u.logger.Error(err.Error()) 45 | continue 46 | } 47 | 48 | err = json.Unmarshal(bS, &dj.Status) 49 | if err != nil { 50 | u.logger.Error(err.Error()) 51 | continue 52 | } 53 | 54 | returnSlice = append(returnSlice, dj) 55 | } 56 | rows.Close() 57 | 58 | return returnSlice, nil 59 | } 60 | 61 | // HistoryEntries returns the content of the history table. 62 | func (u *UserInterfacerAdapter) HistoryEntries() ([]controller.History, error) { 63 | returnSlice := make([]controller.History, 0) 64 | 65 | rows, err := u.db.Client.Query("SELECT time_completed, filename, warnings, errors FROM history;") 66 | if err != nil { 67 | return returnSlice, err 68 | } 69 | 70 | for rows.Next() { 71 | dh := controller.History{} 72 | bW := []byte("") 73 | bE := []byte("") 74 | 75 | err = rows.Scan(&dh.DateTimeCompleted, &dh.Filename, &bW, &bE) 76 | if err != nil { 77 | u.logger.Error(err.Error()) 78 | continue 79 | } 80 | 81 | err = json.Unmarshal(bW, &dh.Warnings) 82 | if err != nil { 83 | u.logger.Error(err.Error()) 84 | continue 85 | } 86 | 87 | err = json.Unmarshal(bE, &dh.Errors) 88 | if err != nil { 89 | u.logger.Error(err.Error()) 90 | continue 91 | } 92 | 93 | returnSlice = append(returnSlice, dh) 94 | } 95 | rows.Close() 96 | 97 | return returnSlice, nil 98 | } 99 | 100 | // DeleteLibrary deletes the specified library from the libraries table. 101 | func (u *UserInterfacerAdapter) DeleteLibrary(id int) error { 102 | _, err := u.db.Client.Exec("DELETE FROM libraries WHERE ID = $1;", id) 103 | return err 104 | } 105 | -------------------------------------------------------------------------------- /controller/structs.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "reflect" 5 | "time" 6 | ) 7 | 8 | // The UUID type defines a Universally Unique Identifier 9 | type UUID string 10 | 11 | // Job represents a job to be carried out by a Runner. 12 | type Job struct { 13 | UUID UUID `json:"uuid"` 14 | Path string `json:"path"` 15 | Command []string `json:"command"` 16 | Metadata FileMetadata `json:"metadata"` 17 | } 18 | 19 | // CompletedJob represents a job that has been completed by a Runner. 20 | type CompletedJob struct { 21 | UUID UUID `json:"uuid"` 22 | Failed bool `json:"failed"` 23 | History History `json:"history"` 24 | InFile string `json:"-"` 25 | } 26 | 27 | // History represents a previously completed job. 28 | type History struct { 29 | Filename string `json:"file"` 30 | DateTimeCompleted time.Time `json:"datetime_completed"` 31 | Warnings []string `json:"warnings"` 32 | Errors []string `json:"errors"` 33 | } 34 | 35 | // DispatchedJob represents a job that is currently being worked on by a Runner. 36 | type DispatchedJob struct { 37 | UUID UUID `json:"uuid"` 38 | Runner string `json:"runner"` 39 | Job Job `json:"job"` 40 | Status JobStatus `json:"status"` 41 | LastUpdated time.Time `json:"last_updated"` 42 | } 43 | 44 | // JobStatus represents the current status of a dispatched job. 45 | type JobStatus struct { 46 | Stage string `json:"stage"` 47 | Percentage string `json:"percentage"` 48 | JobElapsedTime string `json:"job_elapsed_time"` 49 | FPS string `json:"fps"` 50 | StageElapsedTime string `json:"stage_elapsed_time"` 51 | StageEstimatedTimeRemaining string `json:"stage_estimated_time_remaining"` 52 | } 53 | 54 | // Library represents a single library. 55 | type Library struct { 56 | ID int `json:"id"` 57 | Folder string `json:"folder"` 58 | Priority int `json:"priority"` 59 | FsCheckInterval time.Duration `json:"fs_check_interval"` 60 | Queue LibraryQueue `json:"queue"` 61 | PathMasks []string `json:"path_masks"` 62 | CommandDeciderSettings string `json:"command_decider_settings"` // We are using a string for the CommandDecider settings because it is easier for the frontend to convert back and forth from when setting and reading values. 63 | } 64 | 65 | // File represents a file for the purposes of metadata reading. 66 | type File struct { 67 | Path string 68 | ModTime time.Time 69 | Metadata FileMetadata 70 | } 71 | 72 | // LibraryQueue represents a singular queue belonging to one library. 73 | type LibraryQueue struct { 74 | Items []Job 75 | } 76 | 77 | // Push appends an item to the end of a LibraryQueue. 78 | func (q *LibraryQueue) Push(item Job) { 79 | q.Items = append(q.Items, item) 80 | } 81 | 82 | // Pop removes and returns the first item of a LibraryQueue. 83 | func (q *LibraryQueue) Pop() (Job, error) { 84 | if len(q.Items) == 0 { 85 | return Job{}, ErrEmptyQueue 86 | } 87 | item := q.Items[0] 88 | q.Items[0] = Job{} // Hopefully this garbage collects properly 89 | q.Items = q.Items[1:] 90 | return item, nil 91 | } 92 | 93 | // Dequeue returns a copy of the underlying slice in the Queue. 94 | func (q *LibraryQueue) Dequeue() []Job { 95 | return append(make([]Job, 0, len(q.Items)), q.Items...) 96 | } 97 | 98 | // InQueue returns a boolean representing whether or not the provided item is in the queue 99 | func (q *LibraryQueue) InQueue(item Job) bool { 100 | for _, i := range (*q).Items { 101 | if item.Equal(i) { 102 | return true 103 | } 104 | } 105 | return false 106 | } 107 | 108 | // InQueuePath returns a boolean representing whether or not the provided item is in the queue based on only the Path field 109 | func (q *LibraryQueue) InQueuePath(item Job) bool { 110 | for _, i := range (*q).Items { 111 | if item.EqualPath(i) { 112 | return true 113 | } 114 | } 115 | return false 116 | } 117 | 118 | // Empty returns a boolean representing whether or not the queue is empty 119 | func (q *LibraryQueue) Empty() bool { 120 | return len(q.Items) == 0 121 | } 122 | 123 | // Equal is a custom equality check for the Job type 124 | func (j Job) Equal(check Job) bool { 125 | if j.UUID != check.UUID { 126 | return false 127 | } 128 | if j.Path != check.Path { 129 | return false 130 | } 131 | if !reflect.DeepEqual(j.Command, check.Command) { 132 | return false 133 | } 134 | return true 135 | } 136 | 137 | // EqualPath is a custom equality check for the Job type that only checks the Path parameter 138 | func (j Job) EqualPath(check Job) bool { 139 | return j.Path == check.Path 140 | } 141 | -------------------------------------------------------------------------------- /controller/userinterfacer/structs.go: -------------------------------------------------------------------------------- 1 | package userinterfacer 2 | 3 | import "github.com/BrenekH/encodarr/controller" 4 | 5 | type runningJSONResponse struct { 6 | DispatchedJobs []filteredDispatchedJob `json:"jobs"` 7 | } 8 | 9 | type filteredDispatchedJob struct { 10 | Job filteredJob `json:"job"` 11 | RunnerName string `json:"runner_name"` 12 | Status controller.JobStatus `json:"status"` 13 | } 14 | 15 | type filteredJob struct { 16 | UUID controller.UUID `json:"uuid"` 17 | Path string `json:"path"` 18 | Command []string `json:"command"` 19 | } 20 | 21 | type settingsJSON struct { 22 | FileSystemCheckInterval string 23 | HealthCheckInterval string 24 | HealthCheckTimeout string 25 | LogVerbosity string 26 | } 27 | 28 | type humanizedHistoryEntry struct { 29 | File string `json:"file"` 30 | DateTimeCompleted string `json:"datetime_completed"` 31 | Warnings []string `json:"warnings"` 32 | Errors []string `json:"errors"` 33 | } 34 | 35 | type historyJSON struct { 36 | History []humanizedHistoryEntry `json:"history"` 37 | } 38 | 39 | type interimLibraryJSON struct { 40 | ID int `json:"id"` 41 | Folder string `json:"folder"` 42 | Priority int `json:"priority"` 43 | FsCheckInterval string `json:"fs_check_interval"` 44 | Queue controller.LibraryQueue `json:"queue"` 45 | PathMasks []string `json:"path_masks"` 46 | CommandDeciderSettings string `json:"command_decider_settings"` 47 | } 48 | -------------------------------------------------------------------------------- /controller/userinterfacer/util.go: -------------------------------------------------------------------------------- 1 | package userinterfacer 2 | 3 | import "github.com/BrenekH/encodarr/controller" 4 | 5 | func filterDispatchedJobs(dJobs []controller.DispatchedJob) []filteredDispatchedJob { 6 | fDJobs := make([]filteredDispatchedJob, 0) 7 | for _, dJob := range dJobs { 8 | fDJobs = append(fDJobs, filteredDispatchedJob{ 9 | Job: filteredJob{ 10 | UUID: dJob.Job.UUID, 11 | Path: dJob.Job.Path, 12 | Command: dJob.Job.Command, 13 | }, 14 | RunnerName: dJob.Runner, 15 | Status: dJob.Status, 16 | }) 17 | } 18 | return fDJobs 19 | } 20 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/android-chrome-192x192.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/android-chrome-512x512.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/apple-touch-icon.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.386e5276.chunk.css", 4 | "main.js": "/static/js/main.633a313d.chunk.js", 5 | "main.js.map": "/static/js/main.633a313d.chunk.js.map", 6 | "runtime-main.js": "/static/js/runtime-main.d9007f64.js", 7 | "runtime-main.js.map": "/static/js/runtime-main.d9007f64.js.map", 8 | "static/css/2.62793d63.chunk.css": "/static/css/2.62793d63.chunk.css", 9 | "static/js/2.87856085.chunk.js": "/static/js/2.87856085.chunk.js", 10 | "static/js/2.87856085.chunk.js.map": "/static/js/2.87856085.chunk.js.map", 11 | "static/js/3.0271195d.chunk.js": "/static/js/3.0271195d.chunk.js", 12 | "static/js/3.0271195d.chunk.js.map": "/static/js/3.0271195d.chunk.js.map", 13 | "index.html": "/index.html", 14 | "static/css/2.62793d63.chunk.css.map": "/static/css/2.62793d63.chunk.css.map", 15 | "static/css/main.386e5276.chunk.css.map": "/static/css/main.386e5276.chunk.css.map", 16 | "static/js/2.87856085.chunk.js.LICENSE.txt": "/static/js/2.87856085.chunk.js.LICENSE.txt", 17 | "static/media/Encodarr-Logo.4b0cc1bf.svg": "/static/media/Encodarr-Logo.4b0cc1bf.svg", 18 | "static/media/Info-I.ffc9d3a2.svg": "/static/media/Info-I.ffc9d3a2.svg", 19 | "static/media/addLibraryIcon.dd5f1d29.svg": "/static/media/addLibraryIcon.dd5f1d29.svg", 20 | "static/media/terminalIcon.5147de0e.svg": "/static/media/terminalIcon.5147de0e.svg" 21 | }, 22 | "entrypoints": [ 23 | "static/js/runtime-main.d9007f64.js", 24 | "static/css/2.62793d63.chunk.css", 25 | "static/js/2.87856085.chunk.js", 26 | "static/css/main.386e5276.chunk.css", 27 | "static/js/main.633a313d.chunk.js" 28 | ] 29 | } -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/favicon-16x16.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/favicon-32x32.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/favicon.ico -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/index.html: -------------------------------------------------------------------------------- 1 | Encodarr
-------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Encodarr", 3 | "name": "Encodarr", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000e30", 24 | "background_color": "#000e30" 25 | } 26 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/controller/userinterfacer/webfiles/mstile-150x150.png -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 19 | 29 | 48 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/css/main.386e5276.chunk.css: -------------------------------------------------------------------------------- 1 | .progress-bar-style{margin-bottom:1rem;margin-top:1rem;height:2rem;font-size:.9rem}.file-image-container,.svg-flex-container{display:flex;justify-content:space-evenly}.svg-flex-container{flex-direction:column}.info-i,.showCommand{cursor:pointer}.showCommand{font-size:14pt}.spacer{margin-top:3em}.smol-spacer{margin-top:1.5em}.queue-icon{display:inline}.queue-icon-container{white-space:nowrap}.play-button-image{padding-right:5px}.add-lib-ico{cursor:pointer}.delete-button{margin-right:auto}.showQueueCommand{font-size:14pt;cursor:pointer}.api-list{list-style-type:none}.list-title{margin-bottom:.25rem}.pop-in-out{margin-left:1em;opacity:0;-webkit-animation:pulse 5s;animation:pulse 5s;-webkit-animation-timing-function:cubic-bezier(.61,1,.88,1);animation-timing-function:cubic-bezier(.61,1,.88,1)}@-webkit-keyframes pulse{0%{opacity:0}3%{opacity:1%}to{opacity:0}}@keyframes pulse{0%{opacity:0}3%{opacity:1%}to{opacity:0}}.dark-text-input{background-color:#060606!important;border-color:#282828!important;color:#fff!important}.form-control:focus,.no-box-shadow:focus{box-shadow:none}.header-flex{display:flex;align-items:baseline;justify-content:center;margin-top:1rem} 2 | /*# sourceMappingURL=main.386e5276.chunk.css.map */ -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/css/main.386e5276.chunk.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack://src/tabs/RunningTab.css","webpack://src/spacers.css","webpack://src/tabs/shared/SvgImage.css","webpack://src/tabs/LibrariesTab.css","webpack://src/tabs/AboutSection.css","webpack://src/tabs/SettingsTab.css","webpack://src/App.css"],"names":[],"mappings":"AAAA,oBACC,kBAAmB,CACnB,eAAgB,CAChB,WAAY,CACZ,eACD,CAOA,0CAJC,YAAa,CACb,4BAOD,CAJA,oBAEC,qBAED,CAMA,qBAHC,cAMD,CAHA,aACC,cAED,CCzBA,QACC,cACD,CAEA,aACC,gBACD,CCNA,YACC,cACD,CCFA,sBACC,kBACD,CAEA,mBACC,iBACD,CAEA,aACC,cACD,CAEA,eACC,iBACD,CAEA,kBACC,cAAe,CACf,cACD,CCnBA,UACC,oBACD,CAEA,YACC,oBACD,CCNA,YACC,eAAgB,CAChB,SAAW,CACX,0BAAmB,CAAnB,kBAAmB,CACnB,2DAAyD,CAAzD,mDACD,CAEA,yBACC,GACC,SACD,CAEA,GACC,UACD,CAEA,GACC,SACD,CACD,CAZA,iBACC,GACC,SACD,CAEA,GACC,UACD,CAEA,GACC,SACD,CACD,CAGA,iBAIC,kCAAoC,CACpC,8BAAgC,CAChC,oBACD,CAOA,yCAEC,eACD,CCvCA,aACC,YAAa,CACb,oBAAqB,CACrB,sBAAuB,CAEvB,eACD","file":"main.386e5276.chunk.css","sourcesContent":[".progress-bar-style {\n\tmargin-bottom: 1rem;\n\tmargin-top: 1rem;\n\theight: 2rem;\n\tfont-size: 0.9rem;\n}\n\n.file-image-container {\n\tdisplay: flex;\n\tjustify-content: space-evenly;\n}\n\n.svg-flex-container {\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-evenly;\n}\n\n.info-i {\n\tcursor: pointer;\n}\n\n.showCommand {\n\tfont-size: 14pt;\n\tcursor: pointer;\n}\n",".spacer {\n\tmargin-top: 3em;\n}\n\n.smol-spacer {\n\tmargin-top: 1.5em;\n}\n",".queue-icon {\n\tdisplay: inline;\n}\n",".queue-icon-container {\n\twhite-space: nowrap;\n}\n\n.play-button-image {\n\tpadding-right: 5px;\n}\n\n.add-lib-ico {\n\tcursor: pointer;\n}\n\n.delete-button {\n\tmargin-right: auto;\n}\n\n.showQueueCommand {\n\tfont-size: 14pt;\n\tcursor: pointer;\n}\n\n/* Override Bootstrap CSS */\n.dark-text-input {\n\t/* I know !important isn't great practice,\n\tbut the browser likes to prioritize the bootstrap over main.css\n\tand that's not okay */\n\tbackground-color: #060606 !important;\n\tborder-color: #282828 !important;\n\tcolor: #ffffff !important;\n}\n\n.form-control:focus {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n\n.no-box-shadow:focus {\n\t-webkit-box-shadow: none;\n\tbox-shadow: none;\n}\n",".api-list {\n\tlist-style-type: none;\n}\n\n.list-title {\n\tmargin-bottom: .25rem;\n}\n",".pop-in-out {\n\tmargin-left: 1em;\n\topacity: 0%;\n\tanimation: pulse 5s;\n\tanimation-timing-function: cubic-bezier(0.61, 1, 0.88, 1);\n}\n\n@keyframes pulse {\n\t0% {\n\t\topacity: 0%;\n\t}\n\n\t3% {\n\t\topacity: 100%;\n\t}\n\n\t100% {\n\t\topacity: 0%;\n\t}\n}\n\n/* Override Bootstrap CSS */\n.dark-text-input {\n\t/* I know !important isn't great practice,\n\tbut the browser likes to prioritize the bootstrap over main.css\n\tand that's not okay */\n\tbackground-color: #060606 !important;\n\tborder-color: #282828 !important;\n\tcolor: #ffffff !important;\n}\n\n.form-control:focus {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n\n.no-box-shadow:focus {\n\t-webkit-box-shadow: none;\n\tbox-shadow: none;\n}\n",".header-flex {\n\tdisplay: flex;\n\talign-items: baseline;\n\tjustify-content: center;\n\n\tmargin-top: 1rem;\n}\n"]} -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/css/main.d1f77bb4.chunk.css: -------------------------------------------------------------------------------- 1 | .progress-bar-style{margin-bottom:1rem;margin-top:1rem;height:2rem;font-size:.9rem}.file-image-container,.svg-flex-container{display:-webkit-flex;display:flex;-webkit-justify-content:space-evenly;justify-content:space-evenly}.svg-flex-container{-webkit-flex-direction:column;flex-direction:column}.info-i,.showCommand{cursor:pointer}.showCommand{font-size:14pt}.spacer{margin-top:3em}.smol-spacer{margin-top:1.5em}.queue-icon{display:inline}.queue-icon-container{white-space:nowrap}.play-button-image{padding-right:5px}.add-lib-ico{cursor:pointer}.delete-button{margin-right:auto}.showQueueCommand{font-size:14pt;cursor:pointer}.api-list{list-style-type:none}.list-title{margin-bottom:.25rem}.pop-in-out{margin-left:1em;opacity:0;-webkit-animation:pulse 5s;animation:pulse 5s;-webkit-animation-timing-function:cubic-bezier(.61,1,.88,1);animation-timing-function:cubic-bezier(.61,1,.88,1)}@-webkit-keyframes pulse{0%{opacity:0}3%{opacity:1%}to{opacity:0}}@keyframes pulse{0%{opacity:0}3%{opacity:1%}to{opacity:0}}.dark-text-input{background-color:#060606!important;border-color:#282828!important;color:#fff!important}.form-control:focus,.no-box-shadow:focus{box-shadow:none}.header-flex{display:-webkit-flex;display:flex;-webkit-align-items:baseline;align-items:baseline;-webkit-justify-content:center;justify-content:center;margin-top:1rem} 2 | /*# sourceMappingURL=main.d1f77bb4.chunk.css.map */ -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/css/main.d1f77bb4.chunk.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack://src/tabs/RunningTab.css","webpack://src/spacers.css","webpack://src/tabs/shared/SvgImage.css","webpack://src/tabs/LibrariesTab.css","webpack://src/tabs/AboutSection.css","webpack://src/tabs/SettingsTab.css","webpack://src/App.css"],"names":[],"mappings":"AAAA,oBACC,kBAAmB,CACnB,eAAgB,CAChB,WAAY,CACZ,eACD,CAOA,0CAJC,oBAAa,CAAb,YAAa,CACb,oCAA6B,CAA7B,4BAOD,CAJA,oBAEC,6BAAsB,CAAtB,qBAED,CAMA,qBAHC,cAMD,CAHA,aACC,cAED,CCzBA,QACC,cACD,CAEA,aACC,gBACD,CCNA,YACC,cACD,CCFA,sBACC,kBACD,CAEA,mBACC,iBACD,CAEA,aACC,cACD,CAEA,eACC,iBACD,CAEA,kBACC,cAAe,CACf,cACD,CCnBA,UACC,oBACD,CAEA,YACC,oBACD,CCNA,YACC,eAAgB,CAChB,SAAW,CACX,0BAAmB,CAAnB,kBAAmB,CACnB,2DAAyD,CAAzD,mDACD,CAEA,yBACC,GACC,SACD,CAEA,GACC,UACD,CAEA,GACC,SACD,CACD,CAZA,iBACC,GACC,SACD,CAEA,GACC,UACD,CAEA,GACC,SACD,CACD,CAGA,iBAIC,kCAAoC,CACpC,8BAAgC,CAChC,oBACD,CAOA,yCAEC,eACD,CCvCA,aACC,oBAAa,CAAb,YAAa,CACb,4BAAqB,CAArB,oBAAqB,CACrB,8BAAuB,CAAvB,sBAAuB,CAEvB,eACD","file":"main.d1f77bb4.chunk.css","sourcesContent":[".progress-bar-style {\n\tmargin-bottom: 1rem;\n\tmargin-top: 1rem;\n\theight: 2rem;\n\tfont-size: 0.9rem;\n}\n\n.file-image-container {\n\tdisplay: flex;\n\tjustify-content: space-evenly;\n}\n\n.svg-flex-container {\n\tdisplay: flex;\n\tflex-direction: column;\n\tjustify-content: space-evenly;\n}\n\n.info-i {\n\tcursor: pointer;\n}\n\n.showCommand {\n\tfont-size: 14pt;\n\tcursor: pointer;\n}\n",".spacer {\n\tmargin-top: 3em;\n}\n\n.smol-spacer {\n\tmargin-top: 1.5em;\n}\n",".queue-icon {\n\tdisplay: inline;\n}\n",".queue-icon-container {\n\twhite-space: nowrap;\n}\n\n.play-button-image {\n\tpadding-right: 5px;\n}\n\n.add-lib-ico {\n\tcursor: pointer;\n}\n\n.delete-button {\n\tmargin-right: auto;\n}\n\n.showQueueCommand {\n\tfont-size: 14pt;\n\tcursor: pointer;\n}\n\n/* Override Bootstrap CSS */\n.dark-text-input {\n\t/* I know !important isn't great practice,\n\tbut the browser likes to prioritize the bootstrap over main.css\n\tand that's not okay */\n\tbackground-color: #060606 !important;\n\tborder-color: #282828 !important;\n\tcolor: #ffffff !important;\n}\n\n.form-control:focus {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n\n.no-box-shadow:focus {\n\t-webkit-box-shadow: none;\n\tbox-shadow: none;\n}\n",".api-list {\n\tlist-style-type: none;\n}\n\n.list-title {\n\tmargin-bottom: .25rem;\n}\n",".pop-in-out {\n\tmargin-left: 1em;\n\topacity: 0%;\n\tanimation: pulse 5s;\n\tanimation-timing-function: cubic-bezier(0.61, 1, 0.88, 1);\n}\n\n@keyframes pulse {\n\t0% {\n\t\topacity: 0%;\n\t}\n\n\t3% {\n\t\topacity: 100%;\n\t}\n\n\t100% {\n\t\topacity: 0%;\n\t}\n}\n\n/* Override Bootstrap CSS */\n.dark-text-input {\n\t/* I know !important isn't great practice,\n\tbut the browser likes to prioritize the bootstrap over main.css\n\tand that's not okay */\n\tbackground-color: #060606 !important;\n\tborder-color: #282828 !important;\n\tcolor: #ffffff !important;\n}\n\n.form-control:focus {\n -webkit-box-shadow: none;\n box-shadow: none;\n}\n\n.no-box-shadow:focus {\n\t-webkit-box-shadow: none;\n\tbox-shadow: none;\n}\n",".header-flex {\n\tdisplay: flex;\n\talign-items: baseline;\n\tjustify-content: center;\n\n\tmargin-top: 1rem;\n}\n"]} -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/js/2.87856085.chunk.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2018 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /** @license React v0.20.2 14 | * scheduler.production.min.js 15 | * 16 | * Copyright (c) Facebook, Inc. and its affiliates. 17 | * 18 | * This source code is licensed under the MIT license found in the 19 | * LICENSE file in the root directory of this source tree. 20 | */ 21 | 22 | /** @license React v17.0.2 23 | * react-dom.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | 31 | /** @license React v17.0.2 32 | * react-jsx-runtime.production.min.js 33 | * 34 | * Copyright (c) Facebook, Inc. and its affiliates. 35 | * 36 | * This source code is licensed under the MIT license found in the 37 | * LICENSE file in the root directory of this source tree. 38 | */ 39 | 40 | /** @license React v17.0.2 41 | * react.production.min.js 42 | * 43 | * Copyright (c) Facebook, Inc. and its affiliates. 44 | * 45 | * This source code is licensed under the MIT license found in the 46 | * LICENSE file in the root directory of this source tree. 47 | */ 48 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/js/2.b4ebe8d9.chunk.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2018 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /** @license React v0.20.2 14 | * scheduler.production.min.js 15 | * 16 | * Copyright (c) Facebook, Inc. and its affiliates. 17 | * 18 | * This source code is licensed under the MIT license found in the 19 | * LICENSE file in the root directory of this source tree. 20 | */ 21 | 22 | /** @license React v17.0.2 23 | * react-dom.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | 31 | /** @license React v17.0.2 32 | * react-jsx-runtime.production.min.js 33 | * 34 | * Copyright (c) Facebook, Inc. and its affiliates. 35 | * 36 | * This source code is licensed under the MIT license found in the 37 | * LICENSE file in the root directory of this source tree. 38 | */ 39 | 40 | /** @license React v17.0.2 41 | * react.production.min.js 42 | * 43 | * Copyright (c) Facebook, Inc. and its affiliates. 44 | * 45 | * This source code is licensed under the MIT license found in the 46 | * LICENSE file in the root directory of this source tree. 47 | */ 48 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/js/3.0271195d.chunk.js: -------------------------------------------------------------------------------- 1 | (this["webpackJsonpencodarr-react-frontend"]=this["webpackJsonpencodarr-react-frontend"]||[]).push([[3],{95:function(t,e,n){"use strict";n.r(e),n.d(e,"getCLS",(function(){return p})),n.d(e,"getFCP",(function(){return S})),n.d(e,"getFID",(function(){return F})),n.d(e,"getLCP",(function(){return k})),n.d(e,"getTTFB",(function(){return C}));var i,r,a,o,c=function(t,e){return{name:t,value:void 0===e?-1:e,delta:0,entries:[],id:"v1-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},u=function(t,e){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){if("first-input"===t&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(t){return t.getEntries().map(e)}));return n.observe({type:t,buffered:!0}),n}}catch(t){}},f=function(t,e){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(t(i),e&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(t){addEventListener("pageshow",(function(e){e.persisted&&t(e)}),!0)},d="function"==typeof WeakSet?new WeakSet:new Set,m=function(t,e,n){var i;return function(){e.value>=0&&(n||d.has(e)||"hidden"===document.visibilityState)&&(e.delta=e.value-(i||0),(e.delta||void 0===i)&&(i=e.value,t(e)))}},p=function(t,e){var n,i=c("CLS",0),r=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},a=u("layout-shift",r);a&&(n=m(t,i,e),f((function(){a.takeRecords().map(r),n()})),s((function(){i=c("CLS",0),n=m(t,i,e)})))},v=-1,l=function(){return"hidden"===document.visibilityState?0:1/0},h=function(){f((function(t){var e=t.timeStamp;v=e}),!0)},g=function(){return v<0&&(v=l(),h(),s((function(){setTimeout((function(){v=l(),h()}),0)}))),{get timeStamp(){return v}}},S=function(t,e){var n,i=g(),r=c("FCP"),a=function(t){"first-contentful-paint"===t.name&&(f&&f.disconnect(),t.startTime=0&&r1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,e){var n=function(){w(t,e),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,y),removeEventListener("pointercancel",i,y)};addEventListener("pointerup",n,y),addEventListener("pointercancel",i,y)}(e,t):w(e,t)}},b=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach((function(e){return t(e,T,y)}))},F=function(t,e){var n,a=g(),p=c("FID"),v=function(t){t.startTime=0&&(n||d.has(e)||"hidden"===document.visibilityState)&&(e.delta=e.value-(i||0),(e.delta||void 0===i)&&(i=e.value,t(e)))}},p=function(t,e){var n,i=c("CLS",0),r=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},a=u("layout-shift",r);a&&(n=m(t,i,e),f((function(){a.takeRecords().map(r),n()})),s((function(){i=c("CLS",0),n=m(t,i,e)})))},v=-1,l=function(){return"hidden"===document.visibilityState?0:1/0},h=function(){f((function(t){var e=t.timeStamp;v=e}),!0)},g=function(){return v<0&&(v=l(),h(),s((function(){setTimeout((function(){v=l(),h()}),0)}))),{get timeStamp(){return v}}},S=function(t,e){var n,i=g(),r=c("FCP"),a=function(t){"first-contentful-paint"===t.name&&(f&&f.disconnect(),t.startTime=0&&r1e12?new Date:performance.now())-t.timeStamp;"pointerdown"==t.type?function(t,e){var n=function(){w(t,e),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,y),removeEventListener("pointercancel",i,y)};addEventListener("pointerup",n,y),addEventListener("pointercancel",i,y)}(e,t):w(e,t)}},b=function(t){["mousedown","keydown","touchstart","pointerdown"].forEach((function(e){return t(e,T,y)}))},F=function(t,e){var n,a=g(),p=c("FID"),v=function(t){t.startTime 2 | 20 | 22 | 46 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 64 | 69 | 78 | 87 | 96 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/media/Info-I.ffc9d3a2.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 69 | 74 | 82 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/media/addLibraryIcon.dd5f1d29.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 68 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /controller/userinterfacer/webfiles/static/media/terminalIcon.5147de0e.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 38 | 42 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /controller/util.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // IsContextFinished returns a boolean indicating whether or not a context.Context is finished. 8 | // This replaces the need to use a select code block. 9 | func IsContextFinished(ctx *context.Context) bool { 10 | select { 11 | case <-(*ctx).Done(): 12 | return true 13 | default: 14 | return false 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /controller/util_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestIsContextFinished(t *testing.T) { 9 | fCtx, cancel := context.WithCancel(context.Background()) 10 | cancel() 11 | uCtx := context.Background() 12 | 13 | tests := []struct { 14 | name string 15 | in context.Context 16 | out bool 17 | }{ 18 | {name: "Finished Context", in: fCtx, out: true}, 19 | {name: "Unfinished Context", in: uCtx, out: false}, 20 | } 21 | 22 | for _, test := range tests { 23 | t.Run(test.name, func(t *testing.T) { 24 | out := IsContextFinished(&test.in) 25 | 26 | if out != test.out { 27 | t.Errorf("expected %v but got %v", test.out, out) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "encodarr-react-frontend", 3 | "version": "0.3.2", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^11.2.7", 8 | "@testing-library/user-event": "^12.8.3", 9 | "@types/jest": "^26.0.24", 10 | "@types/node": "^12.20.55", 11 | "@types/react": "^17.0.80", 12 | "@types/react-dom": "^17.0.25", 13 | "axios": "^1.8.2", 14 | "bootswatch": "^4.6.2", 15 | "mini-css-extract-plugin": "2.4.7", 16 | "react": "^17.0.1", 17 | "react-bootstrap": "^1.6.8", 18 | "react-dom": "^17.0.2", 19 | "react-scripts": "^5.0.1", 20 | "typescript": "^4.9.5", 21 | "web-vitals": "^1.1.2" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "proxy": "http://localhost:8123" 48 | } 49 | -------------------------------------------------------------------------------- /frontend/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | Encodarr 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Encodarr", 3 | "name": "Encodarr", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000e30", 24 | "background_color": "#000e30" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/public/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 19 | 29 | 48 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /frontend/resources/Encodarr-Logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 46 | 51 | 52 | 54 | 55 | 57 | image/svg+xml 58 | 60 | 61 | 62 | 63 | 64 | 69 | 78 | 87 | 96 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /frontend/resources/Info-I.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 68 | 69 | 74 | 82 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /frontend/resources/addLibraryIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 68 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /frontend/resources/play_button.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 58 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /frontend/resources/terminalIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 17 | 18 | 20 | image/svg+xml 21 | 23 | 24 | 25 | 26 | 27 | 30 | 38 | 42 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .header-flex { 2 | display: flex; 3 | align-items: baseline; 4 | justify-content: center; 5 | 6 | margin-top: 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Tab from "react-bootstrap/Tab"; 3 | import Nav from "react-bootstrap/Nav"; 4 | 5 | import { RunningTab } from "./tabs/RunningTab"; 6 | import { LibrariesTab } from "./tabs/LibrariesTab"; 7 | import { HistoryTab } from "./tabs/HistoryTab"; 8 | import { SettingsTab } from "./tabs/SettingsTab"; 9 | import EncodarrLogo from "./EncodarrLogo"; 10 | import "./spacers.css"; 11 | import "./App.css"; 12 | 13 | 14 | function Title() { 15 | return (
16 | 17 |

ncodarr

18 |
); 19 | } 20 | 21 | class App extends React.Component { 22 | handleSelect(eventKey: any) { 23 | switch (eventKey) { 24 | case "libraries": 25 | window.history.replaceState(undefined, "", "/libraries"); 26 | document.title = "Libraries - Encodarr"; 27 | break; 28 | case "history": 29 | window.history.replaceState(undefined, "", "/history"); 30 | document.title = "History - Encodarr"; 31 | break; 32 | case "settings": 33 | window.history.replaceState(undefined, "", "/settings"); 34 | document.title = "Settings - Encodarr"; 35 | break; 36 | case "running": 37 | window.history.replaceState(undefined, "", "/running"); 38 | document.title = "Running - Encodarr"; 39 | break; 40 | default: 41 | break; 42 | } 43 | } 44 | 45 | render() { 46 | let activeKey: string = "running"; 47 | switch (window.location.pathname) { 48 | case "/libraries": 49 | activeKey = "libraries"; 50 | break; 51 | case "/history": 52 | activeKey = "history"; 53 | break; 54 | case "/settings": 55 | activeKey = "settings"; 56 | break; 57 | default: 58 | break; 59 | } 60 | 61 | return (
62 | 63 | <Tab.Container id="tab-nav" defaultActiveKey={activeKey} transition={false} onSelect={this.handleSelect}> 64 | <Nav fill variant="pills"> 65 | <Nav.Item> 66 | <Nav.Link eventKey="running">Running</Nav.Link> 67 | </Nav.Item> 68 | <Nav.Item> 69 | <Nav.Link eventKey="libraries">Libraries</Nav.Link> 70 | </Nav.Item> 71 | <Nav.Item> 72 | <Nav.Link eventKey="history">History</Nav.Link> 73 | </Nav.Item> 74 | <Nav.Item> 75 | <Nav.Link eventKey="settings">Settings</Nav.Link> 76 | </Nav.Item> 77 | </Nav> 78 | 79 | <div className="spacer"></div> 80 | 81 | <Tab.Content> 82 | <Tab.Pane eventKey="running" mountOnEnter={true} unmountOnExit={true}> 83 | <RunningTab /> 84 | </Tab.Pane> 85 | <Tab.Pane eventKey="libraries" mountOnEnter={true} unmountOnExit={true}> 86 | <LibrariesTab /> 87 | </Tab.Pane> 88 | <Tab.Pane eventKey="history" mountOnEnter={true} unmountOnExit={true}> 89 | <HistoryTab /> 90 | </Tab.Pane> 91 | <Tab.Pane eventKey="settings" mountOnEnter={true} unmountOnExit={true}> 92 | <SettingsTab /> 93 | </Tab.Pane> 94 | </Tab.Content> 95 | </Tab.Container> 96 | 97 | <div className="smol-spacer"></div> 98 | </div>); 99 | } 100 | } 101 | 102 | export default App; 103 | -------------------------------------------------------------------------------- /frontend/src/EncodarrLogo.tsx: -------------------------------------------------------------------------------- 1 | export default function EncodarrLogo(props: any) { 2 | return <svg 3 | xmlns="http://www.w3.org/2000/svg" 4 | width={363.804} 5 | height={507.559} 6 | viewBox="0 0 96.256 134.292" 7 | {...props} 8 | > 9 | <g transform="translate(-25.192 -30.75)"> 10 | <rect 11 | style={{ 12 | fill: "#3626a7", 13 | fillOpacity: 1, 14 | strokeWidth: 0.183394, 15 | stopColor: "#000", 16 | }} 17 | width={21.433} 18 | height={132.292} 19 | x={26.192} 20 | y={31.75} 21 | ry={10.716} 22 | rx={10.716} 23 | /> 24 | <rect 25 | style={{ 26 | fill: "#657ed4", 27 | fillOpacity: 1, 28 | strokeWidth: 0.262019, 29 | stopColor: "#000", 30 | }} 31 | width={65.264} 32 | height={23.812} 33 | x={55.185} 34 | y={31.75} 35 | rx={11.906} 36 | ry={11.906} 37 | /> 38 | <rect 39 | style={{ 40 | fill: "#657ed4", 41 | fillOpacity: 1, 42 | strokeWidth: 0.26175, 43 | stopColor: "#000", 44 | }} 45 | width={65.13} 46 | height={23.812} 47 | x={55.185} 48 | y={140.229} 49 | rx={11.906} 50 | ry={11.906} 51 | /> 52 | <rect 53 | style={{ 54 | fill: "#28afb0", 55 | fillOpacity: 1, 56 | strokeWidth: 0.211026, 57 | imageRendering: "auto", 58 | stopColor: "#000", 59 | }} 60 | width={47.625} 61 | height={21.167} 62 | x={55.185} 63 | y={89.958} 64 | rx={10.583} 65 | ry={10.583} 66 | /> 67 | </g> 68 | </svg> 69 | } 70 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/frontend/src/index.css -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | // Import Cyborg bootstrap theme from Bootswatch 2 | import "bootswatch/dist/cyborg/bootstrap.min.css"; 3 | 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import './index.css'; 7 | import App from './App'; 8 | import reportWebVitals from './reportWebVitals'; 9 | 10 | ReactDOM.render( 11 | <React.StrictMode> 12 | <App /> 13 | </React.StrictMode>, 14 | document.getElementById('root') 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="react-scripts" /> 2 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/spacers.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | margin-top: 3em; 3 | } 4 | 5 | .smol-spacer { 6 | margin-top: 1.5em; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/tabs/AboutSection.css: -------------------------------------------------------------------------------- 1 | .api-list { 2 | list-style-type: none; 3 | } 4 | 5 | .list-title { 6 | margin-bottom: .25rem; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/tabs/AboutSection.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import React from "react"; 3 | 4 | import "./AboutSection.css"; 5 | 6 | interface IAboutSectionState { 7 | controller_version: string, 8 | web_api_versions: Array<string>, 9 | runner_api_versions: Array<string>, 10 | } 11 | 12 | export default class AboutSection extends React.Component<{}, IAboutSectionState> { 13 | constructor (props: any) { 14 | super(props); 15 | 16 | this.state = { 17 | controller_version: "Could not contact a ring", 18 | web_api_versions: [], 19 | runner_api_versions: [], 20 | }; 21 | } 22 | 23 | componentDidMount() { 24 | // Get api supported versions 25 | axios.get("/api").then((response) => { 26 | this.setState({ 27 | web_api_versions: response.data.web.versions, 28 | runner_api_versions: response.data.runner.versions, 29 | }); 30 | }).catch((error) => { 31 | console.error(`Request to /api failed with error: ${error}`); 32 | }); 33 | 34 | // Get controller version 35 | axios.get("/version").then((response) => { 36 | this.setState({ 37 | controller_version: response.data, 38 | }); 39 | }).catch((error) => { 40 | console.error(`Request to /api failed with error: ${error}`); 41 | }); 42 | } 43 | 44 | render() { 45 | return (<> 46 | <h5>About Encodarr</h5> 47 | 48 | <p><b>License:</b> This project is licensed under the Mozilla Public License 2.0 a copy of which can be found <a href="https://github.com/BrenekH/encodarr/blob/master/LICENSE" target="_blank" rel="noreferrer">here</a></p> 49 | 50 | <p><b>Controller Version:</b> {this.state.controller_version}</p> 51 | <p className="list-title"><b>Supported API Versions:</b></p> 52 | <ul className="api-list"> 53 | <li><b>Web:</b> {this.state.web_api_versions.join(", ")}</li> 54 | <li><b>Runner:</b> {this.state.runner_api_versions.join(", ")}</li> 55 | </ul> 56 | 57 | <p><b>GitHub Repository:</b> <a href="https://github.com/BrenekH/encodarr" target="_blank" rel="noreferrer">https://github.com/BrenekH/encodarr</a></p> 58 | </>); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /frontend/src/tabs/AddLibraryIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react" 2 | 3 | const AddLibraryIcon = (props: SVGProps<SVGSVGElement>) => ( 4 | <svg 5 | xmlns="http://www.w3.org/2000/svg" 6 | width="150mm" 7 | height="150mm" 8 | viewBox="0 0 150 150" 9 | {...props} 10 | > 11 | <rect 12 | style={{ 13 | fill: "#fff", 14 | fillOpacity: 1, 15 | strokeWidth: 4.16469, 16 | stopColor: "#000", 17 | }} 18 | width={50} 19 | height={150} 20 | x={50} 21 | y={-150} 22 | rx={0} 23 | ry={0} 24 | transform="scale(1 -1)" 25 | /> 26 | <rect 27 | style={{ 28 | fill: "#fff", 29 | fillOpacity: 1, 30 | strokeWidth: 4.1647, 31 | stopColor: "#000", 32 | }} 33 | width={50} 34 | height={150} 35 | x={50} 36 | rx={0} 37 | ry={0} 38 | transform="matrix(0 1 1 0 0 0)" 39 | /> 40 | </svg> 41 | ) 42 | 43 | export default AddLibraryIcon 44 | -------------------------------------------------------------------------------- /frontend/src/tabs/HistoryTab.tsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import React from "react"; 3 | import Card from "react-bootstrap/Card"; 4 | import Table from "react-bootstrap/Table" 5 | 6 | interface IJobHistory { 7 | datetime_completed: string, 8 | file: string, 9 | } 10 | 11 | interface IHistoryTabState { 12 | jobs: Array<IJobHistory>, 13 | waitingOnServer: Boolean, 14 | } 15 | 16 | export class HistoryTab extends React.Component<{}, IHistoryTabState> { 17 | timerID: ReturnType<typeof setInterval>; 18 | 19 | constructor(props: any) { 20 | super(props); 21 | this.state = { 22 | jobs: [], 23 | waitingOnServer: true, 24 | }; 25 | 26 | // This is just so Typescript doesn't whine about timerID not being instantiated. 27 | this.timerID = setTimeout(() => {}, Number.POSITIVE_INFINITY); 28 | clearInterval(this.timerID); 29 | } 30 | 31 | componentDidMount() { 32 | this.tick(); 33 | this.timerID = setInterval( 34 | () => this.tick(), 35 | 2000 // Two seconds 36 | ); 37 | } 38 | 39 | componentWillUnmount() { 40 | clearInterval(this.timerID); 41 | } 42 | 43 | tick() { 44 | axios.get("/api/web/v1/history").then((response) => { 45 | let history = response.data.history; 46 | if (history === undefined) { 47 | console.error("Response from /api/web/v1/history returned undefined for data.history"); 48 | return; 49 | } 50 | history.reverse(); 51 | this.setState({ 52 | jobs: history, 53 | waitingOnServer: false, 54 | }); 55 | }).catch((error) => { 56 | console.error(`Request to /api/web/v1/history failed with error: ${error}`); 57 | }); 58 | } 59 | 60 | render(): React.ReactNode { 61 | const jobEntries = this.state.jobs.map((v: IJobHistory, i: number) => { 62 | return <TableEntry key={i} datetime={v.datetime_completed} file={v.file}/>; 63 | }); 64 | 65 | const waitingForServerEntry = <tr><th scope="row">-</th><td>Waiting on server</td></tr>; 66 | 67 | return (<Card> 68 | <Table hover size="sm"> 69 | <thead> 70 | <tr> 71 | <th scope="col">Time Completed</th> 72 | <th scope="col">File</th> 73 | </tr> 74 | </thead> 75 | <tbody> 76 | {(!this.state.waitingOnServer) ? jobEntries : waitingForServerEntry} 77 | </tbody> 78 | </Table> 79 | </Card>); 80 | } 81 | } 82 | 83 | interface ITableEntryProps { 84 | datetime: string, 85 | file: string, 86 | } 87 | 88 | function TableEntry(props: ITableEntryProps) { 89 | return <tr><td>{props.datetime}</td><td>{props.file}</td></tr>; 90 | } 91 | -------------------------------------------------------------------------------- /frontend/src/tabs/InfoIIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react" 2 | 3 | const InfoIIcon = (props: SVGProps<SVGSVGElement>) => ( 4 | <svg 5 | xmlns="http://www.w3.org/2000/svg" 6 | width={885.714} 7 | height={885.714} 8 | viewBox="0 0 234.345 234.345" 9 | {...props} 10 | > 11 | <path 12 | style={{ 13 | fill: "#fff", 14 | fillOpacity: 1, 15 | strokeWidth: 12.7181, 16 | stopColor: "#000", 17 | }} 18 | d="M442.67 0A442.857 442.857 0 0 0 0 442.857a442.857 442.857 0 0 0 442.857 442.858 442.857 442.857 0 0 0 442.858-442.858A442.857 442.857 0 0 0 442.857 0a442.857 442.857 0 0 0-.187 0zm-2.947 68.572a371.429 372.092 0 0 1 .277 0 371.429 372.092 0 0 1 371.428 372.092A371.429 372.092 0 0 1 440 812.756 371.429 372.092 0 0 1 68.572 440.664a371.429 372.092 0 0 1 371.15-372.092z" 19 | transform="scale(.26458)" 20 | /> 21 | <g transform="translate(12.352 -26.499)"> 22 | <rect 23 | style={{ 24 | fill: "#fff", 25 | strokeWidth: 2.65864, 26 | stopColor: "#000", 27 | }} 28 | width={44.198} 29 | height={111.689} 30 | x={80.088} 31 | y={107.335} 32 | ry={0} 33 | /> 34 | <circle 35 | style={{ 36 | fill: "#fff", 37 | strokeWidth: 2.37377, 38 | stopColor: "#000", 39 | }} 40 | cx={102.485} 41 | cy={79.562} 42 | r={22.397} 43 | /> 44 | </g> 45 | </svg> 46 | ) 47 | 48 | export default InfoIIcon 49 | -------------------------------------------------------------------------------- /frontend/src/tabs/LibrariesTab.css: -------------------------------------------------------------------------------- 1 | .queue-icon-container { 2 | white-space: nowrap; 3 | } 4 | 5 | .play-button-image { 6 | padding-right: 5px; 7 | } 8 | 9 | .add-lib-ico { 10 | cursor: pointer; 11 | } 12 | 13 | .delete-button { 14 | margin-right: auto; 15 | } 16 | 17 | .showQueueCommand { 18 | font-size: 14pt; 19 | cursor: pointer; 20 | } 21 | 22 | /* Override Bootstrap CSS */ 23 | .dark-text-input { 24 | /* I know !important isn't great practice, 25 | but the browser likes to prioritize the bootstrap over main.css 26 | and that's not okay */ 27 | background-color: #060606 !important; 28 | border-color: #282828 !important; 29 | color: #ffffff !important; 30 | } 31 | 32 | .form-control:focus { 33 | -webkit-box-shadow: none; 34 | box-shadow: none; 35 | } 36 | 37 | .no-box-shadow:focus { 38 | -webkit-box-shadow: none; 39 | box-shadow: none; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/tabs/RunningTab.css: -------------------------------------------------------------------------------- 1 | .progress-bar-style { 2 | margin-bottom: 1rem; 3 | margin-top: 1rem; 4 | height: 2rem; 5 | font-size: 0.9rem; 6 | } 7 | 8 | .file-image-container { 9 | display: flex; 10 | justify-content: space-evenly; 11 | } 12 | 13 | .svg-flex-container { 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: space-evenly; 17 | } 18 | 19 | .info-i { 20 | cursor: pointer; 21 | } 22 | 23 | .showCommand { 24 | font-size: 14pt; 25 | cursor: pointer; 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/tabs/SettingsTab.css: -------------------------------------------------------------------------------- 1 | .pop-in-out { 2 | margin-left: 1em; 3 | opacity: 0%; 4 | animation: pulse 5s; 5 | animation-timing-function: cubic-bezier(0.61, 1, 0.88, 1); 6 | } 7 | 8 | @keyframes pulse { 9 | 0% { 10 | opacity: 0%; 11 | } 12 | 13 | 3% { 14 | opacity: 100%; 15 | } 16 | 17 | 100% { 18 | opacity: 0%; 19 | } 20 | } 21 | 22 | /* Override Bootstrap CSS */ 23 | .dark-text-input { 24 | /* I know !important isn't great practice, 25 | but the browser likes to prioritize the bootstrap over main.css 26 | and that's not okay */ 27 | background-color: #060606 !important; 28 | border-color: #282828 !important; 29 | color: #ffffff !important; 30 | } 31 | 32 | .form-control:focus { 33 | -webkit-box-shadow: none; 34 | box-shadow: none; 35 | } 36 | 37 | .no-box-shadow:focus { 38 | -webkit-box-shadow: none; 39 | box-shadow: none; 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/tabs/shared/AudioImage.tsx: -------------------------------------------------------------------------------- 1 | import HeadphonesIcon from "./HeadphonesIcon"; 2 | 3 | export function AudioImage() { 4 | return (<div style={{ display: "inline" }} title="An additional stereo audio track will be created"> 5 | <HeadphonesIcon /> 6 | </div>); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/tabs/shared/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react" 2 | 3 | const PlayButton = (props: SVGProps<SVGSVGElement>) => ( 4 | <svg 5 | xmlns="http://www.w3.org/2000/svg" 6 | width={48} 7 | height={48} 8 | viewBox="0 0 12.7 12.7" 9 | {...props} 10 | > 11 | <path 12 | style={{ 13 | fill: "#0f0", 14 | fillRule: "evenodd", 15 | strokeWidth: 0.264583, 16 | }} 17 | d="M11.628 6.348 1.302 12.309V.386z" 18 | transform="matrix(1.22077 0 0 1.05866 -1.524 -.39)" 19 | /> 20 | </svg> 21 | ) 22 | 23 | export default PlayButton 24 | -------------------------------------------------------------------------------- /frontend/src/tabs/shared/TerminalIcon.tsx: -------------------------------------------------------------------------------- 1 | import TerminalIconSvg from "./TerminalIconSvg"; 2 | 3 | export interface ITerminalIconProps { 4 | title: string, 5 | height?: string, 6 | width?: string, 7 | } 8 | 9 | export default function TerminalIcon(props: ITerminalIconProps) { 10 | return (<div style={{ display: "inline" }} title={props.title}> 11 | <TerminalIconSvg height={props.height} width={props.width} /> 12 | </div>); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/tabs/shared/TerminalIconSvg.tsx: -------------------------------------------------------------------------------- 1 | import { SVGProps } from "react" 2 | 3 | const TerminalIconSvg = (props: SVGProps<SVGSVGElement>) => ( 4 | <svg xmlns="http://www.w3.org/2000/svg" width={405.675} height={332.944} viewBox="0 0 107.335 88.091" 5 | {...props} 6 | > 7 | <g transform="translate(-40.679 -66.337)"> 8 | <rect style={{ 9 | fill: "none", 10 | stroke: "#fff", 11 | strokeWidth: 9.50602, 12 | strokeLinecap: "round", 13 | strokeLinejoin: "round", 14 | strokeMiterlimit: 4, 15 | strokeDasharray: "none", 16 | strokeOpacity: 1, 17 | paintOrder: "normal", 18 | stopColor: "#000", 19 | }} width={97.829} height={78.585} x={45.432} y={71.09} ry={20.866} 20 | /> 21 | <path style={{ 22 | fill: "none", 23 | fillOpacity: 1, 24 | stroke: "#fff", 25 | strokeWidth: 10.5833, 26 | strokeLinecap: "round", 27 | strokeLinejoin: "round", 28 | strokeMiterlimit: 4, 29 | strokeDasharray: "none", 30 | strokeOpacity: 1, 31 | }} d="m66.283 92.208 23.252 18.442-23.252 20.045" /> 32 | <path style={{ 33 | fill: "none", 34 | stroke: "#fff", 35 | strokeWidth: 12.3723, 36 | strokeLinecap: "round", 37 | strokeLinejoin: "round", 38 | strokeMiterlimit: 4, 39 | strokeDasharray: "none", 40 | strokeOpacity: 1, 41 | }} d="m97.572 130.652 24.006.021" /> 42 | </g> 43 | </svg> 44 | ) 45 | 46 | export default TerminalIconSvg 47 | -------------------------------------------------------------------------------- /frontend/src/tabs/shared/VideoImage.tsx: -------------------------------------------------------------------------------- 1 | import PlayButton from "./PlayButton"; 2 | 3 | export function VideoImage() { 4 | return (<div style={{ display: "inline" }} title="File will be encoded to HEVC"> 5 | <PlayButton /> 6 | </div>); 7 | } 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /images/Encodarr-Text-Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BrenekH/encodarr/51840eaf1f27c88fda4bfa5c511c70377e312a6f/images/Encodarr-Text-Logo.png -------------------------------------------------------------------------------- /runner/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Cmder", 4 | "sincer" 5 | ] 6 | } -------------------------------------------------------------------------------- /runner/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM golang:1.20-buster AS builder 3 | 4 | ARG TARGETOS 5 | ARG TARGETARCH 6 | ARG LDFLAGS_VERSION=development 7 | 8 | WORKDIR /go/src/encodarr/runner 9 | 10 | COPY . . 11 | 12 | # Disable CGO so that we have a static binary 13 | RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o runner -ldflags="-X 'github.com/BrenekH/encodarr/runner/options.Version=${LDFLAGS_VERSION}'" cmd/EncodarrRunner/main.go 14 | 15 | 16 | # Run stage 17 | FROM ubuntu:20.04 18 | 19 | ENV TZ=Etc/GMT \ 20 | ENCODARR_CONFIG_DIR="/config" 21 | 22 | WORKDIR /usr/src/app 23 | 24 | RUN chmod 777 /usr/src/app \ 25 | && apt-get update -qq \ 26 | && DEBIAN_FRONTEND="noninteractive" apt-get install -qq -y tzdata ffmpeg 27 | 28 | COPY --from=builder /go/src/encodarr/runner/runner ./runner 29 | 30 | RUN chmod 777 ./runner 31 | 32 | CMD ["./runner"] 33 | -------------------------------------------------------------------------------- /runner/cmd/EncodarrRunner/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/BrenekH/encodarr/runner" 11 | "github.com/BrenekH/encodarr/runner/cmdrunner" 12 | "github.com/BrenekH/encodarr/runner/http" 13 | "github.com/BrenekH/encodarr/runner/options" 14 | "github.com/BrenekH/logange" 15 | ) 16 | 17 | var ( 18 | logger logange.Logger 19 | ) 20 | 21 | func init() { 22 | logger = logange.NewLogger("main") 23 | } 24 | 25 | func main() { 26 | logger.Info("Starting Encodarr Runner") 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | 29 | signals := make(chan os.Signal, 1) 30 | signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) 31 | 32 | go func() { 33 | sig := <-signals 34 | logger.Info(fmt.Sprintf("Received stop signal: %v", sig)) 35 | cancel() 36 | }() 37 | 38 | cmdRun := cmdrunner.NewCmdRunner() 39 | 40 | apiV1, err := http.NewAPIv1( 41 | options.TempDir(), 42 | options.RunnerName(), 43 | options.ControllerIP(), 44 | options.ControllerPort(), 45 | ) 46 | if err != nil { 47 | logger.Critical(err.Error()) 48 | } 49 | 50 | runner.Run(&ctx, &apiV1, &cmdRun, false) 51 | } 52 | -------------------------------------------------------------------------------- /runner/cmdrunner/interfaces.go: -------------------------------------------------------------------------------- 1 | package cmdrunner 2 | 3 | import ( 4 | "io" 5 | "time" 6 | ) 7 | 8 | // Sincer is an interface that allows mocking out time.Since for testing. 9 | type Sincer interface { 10 | Since(t time.Time) time.Duration 11 | } 12 | 13 | // Commander is an interface that allows for mocking out the os/exec package for testing. 14 | type Commander interface { 15 | Command(name string, args ...string) Cmder 16 | } 17 | 18 | // Cmder is an interface for mocking out the exec.Cmd struct. 19 | type Cmder interface { 20 | Start() error 21 | StderrPipe() (io.ReadCloser, error) 22 | Wait() error 23 | } 24 | -------------------------------------------------------------------------------- /runner/cmdrunner/mocks.go: -------------------------------------------------------------------------------- 1 | package cmdrunner 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "time" 7 | ) 8 | 9 | type mockSincer struct { 10 | t time.Time 11 | } 12 | 13 | func (m *mockSincer) Since(t time.Time) time.Duration { 14 | return m.t.Sub(t) 15 | } 16 | 17 | type mockCommander struct { 18 | cmder mockCmder 19 | 20 | lastCallArgs []string 21 | } 22 | 23 | func (m *mockCommander) Command(name string, args ...string) Cmder { 24 | m.lastCallArgs = make([]string, 0) 25 | m.lastCallArgs = append(m.lastCallArgs, args...) 26 | return &m.cmder 27 | } 28 | 29 | type mockCmder struct { 30 | statusCode int 31 | } 32 | 33 | func (m *mockCmder) Start() error { 34 | return nil 35 | } 36 | 37 | func (m *mockCmder) StderrPipe() (io.ReadCloser, error) { 38 | return io.NopCloser(&bytes.Buffer{}), io.EOF 39 | } 40 | 41 | func (m *mockCmder) Wait() error { 42 | if m.statusCode == 0 { 43 | return nil 44 | } 45 | return mockExitError{statusCode: m.statusCode} 46 | } 47 | 48 | type mockExitError struct { 49 | statusCode int 50 | } 51 | 52 | func (m mockExitError) Error() string { 53 | return "mock exit error" 54 | } 55 | 56 | func (m mockExitError) ExitCode() int { 57 | return m.statusCode 58 | } 59 | -------------------------------------------------------------------------------- /runner/cmdrunner/structs.go: -------------------------------------------------------------------------------- 1 | package cmdrunner 2 | 3 | import ( 4 | "os/exec" 5 | "time" 6 | ) 7 | 8 | type timeSince struct{} 9 | 10 | func (s timeSince) Since(t time.Time) time.Duration { 11 | return time.Since(t) 12 | } 13 | 14 | type execCommander struct{} 15 | 16 | func (e execCommander) Command(name string, args ...string) Cmder { 17 | return exec.Command(name, args...) 18 | } 19 | -------------------------------------------------------------------------------- /runner/cmdrunner/util.go: -------------------------------------------------------------------------------- 1 | package cmdrunner 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // parseColonTimeToDuration takes an "HH:MM:SS" string and converts it to a time.Duration. 10 | // The hour portion does not have to be <= 24. 11 | func parseColonTimeToDuration(s string) (time.Duration, error) { 12 | var hrs, mins, secs int64 13 | 14 | _, err := fmt.Sscanf(s, "%d:%d:%d", &hrs, &mins, &secs) 15 | if err != nil { 16 | return time.Duration(0), err 17 | } 18 | 19 | // Making everything into time.Durations probably isn't the best option, 20 | // but there doesn't seem to be a great option to avoid them and still return a time.Duration. 21 | return time.Duration( 22 | time.Hour*time.Duration(hrs) + 23 | time.Minute*time.Duration(mins) + 24 | time.Second*time.Duration(secs), 25 | ), nil 26 | } 27 | 28 | // parseFFmpegLine parses the fps, time, and speed information from a standard FFmpeg statistics line 29 | // and updates the provided pointers if the parsing doesn't return an error. 30 | func parseFFmpegLine(line string, fps *float64, time *string, speed *float64) { 31 | if pFps, err := extractFps(line); err == nil { 32 | *fps = pFps 33 | } else { 34 | logger.Trace(err.Error()) 35 | } 36 | 37 | if pTime, err := extractTime(line); err == nil { 38 | *time = pTime 39 | } else { 40 | logger.Trace(err.Error()) 41 | } 42 | 43 | if pSpeed, err := extractSpeed(line); err == nil { 44 | // Not preventing the speed from being zero allows a divide by zero error when calculating stats. 45 | // Curiously, Go doesn't panic when performing a divide by zero among floats, only integers. 46 | // This behavior causes the estimated time remaining to appear as a negative number, which makes no sense. 47 | if pSpeed != 0.0 { 48 | *speed = pSpeed 49 | } 50 | } else { 51 | logger.Trace(err.Error()) 52 | } 53 | } 54 | 55 | func extractFps(line string) (fps float64, err error) { 56 | fpsReMatch := fpsRe.FindStringSubmatch(line) 57 | if len(fpsReMatch) > 1 { 58 | fps, err = strconv.ParseFloat(fpsReMatch[1], 64) 59 | if err != nil { 60 | return 61 | } 62 | } else { 63 | err = fmt.Errorf("extractFps: fps regex returned too few groups (need at least 2)") 64 | return 65 | } 66 | return 67 | } 68 | 69 | func extractTime(line string) (time string, err error) { 70 | timeReMatch := timeRe.FindStringSubmatch(line) 71 | if len(timeReMatch) > 1 { 72 | time = timeReMatch[1] 73 | } else { 74 | err = fmt.Errorf("extractTime: time regex returned too few groups (need at least 2)") 75 | return 76 | } 77 | return 78 | } 79 | 80 | func extractSpeed(line string) (speed float64, err error) { 81 | speedReMatch := speedRe.FindStringSubmatch(line) 82 | if len(speedReMatch) > 1 { 83 | speed, err = strconv.ParseFloat(speedReMatch[1], 64) 84 | if err != nil { 85 | return 86 | } 87 | } else { 88 | err = fmt.Errorf("extractSpeed: speed regex returned too few groups (need at least 2)") 89 | return 90 | } 91 | return 92 | } 93 | -------------------------------------------------------------------------------- /runner/errors.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "errors" 4 | 5 | // ErrUnresponsive represents the error state when the Controller decides that the Runner is no longer responsive. 6 | var ErrUnresponsive error = errors.New("received unresponsive status code") 7 | -------------------------------------------------------------------------------- /runner/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/BrenekH/encodarr/runner 2 | 3 | go 1.17 4 | 5 | require github.com/BrenekH/logange v0.4.0 6 | -------------------------------------------------------------------------------- /runner/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BrenekH/logange v0.4.0 h1:Mg7Dx204QXEo63BSKwxroQl4Te+thg6PzO6m1pSJIi4= 2 | github.com/BrenekH/logange v0.4.0/go.mod h1:688zEc1nhUoKOa0z+2c3whFyrQRB9yX68AGQS627fao= 3 | -------------------------------------------------------------------------------- /runner/http/interfaces.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | netHTTP "net/http" 6 | "time" 7 | ) 8 | 9 | // RequestDoer is a mock interface for the ApiV1 which allows 10 | // for mock http Client's to be inserted during testing. 11 | type RequestDoer interface { 12 | Do(*netHTTP.Request) (*netHTTP.Response, error) 13 | } 14 | 15 | // CurrentTimer is an interface for abstracting time.Now away from the code 16 | // for testing purposes. 17 | type CurrentTimer interface { 18 | Now() time.Time 19 | } 20 | 21 | // FSer is a mock interface for the ApiV1 struct which allows 22 | // file system operations to be mocked out during testing. 23 | type FSer interface { 24 | Create(name string) (Filer, error) 25 | Open(name string) (Filer, error) 26 | } 27 | 28 | // Filer is a mock interface for the ApiV1 struct which allows 29 | // reading, writing, closing, and naming of a file. 30 | type Filer interface { 31 | io.Closer 32 | io.Reader 33 | io.Writer 34 | Name() string 35 | } 36 | -------------------------------------------------------------------------------- /runner/http/mocks.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | netHTTP "net/http" 6 | "time" 7 | ) 8 | 9 | type mockHTTPClient struct { 10 | DoResponse netHTTP.Response 11 | LastRequest netHTTP.Request 12 | 13 | doCalled bool 14 | } 15 | 16 | func (m *mockHTTPClient) Do(req *netHTTP.Request) (*netHTTP.Response, error) { 17 | m.doCalled = true 18 | m.LastRequest = *req 19 | return &m.DoResponse, nil 20 | } 21 | 22 | // mockCurrentTime is a mock struct for the CurrentTimer interface. 23 | type mockCurrentTime struct { 24 | time time.Time 25 | } 26 | 27 | func (m *mockCurrentTime) Now() time.Time { 28 | return m.time 29 | } 30 | 31 | // mockFS is a mock struct for the FSer interface. 32 | type mockFS struct { 33 | createdFiles []string 34 | openedFiles []string 35 | } 36 | 37 | func (m *mockFS) Create(name string) (Filer, error) { 38 | if m.createdFiles == nil { 39 | m.createdFiles = make([]string, 1) 40 | } 41 | m.createdFiles = append(m.createdFiles, name) 42 | return &mockFiler{name}, nil 43 | } 44 | 45 | func (m *mockFS) Open(name string) (Filer, error) { 46 | if m.openedFiles == nil { 47 | m.openedFiles = make([]string, 1) 48 | } 49 | m.openedFiles = append(m.openedFiles, name) 50 | return &mockFiler{name}, nil 51 | } 52 | 53 | // mockFiler is a mock struct for the Filer interface. 54 | type mockFiler struct { 55 | name string 56 | } 57 | 58 | func (m *mockFiler) Close() error { return nil } 59 | 60 | func (m *mockFiler) Read(p []byte) (n int, err error) { 61 | return 0, io.EOF 62 | } 63 | 64 | func (m *mockFiler) Write(p []byte) (n int, err error) { 65 | return 0, io.EOF 66 | } 67 | 68 | func (m *mockFiler) Name() string { 69 | return m.name 70 | } 71 | -------------------------------------------------------------------------------- /runner/http/structs.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "os" 5 | "time" 6 | ) 7 | 8 | // OsFS is FSer that uses the os package to fulfill the 9 | // interface requirements. 10 | type OsFS struct{} 11 | 12 | // Create wraps os.Create 13 | func (o OsFS) Create(name string) (Filer, error) { 14 | return os.Create(name) 15 | } 16 | 17 | // Open wraps os.Open 18 | func (o OsFS) Open(name string) (Filer, error) { 19 | return os.Open(name) 20 | } 21 | 22 | // TimeNow uses time.Now to satisfy the CurrentTimer interface. 23 | type TimeNow struct{} 24 | 25 | // Now wraps time.Now 26 | func (t TimeNow) Now() time.Time { 27 | return time.Now() 28 | } 29 | 30 | // FileMetadata contains information about a video file. 31 | type FileMetadata struct { 32 | General General `json:"general"` 33 | VideoTracks []VideoTrack `json:"video_tracks"` 34 | AudioTracks []AudioTrack `json:"audio_tracks"` 35 | SubtitleTracks []SubtitleTrack `json:"subtitle_tracks"` 36 | } 37 | 38 | // General contains the general information about a media file. 39 | type General struct { 40 | Duration float32 `json:"duration"` 41 | } 42 | 43 | // VideoTrack contains information about a singular video stream in a media file. 44 | type VideoTrack struct { 45 | Index int `json:"index"` 46 | Codec string `json:"codec"` 47 | Bitrate int `json:"bitrate"` 48 | Width int `json:"width"` 49 | Height int `json:"height"` 50 | ColorPrimaries string `json:"color_primaries"` 51 | } 52 | 53 | // AudioTrack contains information about a singular audio stream in a media file. 54 | type AudioTrack struct { 55 | Index int `json:"index"` 56 | Channels int `json:"channels"` 57 | } 58 | 59 | // SubtitleTrack contains information about a singular text stream in a media file. 60 | type SubtitleTrack struct { 61 | Index int `json:"index"` 62 | Language string `json:"language"` 63 | } 64 | -------------------------------------------------------------------------------- /runner/http/util.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | // parseFFmpegCmd takes a string slice and creates a valid parameter list for FFmpeg 4 | func parseFFmpegCmd(inputFname, outputFname string, cmd []string) []string { 5 | if len(cmd) == 0 { 6 | return nil 7 | } 8 | 9 | var s []string = make([]string, len(cmd)) 10 | 11 | for i := range cmd { 12 | if cmd[i] == "ENCODARR_INPUT_FILE" { 13 | s[i] = inputFname 14 | } else { 15 | s[i] = cmd[i] 16 | } 17 | } 18 | 19 | return append(s, outputFname) 20 | } 21 | -------------------------------------------------------------------------------- /runner/http/util_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestParseFFmpegCmd(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | inFname string 13 | outFname string 14 | inCmd []string 15 | expected []string 16 | }{ 17 | { 18 | name: "Encode to HEVC", 19 | inFname: "input.mkv", 20 | outFname: "output.mkv", 21 | inCmd: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc"}, 22 | expected: []string{"-i", "input.mkv", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc", "output.mkv"}, 23 | }, 24 | { 25 | name: "Add Stereo Audio Track", 26 | inFname: "input.mkv", 27 | outFname: "output.mkv", 28 | inCmd: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "copy", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"}, 29 | expected: []string{"-i", "input.mkv", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "copy", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE", "output.mkv"}, 30 | }, 31 | { 32 | name: "Encode to HEVC and Add Stereo Audio Track", 33 | inFname: "input.mkv", 34 | outFname: "output.mkv", 35 | inCmd: []string{"-i", "ENCODARR_INPUT_FILE", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "hevc", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE"}, 36 | expected: []string{"-i", "input.mkv", "-map", "0:v", "-map", "0:s?", "-map", "0:a", "-map", "0:a", "-c:v", "hevc", "-c:s", "copy", "-c:a:1", "copy", "-c:a:0", "aac", "-filter:a:0", "pan=stereo|FL=0.5*FC+0.707*FL+0.707*BL+0.5*LFE|FR=0.5*FC+0.707*FR+0.707*BR+0.5*LFE", "output.mkv"}, 37 | }, 38 | { 39 | name: "Encode to HEVC using Hardware", 40 | inFname: "input.mkv", 41 | outFname: "output.mkv", 42 | inCmd: []string{"-hwaccel_device", "/dev/dri/renderD128", "-i", "ENCODARR_INPUT_FILE", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc"}, 43 | expected: []string{"-hwaccel_device", "/dev/dri/renderD128", "-i", "input.mkv", "-map", "0:s?", "-map", "0:a", "-c", "copy", "-map", "0:v", "-vcodec", "hevc", "output.mkv"}, 44 | }, 45 | { 46 | name: "All False Params", 47 | inFname: "input.mkv", 48 | outFname: "output.mkv", 49 | inCmd: []string{}, 50 | expected: nil, 51 | }, 52 | } 53 | 54 | for _, tt := range tests { 55 | testname := fmt.Sprintf("%v", tt.name) 56 | 57 | t.Run(testname, func(t *testing.T) { 58 | ans := parseFFmpegCmd(tt.inFname, tt.outFname, tt.inCmd) 59 | 60 | if !reflect.DeepEqual(ans, tt.expected) { 61 | t.Errorf("got %v, expected %v", ans, tt.expected) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /runner/interfaces.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "context" 4 | 5 | // Communicator defines how a struct which talks with a Controller should behave. 6 | type Communicator interface { 7 | SendJobComplete(*context.Context, JobInfo, CommandResults) error 8 | SendNewJobRequest(*context.Context) (JobInfo, error) 9 | SendStatus(*context.Context, string, JobStatus) error 10 | } 11 | 12 | // CommandRunner defines how a struct which runs the FFmpeg commands should behave. 13 | type CommandRunner interface { 14 | Done() bool 15 | Start(JobInfo) 16 | Status() JobStatus 17 | Results() CommandResults 18 | } 19 | -------------------------------------------------------------------------------- /runner/logging.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/BrenekH/encodarr/runner/options" 9 | "github.com/BrenekH/logange" 10 | ) 11 | 12 | var logger logange.Logger 13 | 14 | func init() { 15 | logger = logange.NewLogger("runner") 16 | 17 | formatter := logange.StandardFormatter{FormatString: "${datetime}|${name}|${lineno}|${levelname}|${message}\n"} 18 | 19 | // Setup the root logger to print info 20 | rootStdoutHandler := logange.NewStdoutHandler() 21 | rootStdoutHandler.SetFormatter(formatter) 22 | rootStdoutHandler.SetLevel(options.LogLevel()) 23 | 24 | logange.RootLogger.AddHandler(&rootStdoutHandler) 25 | 26 | // Setup a file handler for the root logger if we are not in test mode 27 | if !options.InTestMode() { 28 | rootFileHandler, err := logange.NewFileHandler(fmt.Sprintf("%v/runner.log", options.ConfigDir())) 29 | if err != nil { 30 | log.Printf("Error creating rootFileHandler: %v", err) 31 | os.Exit(10) 32 | return 33 | } 34 | rootFileHandler.SetFormatter(formatter) 35 | rootFileHandler.SetLevel(options.LogLevel()) 36 | 37 | logange.RootLogger.AddHandler(&rootFileHandler) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /runner/mocks.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "context" 4 | 5 | type mockCmdRunner struct { 6 | done bool 7 | statusLoopout int // How many calls to status should run before setting done to true 8 | jobInfo JobInfo 9 | jobStatus JobStatus 10 | commandResults CommandResults 11 | 12 | statusLoops int 13 | 14 | doneCalled bool 15 | startCalled bool 16 | statusCalled bool 17 | resultsCalled bool 18 | } 19 | 20 | func (r *mockCmdRunner) Done() bool { 21 | r.doneCalled = true 22 | return r.done 23 | } 24 | 25 | func (r *mockCmdRunner) Start(ji JobInfo) { 26 | r.startCalled = true 27 | r.jobInfo = ji 28 | } 29 | 30 | func (r *mockCmdRunner) Status() JobStatus { 31 | r.statusCalled = true 32 | 33 | r.statusLoops++ 34 | if r.statusLoops >= r.statusLoopout { 35 | r.done = true 36 | } 37 | return r.jobStatus 38 | } 39 | 40 | func (r *mockCmdRunner) Results() CommandResults { 41 | r.resultsCalled = true 42 | return r.commandResults 43 | } 44 | 45 | type mockCommunicator struct { 46 | jobReqJobInfo JobInfo 47 | jobComJobInfo JobInfo 48 | cmdResults CommandResults 49 | statusUUID string 50 | statusJobStatus JobStatus 51 | 52 | statusReturnErr error 53 | 54 | statusTimesCalled int 55 | 56 | jobCompleteCalled bool 57 | newJobCalled bool 58 | statusCalled bool 59 | 60 | // Used to to tell the mock to ignore any calls to SendStatus except for the very first one when setting statusUUID and statusJobStatus. 61 | statusSetOnlyFirst bool 62 | } 63 | 64 | func (c *mockCommunicator) SendJobComplete(ctx *context.Context, ji JobInfo, cr CommandResults) error { 65 | c.jobCompleteCalled = true 66 | c.jobComJobInfo = ji 67 | c.cmdResults = cr 68 | return nil 69 | } 70 | 71 | func (c *mockCommunicator) SendNewJobRequest(ctx *context.Context) (JobInfo, error) { 72 | c.newJobCalled = true 73 | return c.jobReqJobInfo, nil 74 | } 75 | 76 | func (c *mockCommunicator) SendStatus(ctx *context.Context, uuid string, js JobStatus) error { 77 | c.statusTimesCalled++ 78 | if c.statusSetOnlyFirst && !c.statusCalled { 79 | c.statusUUID = uuid 80 | c.statusJobStatus = js 81 | } 82 | c.statusCalled = true 83 | return c.statusReturnErr 84 | } 85 | -------------------------------------------------------------------------------- /runner/options/options_test.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestStringVarFromEnv(t *testing.T) { 9 | t.Run("Load Valid EnvVar", func(t *testing.T) { 10 | os.Clearenv() 11 | os.Setenv("MY_VAR", "hello") 12 | s := "default" 13 | stringVarFromEnv(&s, "MY_VAR") 14 | 15 | if s != "hello" { 16 | t.Errorf("expected 'hello' but got '%v'", s) 17 | } 18 | }) 19 | 20 | t.Run("Load Invalid EnvVar", func(t *testing.T) { 21 | os.Clearenv() 22 | s := "default" 23 | stringVarFromEnv(&s, "MY_VAR") 24 | 25 | if s != "default" { 26 | t.Errorf("expected 'default' but got '%v'", s) 27 | } 28 | }) 29 | } 30 | 31 | func TestConfigDir(t *testing.T) { 32 | c := ConfigDir() 33 | v := configDir 34 | 35 | if c != v { 36 | t.Errorf("expected %v but got %v", v, c) 37 | } 38 | } 39 | 40 | func TestTempDir(t *testing.T) { 41 | td := TempDir() 42 | v := tempDir 43 | 44 | if td != v { 45 | t.Errorf("expected %v but got %v", v, td) 46 | } 47 | } 48 | 49 | func TestRunnerName(t *testing.T) { 50 | r := RunnerName() 51 | v := runnerName 52 | 53 | if r != v { 54 | t.Errorf("expected %v but got %v", v, r) 55 | } 56 | } 57 | 58 | func TestControllerIP(t *testing.T) { 59 | cip := ControllerIP() 60 | v := controllerIP 61 | 62 | if cip != v { 63 | t.Errorf("expected %v but got %v", v, cip) 64 | } 65 | } 66 | 67 | func TestControllerPort(t *testing.T) { 68 | port := ControllerPort() 69 | v := controllerPort 70 | 71 | if port != v { 72 | t.Errorf("expected %v but got %v", v, port) 73 | } 74 | } 75 | 76 | func TestInTestMode(t *testing.T) { 77 | tm := InTestMode() 78 | v := inTestMode 79 | 80 | if tm != v { 81 | t.Errorf("expected %v but got %v", v, tm) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /runner/options/parse_cl_args.go: -------------------------------------------------------------------------------- 1 | // The purpose of this file to provide an API similar to the flag package for parsing command-line arguments 2 | // without impacting the testing package(see https://github.com/golang/go/issues/31859 and https://github.com/golang/go/issues/39093). 3 | 4 | package options 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "runtime" 10 | "strings" 11 | ) 12 | 13 | // flagger defines a type agnostic interface to parse out flags. 14 | type flagger interface { 15 | Name() string 16 | Description() string 17 | Usage() string 18 | Parse(string) error 19 | } 20 | 21 | var flags []flagger 22 | 23 | // stringVar replaces flag.StringVar, but without the default value. 24 | // That functionality is provided by the rest of the options package. 25 | func stringVar(p *string, name, description, usage string) { 26 | flags = append(flags, stringFlag{ 27 | name: name, 28 | description: description, 29 | usage: usage, 30 | pointer: p, 31 | }) 32 | } 33 | 34 | // parseCL parses the command-line arguments into the registered options. 35 | // Replaces flag.Parse. 36 | func parseCL() { 37 | var args []string = os.Args[1:] 38 | 39 | for k, v := range args { 40 | if v == "--help" { 41 | helpStr := fmt.Sprintf("Encodarr Runner %v Help\n\n", Version) 42 | 43 | for _, f := range flags { 44 | helpStr += fmt.Sprintf(" --%v - %v\n Usage: \"%v\"\n\n", 45 | f.Name(), 46 | f.Description(), 47 | f.Usage(), 48 | ) 49 | } 50 | 51 | fmt.Println(strings.TrimRight(helpStr, "\n")) 52 | os.Exit(0) 53 | } else if v == "--version" { 54 | fmt.Printf("Encodarr Runner %v %v/%v", Version, runtime.GOOS, runtime.GOARCH) 55 | os.Exit(0) 56 | } 57 | 58 | for _, f := range flags { 59 | if strings.Replace(v, "--", "", 1) == f.Name() { 60 | if i := k + 1; i >= len(args) { 61 | fmt.Printf("Can not parse %v, EOL reached", v) 62 | logger.Critical(fmt.Sprintf("Can not parse %v, EOL reached", v)) 63 | } else { 64 | f.Parse(args[k+1]) 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | type stringFlag struct { 72 | name string 73 | description string 74 | usage string 75 | pointer *string 76 | } 77 | 78 | func (f stringFlag) Parse(s string) error { 79 | *f.pointer = s 80 | return nil 81 | } 82 | 83 | func (f stringFlag) Description() string { 84 | return f.description 85 | } 86 | 87 | func (f stringFlag) Name() string { 88 | return f.name 89 | } 90 | 91 | func (f stringFlag) Usage() string { 92 | return f.usage 93 | } 94 | -------------------------------------------------------------------------------- /runner/options/parse_cl_args_test.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestStringVar(t *testing.T) { 9 | t.Run("Add Flag to Flags Slice", func(t *testing.T) { 10 | flags = []flagger{} 11 | 12 | var s string 13 | 14 | stringVar(&s, "test", "", "") 15 | expected := []flagger{stringFlag{name: "test", description: "", usage: "", pointer: &s}} 16 | 17 | if !reflect.DeepEqual(flags, expected) { 18 | t.Errorf("expected %v but got %v", expected, flags) 19 | } 20 | }) 21 | } 22 | 23 | func TestStringFlag(t *testing.T) { 24 | s := "default" 25 | sF := stringFlag{ 26 | name: "test", 27 | usage: "Use test", 28 | pointer: &s, 29 | } 30 | 31 | t.Run("Get Name", func(t *testing.T) { 32 | if sF.Name() != sF.name { 33 | t.Errorf("expected '%v' but got '%v", sF.name, sF.Name()) 34 | } 35 | }) 36 | 37 | t.Run("Get Usage", func(t *testing.T) { 38 | if sF.Usage() != sF.usage { 39 | t.Errorf("expected '%v' but got '%v", sF.usage, sF.Usage()) 40 | } 41 | }) 42 | 43 | t.Run("Set string", func(t *testing.T) { 44 | outErr := sF.Parse("hello") 45 | 46 | if outErr != nil { 47 | t.Errorf("unexpected error: %v", outErr) 48 | } 49 | 50 | if s != "hello" { 51 | t.Errorf("expected 'hello' but got '%v", s) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /runner/run.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | ) 8 | 9 | // Run runs the basic loop of the Runner 10 | func Run(ctx *context.Context, c Communicator, r CommandRunner, testMode bool) { 11 | looped := false 12 | 13 | for { 14 | if testMode && looped { 15 | break 16 | } 17 | if testMode { 18 | looped = true 19 | } 20 | 21 | if IsContextFinished(ctx) { 22 | break 23 | } 24 | 25 | // Send new job request 26 | ji, err := c.SendNewJobRequest(ctx) 27 | if err != nil { 28 | logger.Error(err.Error()) 29 | time.Sleep(time.Second) 30 | continue 31 | } 32 | 33 | // Start job with request info 34 | r.Start(ji) 35 | 36 | // This allows us to rate limit to approx. 1 status POST request every 500ms, which keeps resources (tcp sockets) down. 37 | statusLastSent := time.Unix(0, 0) 38 | statusInterval := time.Duration(500 * time.Millisecond) 39 | sleepAmount := time.Duration(50 * time.Millisecond) 40 | 41 | unresponsive := false 42 | 43 | for !r.Done() { 44 | // Rate limit how often we send status updates 45 | if time.Since(statusLastSent) < statusInterval { 46 | time.Sleep(sleepAmount) 47 | continue 48 | } 49 | statusLastSent = time.Now() 50 | 51 | // Get status from job 52 | status := r.Status() 53 | 54 | // Send status to Controller 55 | err = c.SendStatus(ctx, ji.UUID, status) 56 | if err != nil { 57 | if err == ErrUnresponsive { 58 | logger.Warn(err.Error()) 59 | unresponsive = true 60 | break 61 | } else { 62 | logger.Error(err.Error()) 63 | } 64 | } 65 | 66 | if IsContextFinished(ctx) { 67 | break 68 | } 69 | } 70 | // If we are detected as unresponsive, skip sending the job complete request. 71 | if unresponsive { 72 | cleanup(ji) 73 | continue 74 | } 75 | 76 | // If the context is finished, we want to avoid sending a misleading Job Complete request 77 | if IsContextFinished(ctx) { 78 | cleanup(ji) 79 | break 80 | } 81 | 82 | // Collect results from Command Runner 83 | cmdResults := r.Results() 84 | 85 | // Make sure that the Web UI properly states that we are copying the result to the Controller. 86 | // Setting Percentage to 100 also makes sure that the Runner card appears at the top of the page. 87 | err = c.SendStatus(ctx, ji.UUID, JobStatus{ 88 | Stage: "Copying to Controller", 89 | Percentage: "100", 90 | JobElapsedTime: cmdResults.JobElapsedTime.String(), 91 | FPS: "N/A", 92 | StageElapsedTime: "N/A", 93 | StageEstimatedTimeRemaining: "N/A", 94 | }) 95 | if err != nil { 96 | logger.Warn(err.Error()) 97 | } 98 | 99 | // Send job complete 100 | err = c.SendJobComplete(ctx, ji, cmdResults) 101 | if err != nil { 102 | logger.Error(err.Error()) 103 | } 104 | 105 | cleanup(ji) 106 | } 107 | } 108 | 109 | // IsContextFinished returns a boolean indicating whether or not a context.Context is finished. 110 | // This replaces the need to use a select code block. 111 | func IsContextFinished(ctx *context.Context) bool { 112 | select { 113 | case <-(*ctx).Done(): 114 | return true 115 | default: 116 | return false 117 | } 118 | } 119 | 120 | // cleanup uses os.Remove to delete JobInfo.InFile and JobInfo.OutFile. 121 | func cleanup(ji JobInfo) { 122 | if err := os.Remove(ji.InFile); err != nil { 123 | logger.Warn(err.Error()) 124 | } 125 | 126 | if err := os.Remove(ji.OutFile); err != nil { 127 | logger.Warn(err.Error()) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /runner/structs.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "time" 4 | 5 | // JobInfo defines the information about a Job that is sent from the Controller. 6 | type JobInfo struct { 7 | UUID string 8 | File string 9 | InFile string 10 | OutFile string 11 | CommandArgs []string 12 | MediaDuration float32 13 | } 14 | 15 | // JobStatus defines the information to be reported about the current state of a running job. 16 | type JobStatus struct { 17 | Stage string `json:"stage"` 18 | Percentage string `json:"percentage"` 19 | JobElapsedTime string `json:"job_elapsed_time"` 20 | FPS string `json:"fps"` 21 | StageElapsedTime string `json:"stage_elapsed_time"` 22 | StageEstimatedTimeRemaining string `json:"stage_estimated_time_remaining"` 23 | } 24 | 25 | // CommandResults is the results of the FFmpeg command that the Runner was told by the Controller to run. 26 | type CommandResults struct { 27 | Failed bool 28 | JobElapsedTime time.Duration 29 | Warnings []string 30 | Errors []string 31 | } 32 | --------------------------------------------------------------------------------