├── .air.toml ├── .babelrc ├── .browserslistrc ├── .dockerignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ └── docker-image.yml ├── .gitignore ├── .nvmrc ├── .stylelintrc.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assets └── src │ ├── images │ ├── logo-lofi.svg │ ├── logo-night.svg │ └── logo.svg │ ├── js │ ├── app │ │ ├── action.js │ │ ├── action │ │ │ ├── image.js │ │ │ └── stream.js │ │ ├── auth.js │ │ ├── auth │ │ │ ├── login.js │ │ │ ├── logout.js │ │ │ ├── refresh.js │ │ │ └── verify.js │ │ ├── embed │ │ │ ├── ads.js │ │ │ ├── check.js │ │ │ └── index.js │ │ ├── ext │ │ │ ├── download.js │ │ │ └── magnet.js │ │ ├── index.js │ │ ├── layout.js │ │ ├── nav.js │ │ ├── resource │ │ │ └── get.js │ │ ├── support.js │ │ └── tests │ │ │ └── progress_log.js │ ├── dev │ │ └── .gitkeep │ └── lib │ │ ├── ads.js │ │ ├── async.js │ │ ├── asyncView.js │ │ ├── av.js │ │ ├── debug.js │ │ ├── drop.js │ │ ├── executeScriptElements.js │ │ ├── loadAsyncView.js │ │ ├── mediaelement-plugins │ │ ├── advancedtracks.js │ │ ├── availableprogress.js │ │ ├── chromecast.js │ │ ├── chromecast │ │ │ └── player.js │ │ ├── embed.js │ │ └── logo.js │ │ ├── mediaelement.js │ │ ├── message.js │ │ ├── progressLog.js │ │ ├── supertokens.js │ │ ├── themeSelector.js │ │ └── umami.js │ └── styles │ ├── baskerville.css │ ├── embed.css │ ├── mediaelement.css │ └── style.css ├── common.go ├── configure.go ├── enrich.go ├── go.mod ├── go.sum ├── handlers ├── action │ ├── handler.go │ └── helper.go ├── auth │ └── handler.go ├── donate │ └── handler.go ├── embed │ ├── example │ │ └── handler.go │ ├── get.go │ ├── handler.go │ └── post.go ├── ext │ └── handler.go ├── geo │ └── handler.go ├── index │ └── handler.go ├── job │ ├── action.go │ ├── embed.go │ ├── enrich.go │ ├── handler.go │ ├── load.go │ ├── log.go │ └── script │ │ ├── action.go │ │ ├── embed.go │ │ ├── enrich.go │ │ └── load.go ├── legal │ └── handler.go ├── library │ ├── add.go │ ├── handler.go │ ├── helpers │ │ ├── menu.go │ │ ├── sort.go │ │ ├── stars.go │ │ └── video_content.go │ ├── index.go │ ├── poster.go │ ├── remove.go │ └── shared │ │ ├── args.go │ │ └── section.go ├── migration │ └── handler.go ├── profile │ └── handler.go ├── resource │ ├── get.go │ ├── handler.go │ ├── helper.go │ └── post.go ├── session │ └── handler.go ├── static │ └── handler.go ├── support │ └── handler.go └── tests │ └── handler.go ├── main.go ├── migrations ├── 1_embed_domain.down.sql ├── 1_embed_domain.up.sql ├── 2_normalize_users.down.sql ├── 2_normalize_users.up.sql ├── 3_create_library.down.sql ├── 3_create_library.up.sql ├── 4_create_torrent_resource.down.sql ├── 4_create_torrent_resource.up.sql ├── 5_create_media_info.down.sql ├── 5_create_media_info.up.sql ├── 6_create_media_structures.down.sql ├── 6_create_media_structures.up.sql ├── 7_create_omdb_info.down.sql ├── 7_create_omdb_info.up.sql ├── 8_create_kinopoisk_unofficial_info.down.sql └── 8_create_kinopoisk_unofficial_info.up.sql ├── models ├── embed_domain.go ├── embed_settings.go ├── episode.go ├── kinopoisk_unofficial │ ├── info.go │ └── query.go ├── library.go ├── media_info.go ├── movie.go ├── movie_metadata.go ├── omdb │ ├── info.go │ └── query.go ├── series.go ├── series_metadata.go ├── stream_settings.go ├── torrent_resource.go ├── user.go ├── video_content.go ├── video_metadata.go └── video_stream.go ├── package-lock.json ├── package.json ├── postcss.config.js ├── pub ├── Sintel.jpg ├── Sintel.ru.srt └── Sintel.torrent ├── qodana.yaml ├── serve.go ├── services ├── abuse_store │ └── client.go ├── api │ └── api.go ├── auth │ └── auth.go ├── claims │ ├── claims.go │ └── client.go ├── common.go ├── embed │ └── domain_settings.go ├── enrich │ ├── enrich.go │ ├── kinopoisk_unofficial.go │ └── omdb.go ├── geoip │ ├── api.go │ └── helper.go ├── job │ ├── job.go │ ├── redis.go │ └── storage.go ├── kinopoisk_unofficial │ └── api.go ├── obfuscator │ ├── helpers.go │ └── obfuscator.go ├── omdb │ └── api.go ├── parse_torrent_name │ ├── field_type.go │ ├── main.go │ ├── main_test.go │ ├── matcher.go │ ├── parser.go │ ├── testdata │ │ ├── golden_file_000.json │ │ ├── golden_file_001.json │ │ ├── golden_file_002.json │ │ ├── golden_file_003.json │ │ ├── golden_file_004.json │ │ ├── golden_file_005.json │ │ ├── golden_file_006.json │ │ ├── golden_file_007.json │ │ ├── golden_file_008.json │ │ ├── golden_file_009.json │ │ ├── golden_file_010.json │ │ ├── golden_file_011.json │ │ ├── golden_file_012.json │ │ ├── golden_file_013.json │ │ ├── golden_file_014.json │ │ ├── golden_file_015.json │ │ ├── golden_file_016.json │ │ ├── golden_file_017.json │ │ ├── golden_file_018.json │ │ ├── golden_file_019.json │ │ ├── golden_file_020.json │ │ ├── golden_file_021.json │ │ ├── golden_file_022.json │ │ ├── golden_file_023.json │ │ ├── golden_file_024.json │ │ ├── golden_file_025.json │ │ ├── golden_file_026.json │ │ ├── golden_file_027.json │ │ ├── golden_file_028.json │ │ ├── golden_file_029.json │ │ ├── golden_file_030.json │ │ ├── golden_file_031.json │ │ ├── golden_file_032.json │ │ ├── golden_file_033.json │ │ ├── golden_file_034.json │ │ ├── golden_file_035.json │ │ ├── golden_file_036.json │ │ ├── golden_file_037.json │ │ ├── golden_file_038.json │ │ ├── golden_file_039.json │ │ ├── golden_file_040.json │ │ ├── golden_file_041.json │ │ ├── golden_file_042.json │ │ ├── golden_file_043.json │ │ ├── golden_file_044.json │ │ ├── golden_file_045.json │ │ ├── golden_file_046.json │ │ ├── golden_file_047.json │ │ ├── golden_file_048.json │ │ ├── golden_file_049.json │ │ ├── golden_file_050.json │ │ ├── golden_file_051.json │ │ ├── golden_file_052.json │ │ ├── golden_file_053.json │ │ ├── golden_file_054.json │ │ ├── golden_file_055.json │ │ ├── golden_file_056.json │ │ ├── golden_file_057.json │ │ ├── golden_file_058.json │ │ ├── golden_file_059.json │ │ ├── golden_file_060.json │ │ ├── golden_file_061.json │ │ ├── golden_file_062.json │ │ ├── golden_file_063.json │ │ ├── golden_file_064.json │ │ ├── golden_file_065.json │ │ ├── golden_file_066.json │ │ ├── golden_file_067.json │ │ ├── golden_file_068.json │ │ ├── golden_file_069.json │ │ ├── golden_file_070.json │ │ ├── golden_file_071.json │ │ ├── golden_file_072.json │ │ ├── golden_file_073.json │ │ ├── golden_file_074.json │ │ ├── golden_file_075.json │ │ ├── golden_file_076.json │ │ ├── golden_file_077.json │ │ ├── golden_file_078.json │ │ ├── golden_file_079.json │ │ ├── golden_file_080.json │ │ ├── golden_file_081.json │ │ ├── golden_file_082.json │ │ ├── golden_file_083.json │ │ ├── golden_file_084.json │ │ ├── golden_file_085.json │ │ ├── golden_file_086.json │ │ ├── golden_file_087.json │ │ ├── golden_file_088.json │ │ ├── golden_file_089.json │ │ └── golden_file_090.json │ ├── torrent_info.go │ └── transformer.go ├── template │ └── template.go ├── umami │ └── helper.go └── web │ ├── context.go │ ├── helper.go │ └── web.go ├── tailwind.config.js ├── templates ├── layouts │ ├── embed │ │ └── example.html │ └── main.html ├── partials │ ├── about.html │ ├── button.html │ ├── extend_base.html │ ├── file.html │ ├── footer.html │ ├── icons.html │ ├── library │ │ ├── button.html │ │ ├── list.html │ │ ├── menu.html │ │ ├── sort.html │ │ ├── stars.html │ │ ├── torrent_list.html │ │ └── video_list.html │ ├── list.html │ ├── nav.html │ └── stream_button.html └── views │ ├── action │ ├── download_file.html │ ├── download_torrent.html │ ├── post.html │ ├── preview_image.html │ ├── stream_audio.html │ └── stream_video.html │ ├── auth │ ├── login.html │ ├── logout.html │ ├── refresh.html │ └── verify.html │ ├── embed │ ├── ads.html │ ├── example │ │ ├── basic.html │ │ ├── events.html │ │ ├── features.html │ │ ├── fixed_size.html │ │ ├── fixed_size2.html │ │ ├── fixed_size3.html │ │ ├── imdb.html │ │ ├── index.html │ │ ├── nocontrols.html │ │ ├── path.html │ │ ├── poster.html │ │ ├── pwd.html │ │ ├── pwd_with_file.html │ │ ├── responsive.html │ │ ├── responsive2.html │ │ ├── script.html │ │ ├── subtitles.html │ │ ├── torrent_url.html │ │ └── user_lang.html │ ├── get.html │ └── post.html │ ├── ext │ ├── download.html │ └── magnet.html │ ├── index.html │ ├── legal │ └── dmca.html │ ├── library │ └── index.html │ ├── profile │ └── get.html │ ├── resource │ └── get.html │ ├── support │ ├── form.html │ └── success.html │ └── tests │ ├── progress_log.html │ └── theme_switcher.html └── webpack.config.js /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = ['s', '--assets-host', 'http://localhost:8082'] 7 | bin = ";SUPERTOKENS_DEBUG=1 GIN_MODE=debug ./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | full_bin = "dlv exec ./tmp/main --listen=127.0.0.1:2345 --headless=true --api-version=2 --accept-multiclient --continue --log -- " 10 | delay = 1000 11 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] 12 | exclude_file = [] 13 | exclude_regex = ["_test.go"] 14 | exclude_unchanged = false 15 | follow_symlink = false 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | kill_delay = "0s" 19 | log = "build-errors.log" 20 | send_interrupt = false 21 | stop_on_error = true 22 | 23 | [color] 24 | app = "" 25 | build = "yellow" 26 | main = "magenta" 27 | runner = "green" 28 | watcher = "cyan" 29 | 30 | [log] 31 | time = false 32 | 33 | [misc] 34 | clean_on_exit = false 35 | 36 | [screen] 37 | clear_on_rebuild = false 38 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.25% 2 | not dead 3 | not op_mini all -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .vscode 4 | .dockerignore 5 | *Dockerfile* 6 | up.sh 7 | README.md 8 | Makefile 9 | LICENSE 10 | web-ui 11 | __debug_bin 12 | .DS_Store 13 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // "extends": ["google", "plugin:vue/recommended"], 3 | "extends": ["google"], 4 | "parser": "babel-eslint", 5 | "parserOptions": { 6 | "sourceType": "module", 7 | "ecmaVersion": 8, 8 | "ecmaFeatures": { 9 | "experimentalObjectRestSpread": true 10 | } 11 | }, 12 | "rules": { 13 | "max-len": [2, 80, 4, {"ignoreUrls": true, "ignoreStrings": true, 14 | "ignoreComments": true, "ignoreTemplateLiterals": true}], 15 | "require-jsdoc": "off", 16 | "no-invalid-this": "off", 17 | "chai-expect/missing-assertion": 2, 18 | "chai-expect/terminating-properties": 1, 19 | "mocha/no-exclusive-tests": 2 20 | }, 21 | "plugins": [ 22 | "eslint-plugin-html", 23 | "mocha", 24 | "chai-expect", 25 | ] 26 | }; 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: pavel_tatarskiy 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - 'main' 6 | tags: 7 | - 'v*' 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | docker: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - 17 | name: Checkout 18 | uses: actions/checkout@v4 19 | - 20 | name: Docker meta 21 | id: meta 22 | uses: docker/metadata-action@v5 23 | with: 24 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 25 | tags: | 26 | type=ref,event=branch 27 | type=ref,event=pr 28 | type=semver,pattern={{version}} 29 | type=semver,pattern={{major}}.{{minor}} 30 | type=sha 31 | - 32 | name: Login to DockerHub 33 | if: github.event_name != 'pull_request' 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | - 40 | name: Build and push 41 | uses: docker/build-push-action@v6 42 | with: 43 | context: . 44 | push: ${{ github.event_name != 'pull_request' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | *.db 14 | 15 | tmp 16 | 17 | .DS_Store 18 | __debug* 19 | web-ui 20 | web-ui 21 | 22 | node_modules 23 | 24 | assets/dist/* 25 | 26 | .env 27 | .self-hosted.env 28 | 29 | templates/partials/extend.html 30 | .idea 31 | .vscode 32 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "at-rule-no-unknown": [ 5 | true, 6 | { 7 | "ignoreAtRules": [ 8 | "extends", 9 | "apply", 10 | "tailwind", 11 | "components", 12 | "utilities", 13 | "screen" 14 | ] 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest as certs 2 | 3 | # getting certs 4 | RUN apk update && apk upgrade && apk add --no-cache ca-certificates 5 | 6 | FROM node:22 as build_assets 7 | 8 | WORKDIR /app 9 | 10 | COPY . . 11 | 12 | RUN npm install 13 | 14 | RUN npm run build 15 | 16 | FROM golang:latest as build 17 | 18 | # set work dir 19 | WORKDIR /app 20 | 21 | # copy the source files 22 | COPY . . 23 | 24 | # disable crosscompiling 25 | ENV CGO_ENABLED=0 26 | 27 | # compile linux only 28 | ENV GOOS=linux 29 | 30 | # build the binary with debug information removed 31 | RUN go build -ldflags '-w -s -X google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=ignore' -a -installsuffix cgo -o server 32 | 33 | FROM alpine:latest 34 | 35 | # set work dir 36 | WORKDIR /app 37 | 38 | # copy our static linked library 39 | COPY --from=build /app/server . 40 | # copy templates 41 | COPY --from=build /app/templates ./templates 42 | # copy migrations 43 | COPY --from=build /app/migrations ./migrations 44 | # copy pub 45 | COPY --from=build /app/pub ./pub 46 | # copy assets 47 | COPY --from=build_assets /app/assets/dist ./assets/dist 48 | 49 | # copy certs 50 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 51 | 52 | # tell we are exposing our service on ports 8080 8081 53 | EXPOSE 8080 8081 54 | 55 | ENV GIN_MODE=release 56 | 57 | # run it! 58 | CMD ["./server", "serve"] 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 webtor.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | npm run build \ 3 | && go build . 4 | 5 | run: 6 | ./web-ui s 7 | 8 | forward-ports: 9 | kubefwd svc -n webtor -l "app.kubernetes.io/name in (claims-provider, supertokens, rest-api, abuse-store)" -------------------------------------------------------------------------------- /assets/src/images/logo-lofi.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/src/images/logo-night.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/src/images/logo.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /assets/src/js/app/action.js: -------------------------------------------------------------------------------- 1 | import av from '../lib/av'; 2 | av(async function() { 3 | const self = this; 4 | const progress = self.querySelector('form'); 5 | const el = document.createElement('div'); 6 | const initProgressLog = (await import('../lib/progressLog')).initProgressLog; 7 | initProgressLog(progress, function(ev) { 8 | if (ev.level !== 'rendertemplate') return; 9 | window.addEventListener('player_ready', function() { 10 | progress.classList.add('hidden'); 11 | el.classList.remove('hidden'); 12 | }, {once: true}); 13 | el.classList.add('hidden'); 14 | el.classList.add('mb-5') 15 | self.appendChild(el); 16 | ev.render(el); 17 | }); 18 | }); 19 | 20 | export {} -------------------------------------------------------------------------------- /assets/src/js/app/action/image.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | 3 | av(() => { 4 | const event = new CustomEvent('player_ready'); 5 | window.dispatchEvent(event); 6 | }); 7 | 8 | export {} -------------------------------------------------------------------------------- /assets/src/js/app/action/stream.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | 3 | av(async function() { 4 | const initPlayer = (await import('../../lib/mediaelement')).initPlayer; 5 | initPlayer(this); 6 | }, async function() { 7 | const destroyPlayer = (await import('../../lib/mediaelement')).destroyPlayer; 8 | destroyPlayer(); 9 | }); 10 | 11 | export {} -------------------------------------------------------------------------------- /assets/src/js/app/auth.js: -------------------------------------------------------------------------------- 1 | import {init} from '../lib/supertokens'; 2 | try { 3 | await init(window._CSRF); 4 | } catch (err) { 5 | console.log(err); 6 | } 7 | 8 | export {} 9 | -------------------------------------------------------------------------------- /assets/src/js/app/auth/login.js: -------------------------------------------------------------------------------- 1 | window.submitLoginForm = function(target, e) { 2 | (async (data) => { 3 | const initProgressLog = (await import('../../lib/progressLog')).initProgressLog; 4 | const pl = initProgressLog(document.querySelector('.progress-alert')); 5 | pl.clear(); 6 | const e = pl.inProgress('login','sending magic link to ' + data.email); 7 | const supertokens = (await import('../../lib/supertokens')); 8 | try { 9 | await supertokens.sendMagicLink(data, window._CSRF); 10 | e.done('magic link sent to ' + data.email); 11 | } catch (err) { 12 | console.log(err); 13 | if (err.statusText) { 14 | e.error(err.statusText.toLowerCase()); 15 | } else if (err.message) { 16 | e.error(err.message.toLowerCase()); 17 | } else { 18 | e.error('unknown error'); 19 | } 20 | } 21 | e.close(); 22 | })({ 23 | email: target.querySelector('input[name=email]').value, 24 | }); 25 | e.preventDefault(); 26 | return false; 27 | } 28 | -------------------------------------------------------------------------------- /assets/src/js/app/auth/logout.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | av( async function() { 3 | const initProgressLog = (await import('../../lib/progressLog')).initProgressLog; 4 | const pl = initProgressLog(this.querySelector('.progress-alert')); 5 | pl.clear(); 6 | const e = pl.inProgress('logout', 'logging out'); 7 | const supertokens = (await import('../../lib/supertokens')); 8 | try { 9 | await supertokens.logout(window._CSRF); 10 | e.done('logout successful'); 11 | window.dispatchEvent(new CustomEvent('auth')); 12 | } catch (err) { 13 | console.log(err); 14 | if (err.statusText) { 15 | e.error(err.statusText.toLowerCase()); 16 | } else if (err.message) { 17 | e.error(err.message.toLowerCase()); 18 | } else { 19 | e.error('unknown error'); 20 | } 21 | } 22 | e.close(); 23 | }); 24 | 25 | export {} 26 | -------------------------------------------------------------------------------- /assets/src/js/app/auth/refresh.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | av(async function() { 3 | const {refresh} = (await import('../../lib/supertokens')); 4 | try { 5 | await refresh(window._CSRF); 6 | window.location.replace(window.location.href); 7 | } catch (err) { 8 | console.log(err); 9 | window.location = '/login'; 10 | } 11 | }); 12 | 13 | export {} 14 | -------------------------------------------------------------------------------- /assets/src/js/app/auth/verify.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | av(async function() { 3 | const initProgressLog = (await import('../../lib/progressLog')).initProgressLog; 4 | const pl = initProgressLog(this.querySelector('.progress-alert')); 5 | pl.clear(); 6 | const e = pl.inProgress('verify', 'checking magic link'); 7 | const supertokens = (await import('../../lib/supertokens')); 8 | try { 9 | const res = await supertokens.handleMagicLinkClicked(window._CSRF); 10 | if (res.status === 'OK') { 11 | e.done('login successful'); 12 | window.dispatchEvent(new CustomEvent('auth')); 13 | } else if (res.status === 'RESTART_FLOW_ERROR') { 14 | e.error('magic link expired, try to login again'); 15 | } else { 16 | e.error('login failed, try to login again'); 17 | } 18 | } catch (err) { 19 | if (err.statusText) { 20 | e.error(err.statusText.toLowerCase()); 21 | } else if (err.message) { 22 | e.error(err.message.toLowerCase()); 23 | } else { 24 | e.error('unknown error'); 25 | } 26 | } 27 | e.close(); 28 | }); 29 | 30 | export {} 31 | -------------------------------------------------------------------------------- /assets/src/js/app/embed/ads.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | 3 | av(async function() { 4 | if (window._ads === undefined) return; 5 | const renderAd = (await import('../../lib/ads')).default; 6 | for (const ad of window._ads) { 7 | renderAd(this, ad); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /assets/src/js/app/ext/download.js: -------------------------------------------------------------------------------- 1 | import {makeDebug} from '../../lib/debug'; 2 | import semver from 'semver' 3 | const debug = await makeDebug('webtor:ext'); 4 | 5 | function init() { 6 | return new Promise((resolve) => { 7 | if (window.__webtorInjected) return resolve(); 8 | debug('wait for initialization'); 9 | window.addEventListener('message', (event) => { 10 | if (event.source !== window) 11 | return; 12 | 13 | if (event.data.webtorInjected) return resolve(); 14 | }); 15 | }); 16 | } 17 | function fetch(downloadId) { 18 | debug('request downloadId=%d', downloadId); 19 | return new Promise((resolve) => { 20 | window.addEventListener('message', (event) => { 21 | console.log(event); 22 | if (event.source !== window) { 23 | return; 24 | } 25 | if (event.data.torrent) { 26 | if (event.data.ver && semver.gte('0.1.12', event.data.ver)) { 27 | resolve(new Blob([new Uint8Array(event.data.torrent)])); 28 | return; 29 | } 30 | resolve(new Blob([new Uint8Array(event.data.torrent.data)])); 31 | } 32 | }); 33 | window.postMessage({downloadId}, '*'); 34 | }); 35 | } 36 | function send(data) { 37 | const form = document.createElement('form'); 38 | form.setAttribute('method', 'post'); 39 | form.setAttribute('enctype', 'multipart/form-data'); 40 | form.style.display = 'none'; 41 | const csrf = document.createElement('input'); 42 | csrf.setAttribute('name', '_csrf'); 43 | csrf.setAttribute('value', window._CSRF); 44 | csrf.setAttribute('type', 'hidden'); 45 | form.append(csrf); 46 | const sessionID = document.createElement('input'); 47 | sessionID.setAttribute('name', '_sessionID'); 48 | sessionID.setAttribute('value', window._sessionID); 49 | sessionID.setAttribute('type', 'hidden'); 50 | form.append(sessionID); 51 | const res = document.createElement('input'); 52 | res.setAttribute('name', 'resource'); 53 | res.setAttribute('type', 'file'); 54 | let file = new File([data], 'resource.torrent'); 55 | let container = new DataTransfer(); 56 | container.items.add(file); 57 | res.files = container.files; 58 | form.append(res); 59 | document.body.append(form); 60 | form.setAttribute('action', '/'); 61 | form.submit(); 62 | } 63 | await init(); 64 | const data = await fetch(window._downloadID) 65 | send(data); 66 | -------------------------------------------------------------------------------- /assets/src/js/app/ext/magnet.js: -------------------------------------------------------------------------------- 1 | function send(magnet) { 2 | const form = document.createElement('form'); 3 | form.setAttribute('method', 'post'); 4 | form.setAttribute('enctype', 'multipart/form-data'); 5 | form.style.display = 'none'; 6 | const csrf = document.createElement('input'); 7 | csrf.setAttribute('name', '_csrf'); 8 | csrf.setAttribute('value', window._CSRF); 9 | csrf.setAttribute('type', 'hidden'); 10 | form.append(csrf); 11 | const sessionID = document.createElement('input'); 12 | sessionID.setAttribute('name', '_sessionID'); 13 | sessionID.setAttribute('value', window._sessionID); 14 | sessionID.setAttribute('type', 'hidden'); 15 | form.append(sessionID); 16 | const res = document.createElement('input'); 17 | res.setAttribute('name', 'resource'); 18 | res.setAttribute('value', magnet); 19 | res.setAttribute('type', 'hidden'); 20 | form.append(res); 21 | document.body.append(form); 22 | form.setAttribute('action', '/'); 23 | form.submit(); 24 | } 25 | send(window._magnet); 26 | -------------------------------------------------------------------------------- /assets/src/js/app/index.js: -------------------------------------------------------------------------------- 1 | import av from '../lib/av'; 2 | av(async function() { 3 | const dropzone = this.querySelector('.dropzone'); 4 | if (dropzone) { 5 | const initDrop = (await import('../lib/drop')).initDrop; 6 | initDrop(dropzone); 7 | } 8 | const progress = this.querySelector('.progress-alert'); 9 | if (progress != null) { 10 | const initProgressLog = (await import('../lib/progressLog')).initProgressLog; 11 | initProgressLog(progress); 12 | } 13 | }); 14 | 15 | export {} -------------------------------------------------------------------------------- /assets/src/js/app/layout.js: -------------------------------------------------------------------------------- 1 | function showProgress() { 2 | const progress = document.getElementById('progress'); 3 | progress.classList.remove('hidden'); 4 | } 5 | function hideProgress() { 6 | const progress = document.getElementById('progress'); 7 | progress.classList.add('hidden'); 8 | } 9 | 10 | if (window._umami) { 11 | const umami = (await import('../lib/umami')).init(window, window._umami); 12 | window.umami = umami; 13 | 14 | } 15 | 16 | window.progress = { 17 | show: showProgress, 18 | hide: hideProgress, 19 | }; 20 | 21 | import {bindAsync} from '../lib/async'; 22 | import initAsyncView from '../lib/asyncView'; 23 | 24 | const initTheme = (await import('../lib/themeSelector')).initTheme; 25 | 26 | initTheme(document.querySelector('[data-toggle-theme]')); 27 | document.body.style.display = 'flex'; 28 | hideProgress(); 29 | bindAsync({ 30 | async fetch(f, url, fetchParams) { 31 | showProgress(); 32 | fetchParams.headers['X-CSRF-TOKEN'] = window._CSRF; 33 | fetchParams.headers['X-SESSION-ID'] = window._sessionID; 34 | const res = await fetch(url, fetchParams); 35 | hideProgress(); 36 | return res; 37 | }, 38 | update(key, val) { 39 | if (key === 'title') document.querySelector('title').innerText = val; 40 | }, 41 | fallback: { 42 | selector: 'main', 43 | layout: '{{ template "main" . }}', 44 | }, 45 | }); 46 | initAsyncView(); -------------------------------------------------------------------------------- /assets/src/js/app/nav.js: -------------------------------------------------------------------------------- 1 | import av from '../lib/av'; 2 | av(async function() { 3 | if (window.umami && window._tier !== 'free') { 4 | window.umami.identify({ 5 | tier: window._tier, 6 | }); 7 | } 8 | const self = this; 9 | const themeSelector = (await import('../lib/themeSelector')).themeSelector; 10 | themeSelector(this.querySelector('[data-toggle-theme]')); 11 | window.addEventListener('auth', function() { 12 | self.reload(); 13 | }, { once: true }); 14 | }); 15 | 16 | export {} 17 | 18 | -------------------------------------------------------------------------------- /assets/src/js/app/resource/get.js: -------------------------------------------------------------------------------- 1 | import av from '../../lib/av'; 2 | av( async function() { 3 | if (window._ads !== undefined && window._sessionExpired !== true) { 4 | const renderAd = (await import('../../lib/ads')).default; 5 | for (const ad of window._ads) { 6 | renderAd(this, ad); 7 | } 8 | } 9 | const query = window.location.hash.replace('#', ''); 10 | const urlParams = new URLSearchParams(query); 11 | const action = urlParams.get('action'); 12 | const modal = urlParams.get('modal'); 13 | const purge = urlParams.get('purge'); 14 | if (!action) return; 15 | const form = document.querySelector('form.' + action); 16 | if (purge) { 17 | const purgeInput = document.createElement('input'); 18 | purgeInput.setAttribute('type', 'hidden'); 19 | purgeInput.setAttribute('name', 'purge'); 20 | purgeInput.setAttribute('value', 'true'); 21 | form.appendChild(purgeInput); 22 | } 23 | form.requestSubmit(); 24 | if (modal) { 25 | window.addEventListener('player_ready', function () { 26 | if (!modal) return; 27 | const checkbox = document.getElementById(modal + '-checkbox'); 28 | checkbox.checked = true; 29 | }); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /assets/src/js/app/support.js: -------------------------------------------------------------------------------- 1 | import av from '../lib/av'; 2 | 3 | function setRequied(input) { 4 | if (input.getAttribute('data-required') !== null) { 5 | input.setAttribute('required', 'required'); 6 | } 7 | } 8 | 9 | function updateForm(select, inputs, submit) { 10 | if (select.value === '-1') { 11 | for (const i of inputs) i.classList.add('hidden'); 12 | submit.classList.add('hidden'); 13 | } else { 14 | for (const i of inputs) { 15 | const ds = i.getAttribute('data-select'); 16 | if (!ds) { 17 | i.classList.remove('hidden'); 18 | setRequied(i); 19 | } else if (ds.split(',').includes(select.value)) { 20 | i.classList.remove('hidden'); 21 | setRequied(i); 22 | } else { 23 | i.classList.add('hidden'); 24 | i.removeAttribute('required'); 25 | } 26 | } 27 | submit.classList.remove('hidden'); 28 | } 29 | } 30 | 31 | av(async function() { 32 | const form = this.querySelector('form'); 33 | const select = form.querySelector('select'); 34 | const inputs = form.querySelectorAll('input, textarea'); 35 | const submit = form.querySelector('button'); 36 | updateForm(select, inputs, submit); 37 | select.addEventListener('change', () => { 38 | updateForm(select, inputs, submit); 39 | }); 40 | }); 41 | 42 | export {} -------------------------------------------------------------------------------- /assets/src/js/app/tests/progress_log.js: -------------------------------------------------------------------------------- 1 | import {initProgressLog} from "../../lib/progressLog"; 2 | 3 | initProgressLog(document.querySelector('.test-info')) 4 | .info('some info message') 5 | .info('some another info message') 6 | .close(); 7 | 8 | initProgressLog(document.querySelector('.test-progress')) 9 | .inProgress('done', 'something very very very long is done').done() 10 | .inProgress('progress', 'something very very very long in progress') 11 | .close(); 12 | 13 | initProgressLog(document.querySelector('.test-status')) 14 | .inProgress('done', 'something very very very long is done') 15 | .updateStatus('test') 16 | .done() 17 | .close(); 18 | 19 | let counter = 1; 20 | const e = initProgressLog(document.querySelector('.test-dynamic-status')) 21 | .inProgress('progress','something with dynamic status in progress'); 22 | 23 | setInterval(() => { 24 | e.updateStatus(counter++); 25 | }, 1000); 26 | 27 | initProgressLog(document.querySelector('.test-done-message')) 28 | .inProgress('done','something very very very long is done') 29 | .done('some done message') 30 | .close(); 31 | 32 | initProgressLog(document.querySelector('.test-warn-message')) 33 | .inProgress('warn', 'something very very very long has warn') 34 | .warn('some warning') 35 | .close(); 36 | 37 | initProgressLog(document.querySelector('.test-error-message')) 38 | .inProgress('error', 'something very very very long has error') 39 | .error('some very very really very long error message') 40 | .close(); 41 | 42 | initProgressLog(document.querySelector('.test-error-message-with-extra')) 43 | .inProgress('error', 'something very very very long has error') 44 | .error('some very very really very long error message') 45 | .close(); 46 | 47 | initProgressLog(document.querySelector('.test-done-with-extra')) 48 | .inProgress('done', 'something very very very long') 49 | .done() 50 | .close(); 51 | 52 | initProgressLog(document.querySelector('.test-oneline')); 53 | 54 | -------------------------------------------------------------------------------- /assets/src/js/dev/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webtor-io/web-ui/bcf56b9191083d04cc904b316488cd13b0029f93/assets/src/js/dev/.gitkeep -------------------------------------------------------------------------------- /assets/src/js/lib/asyncView.js: -------------------------------------------------------------------------------- 1 | import {makeDebug} from './debug'; 2 | const debug = await makeDebug('webtor:embed:message'); 3 | export default function init() { 4 | if (window.av) { 5 | for (const data of window.av) { 6 | initAsyncView(...data); 7 | } 8 | } 9 | window.av = { 10 | push(data) { 11 | initAsyncView(...data); 12 | } 13 | } 14 | } 15 | function initAsyncView(target, init, destroy) { 16 | const scripts = target.getElementsByTagName('script'); 17 | const src = scripts[scripts.length-1].src; 18 | const url = new URL(src); 19 | const name = url.pathname.replace(/\.js$/, ''); 20 | target.setAttribute('data-async-view', name); 21 | const onLoad = function(e) { 22 | debug(`webtor:async view script loaded name=%o`, name); 23 | const target = e.detail.target; 24 | if (!target.reload) { 25 | target.reload = function() { 26 | return new Promise(function(resolve, _) { 27 | target.reloadResolve = resolve; 28 | }) 29 | } 30 | } 31 | init.call(target); 32 | } 33 | const onDestroy = async (e) => { 34 | debug(`webtor:async view script destroyed name=%o`, name); 35 | const event = new CustomEvent(`async:${name}_destroyed`); 36 | if (destroy) { 37 | let target = document; 38 | if (e && e.detail && e.detail.target) { 39 | target = e.detail.target; 40 | } 41 | await destroy.call(target); 42 | } 43 | window.dispatchEvent(event); 44 | } 45 | let key = `__async${name}_loaded`; 46 | if (!window[key]) { 47 | window.addEventListener(`async:${name}`, onLoad); 48 | window.addEventListener(`async:${name}_destroy`, onDestroy); 49 | window[key] = true; 50 | onLoad({ 51 | detail: { 52 | target, 53 | }, 54 | }); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /assets/src/js/lib/av.js: -------------------------------------------------------------------------------- 1 | export default function (init, destroy = null) { 2 | const target = document.currentScript.parentElement; 3 | window.av = window.av || []; 4 | window.av.push([target, init, destroy]); 5 | } -------------------------------------------------------------------------------- /assets/src/js/lib/debug.js: -------------------------------------------------------------------------------- 1 | export async function makeDebug(name) { 2 | if (localStorage.debug) { 3 | const makeDebug = (await import('debug')).default; 4 | return makeDebug(name); 5 | } else { 6 | return function() {}; 7 | } 8 | } -------------------------------------------------------------------------------- /assets/src/js/lib/drop.js: -------------------------------------------------------------------------------- 1 | export function initDrop(dropzone) { 2 | const dropzoneInput = dropzone.querySelector('.dropzone-input'); 3 | 4 | dropzone.addEventListener('click', function(e) { 5 | dropzoneInput.click(); 6 | }); 7 | 8 | ['drag', 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'drop'].forEach(function(event) { 9 | document.addEventListener(event, function(e) { 10 | e.preventDefault(); 11 | e.stopPropagation(); 12 | }); 13 | }); 14 | 15 | document.addEventListener('drop', function(e) { 16 | const dt = e.dataTransfer; 17 | dropzoneInput.files = dt.files; 18 | dropzone.requestSubmit(); 19 | }); 20 | 21 | dropzoneInput.addEventListener('change', function(e) { 22 | dropzone.requestSubmit(); 23 | }); 24 | } -------------------------------------------------------------------------------- /assets/src/js/lib/executeScriptElements.js: -------------------------------------------------------------------------------- 1 | const invokedScripts = {}; 2 | let loaded = false; 3 | function getScriptName(script) { 4 | const src = script.getAttribute('src'); 5 | if (src) { 6 | return src; 7 | } 8 | return; 9 | } 10 | // https://stackoverflow.com/a/69190644 11 | function executeScriptElements(containerElement) { 12 | const scriptElements = containerElement.querySelectorAll('script'); 13 | 14 | Array.from(scriptElements).forEach((scriptElement) => { 15 | const name = getScriptName(scriptElement); 16 | if (name) { 17 | if (invokedScripts[name]) { 18 | return; 19 | } else { 20 | invokedScripts[name] = true; 21 | } 22 | } 23 | 24 | const clonedElement = document.createElement('script'); 25 | clonedElement.async = false; 26 | 27 | Array.from(scriptElement.attributes).forEach((attribute) => { 28 | clonedElement.setAttribute(attribute.name, attribute.value); 29 | }); 30 | 31 | clonedElement.text = scriptElement.text; 32 | 33 | scriptElement.parentNode.replaceChild(clonedElement, scriptElement); 34 | }); 35 | } 36 | 37 | if (!loaded) { 38 | loaded = true; 39 | window.addEventListener('load', (event) => { 40 | const scripts = document.querySelectorAll('script'); 41 | for (const s of scripts) { 42 | const name = getScriptName(s); 43 | if (!name) continue; 44 | invokedScripts[name] = true; 45 | } 46 | }); 47 | } 48 | 49 | export default executeScriptElements; 50 | -------------------------------------------------------------------------------- /assets/src/js/lib/loadAsyncView.js: -------------------------------------------------------------------------------- 1 | import executeScriptElements from "./executeScriptElements"; 2 | function loadAsyncView(target, body) { 3 | const els = target.querySelectorAll('[data-async-view]'); 4 | for (const el of els) { 5 | const view = el.getAttribute('data-async-view'); 6 | const detail = { 7 | target: el, 8 | }; 9 | const event = new CustomEvent(`async:${view}_destroy`, { detail }); 10 | window.dispatchEvent(event); 11 | } 12 | renderBody(target, body); 13 | } 14 | function renderBody(target, body) { 15 | target.innerHTML = body; 16 | executeScriptElements(target); 17 | const detail = { 18 | target, 19 | }; 20 | // Update async elements 21 | const event = new CustomEvent('async', { detail }); 22 | window.dispatchEvent(event); 23 | 24 | const scripts = target.getElementsByTagName('script'); 25 | for (const script of scripts) { 26 | if (script.src === "") continue; 27 | const url = new URL(script.src); 28 | const name = url.pathname.replace(/\.js$/, ''); 29 | const event = new CustomEvent('async:' + name, { detail }); 30 | window.dispatchEvent(event); 31 | } 32 | 33 | // Process async views 34 | const yOffset = -100; 35 | const y = target.getBoundingClientRect().top + window.scrollY + yOffset; 36 | 37 | window.scrollTo({ top: y }); 38 | } 39 | 40 | export default loadAsyncView; -------------------------------------------------------------------------------- /assets/src/js/lib/mediaelement-plugins/availableprogress.js: -------------------------------------------------------------------------------- 1 | Object.assign(MediaElementPlayer.prototype, { 2 | buildavailableprogress(player, controls, layers, media) { 3 | const slider = player.slider; 4 | const el = document.createElement('span'); 5 | el.classList.add(this.options.classPrefix + 'available-progress'); 6 | el.classList.add('bg-accent'); 7 | slider.appendChild(el); 8 | const cb = function() { 9 | const a = media.oldGetDuration(); 10 | const t = media.getDuration(); 11 | if (a > 0 && t > 0) { 12 | const progress = a/t; 13 | if (progress >= 1) { 14 | el.style.display = 'none'; 15 | } else { 16 | el.style.transform = `scaleX(${progress})`; 17 | } 18 | } 19 | } 20 | media.addEventListener('timeupdate', cb); 21 | }, 22 | }) -------------------------------------------------------------------------------- /assets/src/js/lib/mediaelement-plugins/embed.js: -------------------------------------------------------------------------------- 1 | Object.assign(MediaElementPlayer.prototype, { 2 | async buildembed(player, controls, layers) { 3 | player.embedButton = document.createElement('div'); 4 | player.embedButton.className = `${this.options.classPrefix}button ${this.options.classPrefix}embed-button`; 5 | player.embedButton.innerHTML = 6 | ``; 7 | this.addControlElement(player.embedButton, 'embed'); 8 | player.embedLayer = document.createElement('div'); 9 | player.embedLayer.className = `${this.options.classPrefix}layer ${this.options.classPrefix}overlay ${this.options.classPrefix}webtor-embed`; 10 | const embedContainer = document.getElementById('embed').cloneNode(true); 11 | const checkbox = document.getElementById('embed-checkbox').cloneNode(true); 12 | player.embedLayer.appendChild(checkbox); 13 | player.embedLayer.appendChild(embedContainer); 14 | const playLayer = layers.querySelector(`.${this.options.classPrefix}overlay-play`); 15 | layers.insertBefore(player.embedLayer, playLayer); 16 | const t = (e) => { 17 | e.preventDefault(); 18 | checkbox.checked = !checkbox.checked; 19 | if (checkbox.checked) { 20 | player.embedLayer.style.display = 'block'; 21 | } else { 22 | player.embedLayer.style.display = 'none'; 23 | } 24 | return false; 25 | } 26 | 27 | for (const cl of embedContainer.querySelectorAll('label[for=embed-checkbox]')) { 28 | cl.addEventListener('click', t); 29 | } 30 | const copy = embedContainer.querySelector('label.copy'); 31 | copy.addEventListener('click', () => { 32 | const code = embedContainer.querySelector('textarea').value; 33 | navigator.clipboard.writeText(code); 34 | }); 35 | player.embedLayer.style.display = 'none'; 36 | player.embedLayer.style.zIndex = 1000; 37 | player.embedButton.addEventListener('click', t); 38 | }, 39 | }) -------------------------------------------------------------------------------- /assets/src/js/lib/mediaelement-plugins/logo.js: -------------------------------------------------------------------------------- 1 | Object.assign(MediaElementPlayer.prototype, { 2 | watchControlsVisible(controls, callback) { 3 | const observer = new MutationObserver((mutations) => { 4 | mutations.forEach((mutation) => { 5 | if (mutation.attributeName === 'class') { 6 | callback(!mutation.target.classList.contains(`${this.options.classPrefix}offscreen`)); 7 | } 8 | }); 9 | }); 10 | observer.observe(controls, { 11 | attributes: true, 12 | }); 13 | }, 14 | async buildlogo(player, controls, layers) { 15 | const logoLayer = document.createElement('div'); 16 | layers.logoLayer = logoLayer; 17 | logoLayer.className = `${this.options.classPrefix}layer ${this.options.classPrefix}webtor-logo`; 18 | const logo = document.getElementById('logo').cloneNode(true); 19 | logo.classList.remove('hidden'); 20 | logoLayer.appendChild(logo); 21 | layers.appendChild(logoLayer); 22 | this.watchControlsVisible(controls, (visible) => { 23 | if (visible) { 24 | logoLayer.classList.remove('hidden'); 25 | } else { 26 | logoLayer.classList.add('hidden'); 27 | } 28 | }); 29 | } 30 | }); -------------------------------------------------------------------------------- /assets/src/js/lib/message.js: -------------------------------------------------------------------------------- 1 | import {makeDebug} from './debug'; 2 | const debug = await makeDebug('webtor:embed:message'); 3 | function inIframe() { 4 | try { 5 | return window.self !== window.top; 6 | } catch (e) { 7 | return true; 8 | } 9 | } 10 | const id = window._id; 11 | debug('using message id=%o', id); 12 | const message = { 13 | id() { 14 | return id; 15 | }, 16 | send(m, data = {}) { 17 | if (!inIframe) return; 18 | if (!id) { 19 | m = 'webtor: ' + m; 20 | } else { 21 | m = { 22 | id, 23 | name: m, 24 | data, 25 | }; 26 | } 27 | debug('post message=%o data=%o', m, data); 28 | window.parent.postMessage(m, '*'); 29 | }, 30 | receiveOnce(name) { 31 | return new Promise((resolve, reject) => { 32 | const func = (event) => { 33 | const d = event.data; 34 | if (!id) { 35 | window.removeEventListener('message', func); 36 | resolve(); 37 | } 38 | if (d.id == id && d.name == name) { 39 | debug('receive message=%o', d); 40 | window.removeEventListener('message', func); 41 | resolve(d.data); 42 | } 43 | } 44 | window.addEventListener('message', func); 45 | }); 46 | }, 47 | receive(name, callback) { 48 | window.addEventListener('message', function(event) { 49 | const d = event.data; 50 | if (d.id == id && d.name == name) { 51 | debug('receive message=%o', d); 52 | callback(d.data); 53 | } 54 | }); 55 | } 56 | } 57 | export default message; -------------------------------------------------------------------------------- /assets/src/js/lib/supertokens.js: -------------------------------------------------------------------------------- 1 | import SuperTokens from 'supertokens-web-js'; 2 | import Session from 'supertokens-web-js/recipe/session'; 3 | import Passwordless, { createCode, consumeCode, signOut } from "supertokens-web-js/recipe/passwordless"; 4 | 5 | function preAPIHook(csrf) { 6 | return function(context) { 7 | let requestInit = context.requestInit; 8 | let url = context.url; 9 | let headers = { 10 | ...requestInit.headers, 11 | 'X-CSRF-TOKEN': csrf, 12 | }; 13 | requestInit = { 14 | ...requestInit, 15 | headers, 16 | } 17 | return { 18 | requestInit, url 19 | }; 20 | } 21 | } 22 | 23 | export async function sendMagicLink({email}, csrf) { 24 | await initSuperTokens(csrf); 25 | return await createCode({ 26 | email, 27 | options: { 28 | preAPIHook: preAPIHook(csrf), 29 | }, 30 | }); 31 | } 32 | 33 | export async function handleMagicLinkClicked(csrf) { 34 | await initSuperTokens(csrf); 35 | return await consumeCode({ 36 | options: { 37 | preAPIHook: preAPIHook(csrf), 38 | }, 39 | }); 40 | } 41 | 42 | export async function logout(csrf) { 43 | await initSuperTokens(csrf); 44 | return await signOut({ 45 | options: { 46 | preAPIHook: preAPIHook(csrf), 47 | }, 48 | }); 49 | } 50 | 51 | export async function refresh(csrf) { 52 | await initSuperTokens(csrf); 53 | return Session.attemptRefreshingSession(); 54 | } 55 | 56 | export async function init(csrf) { 57 | await initSuperTokens(csrf); 58 | } 59 | 60 | let inited = false; 61 | async function initSuperTokens(csrf) { 62 | if (inited) { 63 | return; 64 | } 65 | inited = true; 66 | SuperTokens.init({ 67 | appInfo: { 68 | apiDomain: window._domain, 69 | apiBasePath: '/auth', 70 | appName: 'webtor', 71 | }, 72 | recipeList: [ 73 | Session.init({ 74 | preAPIHook: preAPIHook(csrf), 75 | }), 76 | Passwordless.init(), 77 | ], 78 | }); 79 | 80 | } 81 | -------------------------------------------------------------------------------- /assets/src/js/lib/themeSelector.js: -------------------------------------------------------------------------------- 1 | const storageKey = 'theme'; 2 | function getThemes(themeSelector) { 3 | return themeSelector.getAttribute('data-toggle-theme').split(',').map((t) => t.trim()); 4 | } 5 | function changeFavicons(current) { 6 | const els = document.querySelectorAll('link[rel~="icon"]') 7 | for (const el of els) { 8 | el.href = el.href.replace(/\w+\/favicon/, `${current}/favicon`); 9 | } 10 | } 11 | export function initTheme(themeSelector) { 12 | const [darkTheme, lightTheme] = getThemes(themeSelector); 13 | let currentTheme = window.localStorage.getItem(storageKey); 14 | if (currentTheme === null) { 15 | currentTheme = darkTheme; 16 | if (window.matchMedia && !window.matchMedia('(prefers-color-scheme: dark)')) { 17 | currentTheme = lightTheme; 18 | } 19 | } 20 | if (currentTheme === lightTheme) { 21 | themeSelector.checked = true; 22 | changeFavicons(currentTheme); 23 | } 24 | document.querySelector('html').setAttribute('data-theme', currentTheme); 25 | window.localStorage.setItem(storageKey, currentTheme); 26 | } 27 | export function themeSelector(themeSelector) { 28 | const [darkTheme, lightTheme] = getThemes(themeSelector); 29 | let currentTheme = window.localStorage.getItem(storageKey); 30 | if (currentTheme === lightTheme) themeSelector.checked = true; 31 | themeSelector.addEventListener('change', (e) => { 32 | currentTheme = e.target.checked ? lightTheme : darkTheme; 33 | document.querySelector('html').setAttribute('data-theme', currentTheme); 34 | changeFavicons(currentTheme); 35 | window.localStorage.setItem(storageKey, currentTheme); 36 | }); 37 | } -------------------------------------------------------------------------------- /assets/src/styles/embed.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin 'daisyui' { 4 | themes: night --default; 5 | } 6 | 7 | @config '../../../tailwind.config.js'; 8 | 9 | @utility loading-elipsis { 10 | &::after { 11 | overflow: hidden; 12 | animation: ellipsis steps(4, end) 1500ms infinite; 13 | content: '...'; 14 | width: 0; 15 | display: inline-block; 16 | vertical-align: bottom; 17 | } 18 | } 19 | 20 | @utility popin { 21 | animation: popin 200ms; 22 | } 23 | 24 | @utility progress-alert { 25 | @apply py-4 popin; 26 | position: relative; 27 | color: white; 28 | 29 | pre { 30 | @apply px-5 leading-8 whitespace-pre-wrap flex; 31 | 32 | &::before { 33 | content: '> '; 34 | 35 | @apply shrink-0; 36 | } 37 | 38 | &.error-summary, 39 | &.warn-summary { 40 | @apply px-5 bg-warning text-warning-content; 41 | } 42 | 43 | &.done-summary, 44 | &.download-summary, 45 | &.redirect-summary { 46 | @apply px-5 bg-success text-success-content; 47 | } 48 | 49 | &.inprogress, 50 | &.statusupdate { 51 | span.loader { 52 | @apply pl-1; 53 | @apply loading-elipsis; 54 | } 55 | } 56 | 57 | &.statusupdate { 58 | span.task-status { 59 | @apply pl-1; 60 | 61 | &::before { 62 | content: '('; 63 | } 64 | 65 | &::after { 66 | content: ')'; 67 | } 68 | } 69 | } 70 | 71 | &.done, 72 | &.error, 73 | &.warn { 74 | span.task-status { 75 | @apply pl-1; 76 | 77 | &::before { 78 | content: '...['; 79 | } 80 | 81 | &::after { 82 | content: ']'; 83 | } 84 | } 85 | } 86 | } 87 | 88 | .close { 89 | @apply btn btn-sm btn-accent mr-4; 90 | } 91 | 92 | &.progress-alert-oneline { 93 | @apply flex; 94 | 95 | pre { 96 | @apply flex-grow; 97 | } 98 | } 99 | } 100 | 101 | @layer utilities { 102 | 103 | @keyframes ellipsis { 104 | to { 105 | width: 2.25em; 106 | } 107 | } 108 | 109 | @keyframes popin { 110 | from { 111 | transform: scaleX(0.95); 112 | opacity: 0; 113 | } 114 | 115 | to { 116 | transform: scaleX(1); 117 | opacity: 1; 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /assets/src/styles/mediaelement.css: -------------------------------------------------------------------------------- 1 | @import '../../../node_modules/mediaelement/build/mediaelementplayer.css'; 2 | 3 | .mejs__available-progress { 4 | left: 0; 5 | transform: scaleX(0); 6 | transform-origin: 0 0; 7 | transition: 0.15s ease-in all; 8 | width: 100%; 9 | border-radius: 2px; 10 | cursor: pointer; 11 | display: block; 12 | height: 2px; 13 | position: absolute; 14 | bottom: 0; 15 | } 16 | 17 | .mejs__cast-button { 18 | position: absolute; 19 | top: 4px; 20 | right: 9px; 21 | z-index: 999999999; 22 | cursor: pointer; 23 | } 24 | 25 | .mejs__embed-button button { 26 | background: none; 27 | color: #fff; 28 | font-size: .8rem; 29 | white-space: nowrap; 30 | letter-spacing: .1rem; 31 | transform: scaleY(1.8); 32 | overflow: visible; 33 | position: relative; 34 | left: -.2rem; 35 | .slash { 36 | padding-left: .07rem; 37 | padding-right: .07rem; 38 | position: relative; 39 | bottom: -.04rem; 40 | } 41 | } 42 | 43 | video::-webkit-media-text-track-display { 44 | bottom: 2rem !important; 45 | line-height: 1.5 !important; 46 | top: auto !important; 47 | } 48 | 49 | .mejs__mediaelement { 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | } 54 | -------------------------------------------------------------------------------- /common.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli" 5 | cs "github.com/webtor-io/common-services" 6 | "github.com/webtor-io/web-ui/services/api" 7 | enr "github.com/webtor-io/web-ui/services/enrich" 8 | ku "github.com/webtor-io/web-ui/services/kinopoisk_unofficial" 9 | "github.com/webtor-io/web-ui/services/omdb" 10 | "net/http" 11 | ) 12 | 13 | func configureEnricher(f []cli.Flag) []cli.Flag { 14 | f = omdb.RegisterFlags(f) 15 | f = ku.RegisterFlags(f) 16 | return f 17 | } 18 | 19 | func makeEnricher(c *cli.Context, cl *http.Client, pg *cs.PG, sapi *api.Api) *enr.Enricher { 20 | var mdMappers []enr.MetadataMapper 21 | 22 | // Setting OMDB API 23 | omdbApi := omdb.New(c, cl) 24 | 25 | // Setting OMDB Mapper 26 | om := enr.NewOMDB(pg, omdbApi) 27 | if om != nil { 28 | mdMappers = append(mdMappers, om) 29 | } 30 | 31 | // Setting Kinopoisk Unofficial API 32 | kpuApi := ku.New(c, cl) 33 | 34 | // Setting Kinopoisk Unofficial Mapper 35 | kpu := enr.NewKinopoiskUnofficial(pg, kpuApi) 36 | if kpu != nil { 37 | mdMappers = append(mdMappers, kpu) 38 | } 39 | 40 | // Setting Enricher 41 | return enr.NewEnricher(pg, sapi, mdMappers) 42 | } 43 | -------------------------------------------------------------------------------- /configure.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/urfave/cli" 5 | services "github.com/webtor-io/common-services" 6 | ) 7 | 8 | func configure(app *cli.App) { 9 | serveCMD := makeServeCMD() 10 | migrationCMD := services.MakePGMigrationCMD() 11 | enrichCMD := makeEnrichCMD() 12 | app.Commands = []cli.Command{serveCMD, migrationCMD, enrichCMD} 13 | } 14 | -------------------------------------------------------------------------------- /handlers/auth/handler.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/services/auth" 5 | "github.com/webtor-io/web-ui/services/web" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | 10 | "github.com/webtor-io/web-ui/services/template" 11 | ) 12 | 13 | type LoginData struct { 14 | Instruction string 15 | } 16 | 17 | type LogoutData struct{} 18 | 19 | type VerifyData struct { 20 | PreAuthSessionId string 21 | } 22 | 23 | type Handler struct { 24 | tb template.Builder[*web.Context] 25 | } 26 | 27 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 28 | h := &Handler{ 29 | tb: tm.MustRegisterViews("auth/*").WithLayout("main"), 30 | } 31 | 32 | r.Use(func(c *gin.Context) { 33 | u := auth.GetUserFromContext(c) 34 | if u != nil && u.Expired { 35 | h.refresh(c) 36 | c.Abort() 37 | return 38 | } 39 | }) 40 | 41 | r.GET("/login", h.login) 42 | r.GET("/refresh", h.refresh) 43 | r.GET("/logout", h.logout) 44 | r.GET("/auth/verify", h.verify) 45 | } 46 | 47 | func (s *Handler) refresh(c *gin.Context) { 48 | s.tb.Build("auth/refresh").HTML(http.StatusOK, web.NewContext(c)) 49 | } 50 | 51 | func (s *Handler) login(c *gin.Context) { 52 | instruction := "default" 53 | if c.Query("from") != "" { 54 | instruction = c.Query("from") 55 | } 56 | ld := LoginData{ 57 | Instruction: instruction, 58 | } 59 | s.tb.Build("auth/login").HTML(http.StatusOK, web.NewContext(c).WithData(ld)) 60 | } 61 | 62 | func (s *Handler) logout(c *gin.Context) { 63 | s.tb.Build("auth/logout").HTML(http.StatusOK, web.NewContext(c).WithData(LogoutData{})) 64 | } 65 | 66 | func (s *Handler) verify(c *gin.Context) { 67 | s.tb.Build("auth/verify").HTML(http.StatusOK, web.NewContext(c).WithData(&VerifyData{ 68 | PreAuthSessionId: c.Query("preAuthSessionId"), 69 | })) 70 | } 71 | -------------------------------------------------------------------------------- /handlers/donate/handler.go: -------------------------------------------------------------------------------- 1 | package donate 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | ) 7 | 8 | type Handler struct { 9 | } 10 | 11 | func RegisterHandler(r *gin.Engine) { 12 | h := &Handler{} 13 | r.GET("/donate", h.get) 14 | } 15 | 16 | func (h *Handler) get(c *gin.Context) { 17 | c.Redirect(http.StatusTemporaryRedirect, "https://www.patreon.com/bePatron?u=24145874") 18 | } 19 | -------------------------------------------------------------------------------- /handlers/embed/example/handler.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/webtor-io/web-ui/services/template" 6 | "github.com/webtor-io/web-ui/services/web" 7 | "github.com/yargevad/filepathx" 8 | "net/http" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | type Handler struct { 14 | tb template.Builder[*web.Context] 15 | examples Examples 16 | } 17 | 18 | type Example struct { 19 | Name string 20 | } 21 | 22 | type Examples []Example 23 | 24 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 25 | examples := Examples{} 26 | g, err := filepathx.Glob("templates/views/embed/example/*") 27 | if err != nil { 28 | panic(err) 29 | } 30 | for _, f := range g { 31 | examples = append(examples, Example{ 32 | Name: strings.TrimSuffix(filepath.Base(f), ".html"), 33 | }) 34 | } 35 | 36 | h := &Handler{ 37 | tb: tm.MustRegisterViews("embed/example/*").WithLayout("embed/example"), 38 | examples: examples, 39 | } 40 | r.GET("/embed/example/:name", h.get) 41 | r.GET("/embed/example", h.exampleIndex) 42 | } 43 | 44 | type Data struct { 45 | } 46 | 47 | func (s *Handler) get(c *gin.Context) { 48 | s.tb.Build("embed/example/"+c.Param("name")).HTML(http.StatusOK, web.NewContext(c).WithData(&Data{})) 49 | } 50 | 51 | func (s *Handler) exampleIndex(c *gin.Context) { 52 | s.tb.Build("embed/example/index").HTML(http.StatusOK, web.NewContext(c).WithData(s.examples)) 53 | } 54 | -------------------------------------------------------------------------------- /handlers/embed/get.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "fmt" 7 | "github.com/webtor-io/web-ui/services/web" 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/google/uuid" 12 | ) 13 | 14 | type GetData struct { 15 | CheckScript string 16 | CheckHash string 17 | ID string 18 | } 19 | 20 | func (s *Handler) get(c *gin.Context) { 21 | id := c.Query("id") 22 | code := uuid.New().String() 23 | h := sha1.New() 24 | h.Write([]byte(id + code)) 25 | gd := GetData{ 26 | CheckHash: hex.EncodeToString(h.Sum(nil)), 27 | CheckScript: s.generateCheckScript(code, id), 28 | ID: id, 29 | } 30 | s.tb.Build("embed/get").HTML(http.StatusOK, web.NewContext(c).WithData(gd)) 31 | } 32 | 33 | func (s *Handler) generateCheckScript(code string, id string) string { 34 | return fmt.Sprintf(` 35 | var found = false; 36 | var scripts = document.getElementsByTagName('script'); 37 | for (var i = scripts.length; i--;) { 38 | if ( 39 | scripts[i].src.includes('https://cdn.jsdelivr.net/npm/@webtor/') || 40 | scripts[i].src.includes('http://localhost:9009/') 41 | ) { 42 | found = '%v'; 43 | } 44 | } 45 | var f = window.frames['webtor-%v']; 46 | f.contentWindow.postMessage({id: '%v', name: 'check', data: found}, '*'); 47 | `, code, id, id) 48 | } 49 | -------------------------------------------------------------------------------- /handlers/embed/handler.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | j "github.com/webtor-io/web-ui/handlers/job" 5 | "github.com/webtor-io/web-ui/services/api" 6 | "github.com/webtor-io/web-ui/services/web" 7 | "net/http" 8 | 9 | "github.com/gin-gonic/gin" 10 | "github.com/webtor-io/web-ui/services/embed" 11 | "github.com/webtor-io/web-ui/services/template" 12 | ) 13 | 14 | type Handler struct { 15 | tb template.Builder[*web.Context] 16 | cl *http.Client 17 | jobs *j.Handler 18 | ds *embed.DomainSettings 19 | api *api.Api 20 | } 21 | 22 | func RegisterHandler(cl *http.Client, r *gin.Engine, tm *template.Manager[*web.Context], jobs *j.Handler, ds *embed.DomainSettings, sapi *api.Api) { 23 | h := &Handler{ 24 | tb: tm.MustRegisterViews("embed/*"), 25 | jobs: jobs, 26 | ds: ds, 27 | cl: cl, 28 | api: sapi, 29 | } 30 | r.GET("/embed", h.get) 31 | r.POST("/embed", h.post) 32 | } 33 | -------------------------------------------------------------------------------- /handlers/embed/post.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/webtor-io/web-ui/models" 6 | "github.com/webtor-io/web-ui/services/web" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/gin-gonic/gin" 11 | "github.com/webtor-io/web-ui/services/embed" 12 | "github.com/webtor-io/web-ui/services/job" 13 | ) 14 | 15 | type PostArgs struct { 16 | ID string 17 | EmbedSettings *models.EmbedSettings 18 | } 19 | 20 | type PostData struct { 21 | ID string 22 | EmbedSettings *models.EmbedSettings 23 | DomainSettings *embed.DomainSettingsData 24 | Job *job.Job 25 | } 26 | 27 | func (s *Handler) bindPostArgs(c *gin.Context) (*PostArgs, error) { 28 | rawSettings := c.PostForm("settings") 29 | var settings models.EmbedSettings 30 | err := json.Unmarshal([]byte(rawSettings), &settings) 31 | if err != nil { 32 | return nil, err 33 | } 34 | id := c.Query("id") 35 | 36 | return &PostArgs{ 37 | ID: id, 38 | EmbedSettings: &settings, 39 | }, nil 40 | 41 | } 42 | 43 | func (s *Handler) post(c *gin.Context) { 44 | tpl := s.tb.Build("embed/post") 45 | pd := PostData{} 46 | args, err := s.bindPostArgs(c) 47 | if err != nil { 48 | tpl.HTML(http.StatusBadRequest, web.NewContext(c).WithData(pd).WithErr(err)) 49 | return 50 | } 51 | pd.ID = args.ID 52 | u, err := url.Parse(args.EmbedSettings.Referer) 53 | if err != nil { 54 | tpl.HTML(http.StatusBadRequest, web.NewContext(c).WithData(pd).WithErr(err)) 55 | return 56 | } 57 | domain := u.Hostname() 58 | dsd, err := s.ds.Get(domain) 59 | if err != nil { 60 | tpl.HTML(http.StatusBadRequest, web.NewContext(c).WithData(pd).WithErr(err)) 61 | return 62 | } 63 | pd.EmbedSettings = args.EmbedSettings 64 | pd.DomainSettings = dsd 65 | c, err = s.api.SetClaims(c, domain) 66 | if err != nil { 67 | _ = c.AbortWithError(http.StatusInternalServerError, err) 68 | return 69 | } 70 | embedJob, err := s.jobs.Embed(web.NewContext(c), s.cl, args.EmbedSettings, dsd) 71 | if err != nil { 72 | tpl.HTML(http.StatusBadRequest, web.NewContext(c).WithData(pd).WithErr(err)) 73 | return 74 | } 75 | pd.Job = embedJob 76 | tpl.HTML(http.StatusAccepted, web.NewContext(c).WithData(pd)) 77 | } 78 | -------------------------------------------------------------------------------- /handlers/ext/handler.go: -------------------------------------------------------------------------------- 1 | package ext 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/webtor-io/web-ui/services/template" 7 | "github.com/webtor-io/web-ui/services/web" 8 | "net/http" 9 | "strconv" 10 | ) 11 | 12 | type Handler struct { 13 | tb template.Builder[*web.Context] 14 | } 15 | 16 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 17 | h := &Handler{ 18 | tb: tm.MustRegisterViews("ext/*"), 19 | } 20 | r.GET("/ext/download", h.download) 21 | r.GET("/ext/magnet", h.magnet) 22 | } 23 | 24 | type DownloadData struct { 25 | DownloadID int 26 | } 27 | 28 | func (s *Handler) download(c *gin.Context) { 29 | i, err := strconv.Atoi(c.Query("id")) 30 | 31 | if err != nil { 32 | log.WithError(err).Error("failed to parse download id") 33 | c.AbortWithStatus(http.StatusBadRequest) 34 | } 35 | 36 | d := DownloadData{ 37 | DownloadID: i, 38 | } 39 | s.tb.Build("ext/download").HTML(http.StatusOK, web.NewContext(c).WithData(d)) 40 | } 41 | 42 | type MagnetData struct { 43 | Magnet string 44 | } 45 | 46 | func (s *Handler) magnet(c *gin.Context) { 47 | magnet := c.Query("url") 48 | 49 | d := MagnetData{ 50 | Magnet: magnet, 51 | } 52 | s.tb.Build("ext/magnet").HTML(http.StatusOK, web.NewContext(c).WithData(d)) 53 | } 54 | -------------------------------------------------------------------------------- /handlers/geo/handler.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import ( 4 | "context" 5 | "github.com/gin-gonic/gin" 6 | "github.com/pkg/errors" 7 | "github.com/webtor-io/web-ui/services/geoip" 8 | "net" 9 | "net/http" 10 | "strings" 11 | ) 12 | 13 | func getIp(c *gin.Context) net.IP { 14 | var ipStr string 15 | if xff := c.Request.Header.Get("X-Forwarded-For"); xff != "" { 16 | parts := strings.Split(xff, ",") 17 | ipStr = strings.TrimSpace(parts[0]) 18 | } else if xrip := c.Request.Header.Get("X-Real-IP"); xrip != "" { 19 | ipStr = xrip 20 | } else { 21 | host, _, err := net.SplitHostPort(c.Request.RemoteAddr) 22 | if err != nil { 23 | host = c.Request.RemoteAddr // fallback in case of error 24 | } 25 | ipStr = host 26 | } 27 | return net.ParseIP(ipStr) 28 | } 29 | 30 | func RegisterHandler(api *geoip.Api, r *gin.Engine) error { 31 | if api == nil { 32 | return nil 33 | } 34 | r.Use(func(c *gin.Context) { 35 | var ip net.IP 36 | if coo, err := c.Cookie("test-ip"); err == nil { 37 | ip = net.ParseIP(coo) 38 | } else if c.Query("test-ip") != "" { 39 | ip = net.ParseIP(c.Query("test-ip")) 40 | } else { 41 | ip = getIp(c) 42 | } 43 | if ip == nil { 44 | return 45 | } 46 | if ip.To4() == nil { 47 | return 48 | } 49 | data, err := api.Get(ip) 50 | if err != nil { 51 | _ = c.AbortWithError(http.StatusInternalServerError, errors.Wrap(err, "failed to fetch geoip data")) 52 | return 53 | } 54 | c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), geoip.Data{}, data)) 55 | }) 56 | return nil 57 | } 58 | 59 | func GetFromContext(c *gin.Context) *geoip.Data { 60 | if gd := c.Request.Context().Value(geoip.Data{}); gd != nil { 61 | return gd.(*geoip.Data) 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /handlers/index/handler.go: -------------------------------------------------------------------------------- 1 | package index 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/services/web" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/webtor-io/web-ui/services/template" 10 | ) 11 | 12 | type Data struct { 13 | Instruction string 14 | } 15 | 16 | type Handler struct { 17 | tb template.Builder[*web.Context] 18 | } 19 | 20 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 21 | h := &Handler{ 22 | tb: tm.MustRegisterViews("*").WithLayout("main"), 23 | } 24 | r.GET("/", h.index) 25 | r.GET("/torrent-to-ddl", h.index) 26 | r.GET("/torrent-to-zip", h.index) 27 | r.GET("/magnet-to-ddl", h.index) 28 | r.GET("/magnet-to-torrent", h.index) 29 | } 30 | 31 | func (s *Handler) index(c *gin.Context) { 32 | s.tb.Build("index").HTML(http.StatusOK, web.NewContext(c).WithData(&Data{ 33 | Instruction: strings.TrimPrefix(c.Request.URL.Path, "/"), 34 | })) 35 | } 36 | -------------------------------------------------------------------------------- /handlers/job/action.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "github.com/webtor-io/web-ui/handlers/job/script" 6 | models2 "github.com/webtor-io/web-ui/models" 7 | "github.com/webtor-io/web-ui/services/web" 8 | "time" 9 | 10 | "github.com/webtor-io/web-ui/services/job" 11 | ) 12 | 13 | func (s *Handler) Action(c *web.Context, resourceID string, itemID string, action string, settings *models2.StreamSettings, purge bool, vsud *models2.VideoStreamUserData) (j *job.Job, err error) { 14 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 15 | as, id := script.Action(s.tb, s.api, c, resourceID, itemID, action, settings, nil, vsud) 16 | j = s.q.GetOrCreate(action).Enqueue(ctx, cancel, id, as, purge) 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /handlers/job/embed.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "github.com/webtor-io/web-ui/handlers/job/script" 6 | "github.com/webtor-io/web-ui/models" 7 | "github.com/webtor-io/web-ui/services/embed" 8 | "github.com/webtor-io/web-ui/services/web" 9 | "net/http" 10 | "time" 11 | 12 | "github.com/webtor-io/web-ui/services/job" 13 | ) 14 | 15 | func (s *Handler) Embed(c *web.Context, cl *http.Client, settings *models.EmbedSettings, dsd *embed.DomainSettingsData) (j *job.Job, err error) { 16 | es, hash, err := script.Embed(s.tb, cl, c, s.api, settings, "", dsd) 17 | if err != nil { 18 | return 19 | } 20 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 21 | j = s.q.GetOrCreate("embded").Enqueue(ctx, cancel, hash, es, false) 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /handlers/job/enrich.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "github.com/webtor-io/web-ui/handlers/job/script" 6 | "github.com/webtor-io/web-ui/services/web" 7 | "time" 8 | 9 | "github.com/webtor-io/web-ui/services/job" 10 | ) 11 | 12 | func (s *Handler) Enrich(c *web.Context, rID string) (j *job.Job, err error) { 13 | es, hash := script.Enrich(s.enricher, c, rID) 14 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 15 | j = s.q.GetOrCreate("enrich").Enqueue(ctx, cancel, hash, job.NewScript(func(j *job.Job) (err error) { 16 | return es.Run(ctx, j) 17 | }), false) 18 | return 19 | } 20 | -------------------------------------------------------------------------------- /handlers/job/handler.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/webtor-io/web-ui/services/api" 6 | "github.com/webtor-io/web-ui/services/enrich" 7 | "github.com/webtor-io/web-ui/services/job" 8 | "github.com/webtor-io/web-ui/services/template" 9 | "github.com/webtor-io/web-ui/services/web" 10 | ) 11 | 12 | type Handler struct { 13 | q *job.Queues 14 | tb template.Builder[*web.Context] 15 | api *api.Api 16 | enricher *enrich.Enricher 17 | } 18 | 19 | func New(q *job.Queues, tm *template.Manager[*web.Context], api *api.Api, enricher *enrich.Enricher) *Handler { 20 | return &Handler{ 21 | q: q, 22 | tb: tm, 23 | api: api, 24 | enricher: enricher, 25 | } 26 | } 27 | 28 | func (s *Handler) RegisterHandler(r *gin.Engine) *Handler { 29 | r.GET("/queue/:queue_id/job/:job_id/log", s.log) 30 | return s 31 | } 32 | -------------------------------------------------------------------------------- /handlers/job/load.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "github.com/webtor-io/web-ui/handlers/job/script" 6 | "github.com/webtor-io/web-ui/services/web" 7 | "time" 8 | 9 | "github.com/webtor-io/web-ui/services/job" 10 | ) 11 | 12 | func (s *Handler) Load(c *web.Context, args *script.LoadArgs) (j *job.Job, err error) { 13 | ls, hash, err := script.Load(s.api, c, args) 14 | if err != nil { 15 | return 16 | } 17 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 18 | j = s.q.GetOrCreate("load").Enqueue(ctx, cancel, hash, job.NewScript(func(j *job.Job) (err error) { 19 | err = ls.Run(ctx, j) 20 | if err != nil { 21 | return 22 | } 23 | j.Redirect("/" + j.Context.Value("respID").(string)) 24 | return 25 | }), false) 26 | return 27 | } 28 | -------------------------------------------------------------------------------- /handlers/job/log.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/gin-gonic/gin" 10 | ) 11 | 12 | func (s *Handler) log(c *gin.Context) { 13 | ctx, cancel := context.WithCancel(c.Request.Context()) 14 | defer cancel() 15 | l, ok, err := s.q.GetOrCreate(c.Param("queue_id")).Log(ctx, c.Param("job_id")) 16 | if err != nil { 17 | _ = c.AbortWithError(http.StatusInternalServerError, err) 18 | return 19 | } 20 | if !ok { 21 | c.Status(http.StatusNotFound) 22 | return 23 | } 24 | 25 | c.Header("Content-Type", "text/event-stream") 26 | c.Header("Cache-Control", "no-cache,no-store,no-transform") 27 | c.Header("Connection", "keep-alive") 28 | c.Header("Access-Control-Allow-Origin", "*") 29 | 30 | c.Stream(func(w io.Writer) bool { 31 | ticker := time.NewTicker(5 * time.Second) 32 | select { 33 | case <-ctx.Done(): 34 | ticker.Stop() 35 | return false 36 | case <-ticker.C: 37 | c.SSEvent("ping", "") 38 | return true 39 | case msg, ok := <-l: 40 | if !ok { 41 | return false 42 | } 43 | c.SSEvent("message", msg) 44 | return true 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /handlers/job/script/enrich.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "context" 5 | "github.com/webtor-io/web-ui/services/enrich" 6 | "github.com/webtor-io/web-ui/services/job" 7 | "github.com/webtor-io/web-ui/services/web" 8 | ) 9 | 10 | type EnrichScript struct { 11 | enricher *enrich.Enricher 12 | rID string 13 | c *web.Context 14 | } 15 | 16 | func NewEnrichScript(enricher *enrich.Enricher, c *web.Context, rID string) *EnrichScript { 17 | return &EnrichScript{ 18 | enricher: enricher, 19 | rID: rID, 20 | c: c, 21 | } 22 | } 23 | 24 | func (s *EnrichScript) Run(ctx context.Context, j *job.Job) (err error) { 25 | return s.enricher.Enrich(ctx, s.rID, s.c.ApiClaims, false) 26 | } 27 | 28 | func Enrich(enricher *enrich.Enricher, c *web.Context, rID string) (job.Runnable, string) { 29 | return NewEnrichScript(enricher, c, rID), rID 30 | } 31 | -------------------------------------------------------------------------------- /handlers/legal/handler.go: -------------------------------------------------------------------------------- 1 | package legal 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/services/web" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/webtor-io/web-ui/services/template" 9 | ) 10 | 11 | type Handler struct { 12 | tb template.Builder[*web.Context] 13 | } 14 | 15 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 16 | h := &Handler{ 17 | tb: tm.MustRegisterViews("legal/**/*").WithLayout("main"), 18 | } 19 | 20 | r.GET("/legal/*template", h.get) 21 | } 22 | 23 | type Data struct { 24 | } 25 | 26 | func (s *Handler) get(c *gin.Context) { 27 | s.tb.Build("legal"+c.Param("template")).HTML(http.StatusOK, web.NewContext(c).WithData(&Data{})) 28 | } 29 | -------------------------------------------------------------------------------- /handlers/library/add.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/anacrolix/torrent/metainfo" 5 | "github.com/gin-gonic/gin" 6 | "github.com/pkg/errors" 7 | uuid "github.com/satori/go.uuid" 8 | "github.com/webtor-io/web-ui/models" 9 | "github.com/webtor-io/web-ui/services/api" 10 | "github.com/webtor-io/web-ui/services/auth" 11 | "github.com/webtor-io/web-ui/services/web" 12 | "io" 13 | "net/http" 14 | ) 15 | 16 | func (s *Handler) add(c *gin.Context) { 17 | u := auth.GetUserFromContext(c) 18 | if !u.HasAuth() { 19 | c.Status(http.StatusForbidden) 20 | return 21 | } 22 | err, rID := s.addTorrentToLibrary(c, u) 23 | if err != nil { 24 | _ = c.AbortWithError(http.StatusInternalServerError, err) 25 | return 26 | } 27 | _, _ = s.jobs.Enrich(web.NewContext(c), rID) 28 | c.Redirect(http.StatusFound, c.GetHeader("X-Return-Url")) 29 | } 30 | 31 | func (s *Handler) addTorrentToLibrary(c *gin.Context, u *auth.User) (err error, id string) { 32 | uID, err := uuid.FromString(u.ID) 33 | if err != nil { 34 | return 35 | } 36 | clms := api.GetClaimsFromContext(c) 37 | ctx := c.Request.Context() 38 | rID, _ := c.GetPostForm("resource_id") 39 | body, err := s.api.GetTorrent(ctx, clms, rID) 40 | if err != nil { 41 | return 42 | } 43 | mi, err := metainfo.Load(body) 44 | if err != nil { 45 | return 46 | } 47 | info, err := mi.UnmarshalInfo() 48 | if err != nil { 49 | return 50 | } 51 | db := s.pg.Get() 52 | if db == nil { 53 | return errors.New("no db"), id 54 | } 55 | err = models.AddTorrentToLibrary(db, uID, rID, info) 56 | if err != nil { 57 | return 58 | } 59 | defer func(body io.ReadCloser) { 60 | _ = body.Close() 61 | }(body) 62 | return nil, rID 63 | } 64 | -------------------------------------------------------------------------------- /handlers/library/handler.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/urfave/cli" 6 | cs "github.com/webtor-io/common-services" 7 | "github.com/webtor-io/web-ui/handlers/job" 8 | "github.com/webtor-io/web-ui/handlers/library/helpers" 9 | "github.com/webtor-io/web-ui/services/api" 10 | "github.com/webtor-io/web-ui/services/template" 11 | "github.com/webtor-io/web-ui/services/web" 12 | "net/http" 13 | ) 14 | 15 | const ( 16 | awsPosterCacheBucket = "aws-poster-cache-bucket" 17 | ) 18 | 19 | func RegisterFlags(f []cli.Flag) []cli.Flag { 20 | return append(f, 21 | cli.StringFlag{ 22 | Name: awsPosterCacheBucket, 23 | Usage: "aws poster cache bucket", 24 | EnvVar: "AWS_POSTER_CACHE_BUCKET", 25 | }, 26 | ) 27 | } 28 | 29 | type Handler struct { 30 | tb template.Builder[*web.Context] 31 | api *api.Api 32 | pg *cs.PG 33 | jobs *job.Handler 34 | cl *http.Client 35 | s3Cl *cs.S3Client 36 | posterCacheS3Bucket string 37 | } 38 | 39 | func RegisterHandler(c *cli.Context, r *gin.Engine, tm *template.Manager[*web.Context], api *api.Api, pg *cs.PG, jobs *job.Handler, cl *http.Client, s3Cl *cs.S3Client) { 40 | h := &Handler{ 41 | tb: tm.MustRegisterViews("library/*"). 42 | WithHelper(helpers.NewStarsHelper()). 43 | WithHelper(helpers.NewMenuHelper()). 44 | WithHelper(helpers.NewSortHelper()). 45 | WithHelper(helpers.NewVideoContentHelper()). 46 | WithLayout("main"), 47 | api: api, 48 | pg: pg, 49 | jobs: jobs, 50 | cl: cl, 51 | s3Cl: s3Cl, 52 | posterCacheS3Bucket: c.String(awsPosterCacheBucket), 53 | } 54 | r.GET("/lib", h.index) 55 | r.GET("/lib/:type", h.index) 56 | r.GET("/lib/:type/poster/:imdb_id/:file", h.poster) 57 | r.POST("/lib/add", h.add) 58 | r.POST("/lib/remove", h.remove) 59 | } 60 | -------------------------------------------------------------------------------- /handlers/library/helpers/menu.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import "github.com/webtor-io/web-ui/handlers/library/shared" 4 | 5 | type MenuItem struct { 6 | Title shared.SectionType 7 | TargetURL string 8 | Active bool 9 | } 10 | 11 | type Menu []MenuItem 12 | 13 | var baseMenu Menu = Menu{ 14 | {shared.SectionTypeTorrents, "/lib", false}, 15 | {shared.SectionTypeMovies, "/lib/movies", false}, 16 | {shared.SectionTypeSeries, "/lib/series", false}, 17 | } 18 | 19 | func (s *VideoContentHelper) MakeMenu(args *shared.IndexArgs) Menu { 20 | m := Menu{} 21 | for _, item := range baseMenu { 22 | nm := item 23 | if item.Title == args.Section { 24 | nm.Active = true 25 | } 26 | m = append(m, nm) 27 | } 28 | return m 29 | } 30 | 31 | type MenuHelper struct{} 32 | 33 | func NewMenuHelper() *MenuHelper { 34 | return &MenuHelper{} 35 | } 36 | -------------------------------------------------------------------------------- /handlers/library/helpers/sort.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/handlers/library/shared" 5 | "github.com/webtor-io/web-ui/models" 6 | ) 7 | 8 | type SortHelper struct{} 9 | 10 | func NewSortHelper() *SortHelper { 11 | return &SortHelper{} 12 | } 13 | 14 | type SortOption struct { 15 | SortType models.SortType 16 | Title string 17 | Value int 18 | Selected bool 19 | } 20 | 21 | type Sort []SortOption 22 | 23 | func NewSort(sortTypes ...models.SortType) Sort { 24 | var sort Sort 25 | for _, sortType := range sortTypes { 26 | sort = append(sort, SortOption{ 27 | SortType: sortType, 28 | Title: sortType.String(), 29 | Value: int(sortType), 30 | }) 31 | } 32 | return sort 33 | 34 | } 35 | 36 | var videoSort = NewSort( 37 | models.SortTypeRecentlyAdded, models.SortTypeYear, 38 | models.SortTypeRating, models.SortTypeName, 39 | ) 40 | 41 | var sorts = map[shared.SectionType]Sort{ 42 | shared.SectionTypeTorrents: NewSort(models.SortTypeRecentlyAdded, models.SortTypeName), 43 | shared.SectionTypeMovies: videoSort, 44 | shared.SectionTypeSeries: videoSort, 45 | } 46 | 47 | func (s *SortHelper) MakeSort(args *shared.IndexArgs) *Sort { 48 | sort := sorts[args.Section] 49 | for k, opt := range sort { 50 | sort[k].Selected = opt.SortType == args.Sort 51 | } 52 | return &sort 53 | } 54 | -------------------------------------------------------------------------------- /handlers/library/helpers/stars.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type Star struct { 8 | Value float64 9 | Title string 10 | Selected bool 11 | HalfStep bool 12 | } 13 | 14 | func (s *StarsHelper) MakeStars(r float64) (stars []Star) { 15 | step := 0.5 16 | maxStar := 5.0 17 | maxRating := 10.0 18 | rating := r / maxRating * maxStar 19 | for i := float64(0); i <= maxStar; i = i + step { 20 | stars = append(stars, Star{ 21 | Value: i, 22 | Title: fmt.Sprintf("%.1f", i), 23 | Selected: rating >= i && rating < i+step, 24 | HalfStep: int((i-float64(int(i)))*2) == 1, 25 | }) 26 | } 27 | return stars 28 | } 29 | 30 | type StarsHelper struct { 31 | } 32 | 33 | func NewStarsHelper() *StarsHelper { 34 | return &StarsHelper{} 35 | } 36 | -------------------------------------------------------------------------------- /handlers/library/helpers/video_content.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "github.com/webtor-io/web-ui/models" 6 | ) 7 | 8 | type VideoContentHelper struct{} 9 | 10 | func NewVideoContentHelper() *VideoContentHelper { 11 | return &VideoContentHelper{} 12 | } 13 | 14 | func (s *VideoContentHelper) GetTitle(m models.VideoContentWithMetadata) string { 15 | if m.GetMetadata() != nil { 16 | return m.GetMetadata().Title 17 | } 18 | return m.GetContent().Title 19 | } 20 | 21 | func (s *VideoContentHelper) HasYear(m models.VideoContentWithMetadata) bool { 22 | return s.GetYear(m) != 0 23 | } 24 | 25 | func (s *VideoContentHelper) GetYear(m models.VideoContentWithMetadata) int { 26 | if m.GetMetadata() != nil { 27 | y := *m.GetMetadata().Year 28 | return int(y) 29 | } 30 | if m.GetContent().Year == nil { 31 | return 0 32 | } 33 | y := *m.GetContent().Year 34 | return int(y) 35 | } 36 | 37 | func (s *VideoContentHelper) HasRating(m models.VideoContentWithMetadata) bool { 38 | return s.GetRating(m) != 0 39 | } 40 | 41 | func (s *VideoContentHelper) GetRating(m models.VideoContentWithMetadata) float64 { 42 | if m.GetMetadata() != nil && m.GetMetadata().Rating != nil { 43 | r := *m.GetMetadata().Rating 44 | return r 45 | } 46 | return 0 47 | } 48 | 49 | func (s *VideoContentHelper) HasPoster(m models.VideoContentWithMetadata) bool { 50 | return s.GetOriginalPoster(m) != "" 51 | } 52 | 53 | func (s *VideoContentHelper) GetOriginalPoster(m models.VideoContentWithMetadata) string { 54 | if m.GetMetadata() != nil { 55 | return m.GetMetadata().PosterURL 56 | } 57 | return "" 58 | } 59 | 60 | func (s *VideoContentHelper) GetCachedPoster240(m models.VideoContentWithMetadata) string { 61 | return fmt.Sprintf("/lib/%v/poster/%v/240.jpg", m.GetContentType(), m.GetMetadata().VideoID) 62 | } 63 | -------------------------------------------------------------------------------- /handlers/library/remove.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/pkg/errors" 6 | uuid "github.com/satori/go.uuid" 7 | "github.com/webtor-io/web-ui/models" 8 | "github.com/webtor-io/web-ui/services/auth" 9 | "net/http" 10 | ) 11 | 12 | func (s *Handler) remove(c *gin.Context) { 13 | u := auth.GetUserFromContext(c) 14 | if !u.HasAuth() { 15 | c.Status(http.StatusForbidden) 16 | return 17 | } 18 | err := s.removeFromLibrary(c, u) 19 | if err != nil { 20 | _ = c.AbortWithError(http.StatusInternalServerError, err) 21 | return 22 | } 23 | c.Redirect(http.StatusFound, c.GetHeader("X-Return-Url")) 24 | } 25 | 26 | func (s *Handler) removeFromLibrary(c *gin.Context, u *auth.User) (err error) { 27 | uID, err := uuid.FromString(u.ID) 28 | if err != nil { 29 | return 30 | } 31 | rID, _ := c.GetPostForm("resource_id") 32 | db := s.pg.Get() 33 | if db == nil { 34 | return errors.New("no db") 35 | } 36 | return models.RemoveFromLibrary(db, uID, rID) 37 | } 38 | -------------------------------------------------------------------------------- /handlers/library/shared/args.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "github.com/webtor-io/web-ui/models" 4 | 5 | type IndexArgs struct { 6 | Sort models.SortType 7 | Section SectionType 8 | } 9 | -------------------------------------------------------------------------------- /handlers/library/shared/section.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type SectionType string 4 | 5 | const ( 6 | SectionTypeTorrents SectionType = "torrents" 7 | SectionTypeMovies SectionType = "movies" 8 | SectionTypeSeries SectionType = "series" 9 | ) 10 | -------------------------------------------------------------------------------- /handlers/migration/handler.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func RegisterHandler(r *gin.Engine) { 11 | r.GET("/en", func(c *gin.Context) { 12 | c.Redirect(http.StatusMovedPermanently, "/") 13 | }) 14 | r.GET("/ru", func(c *gin.Context) { 15 | c.Redirect(http.StatusMovedPermanently, "/") 16 | }) 17 | r.GET("/show", func(c *gin.Context) { 18 | params := c.Request.URL.Query() 19 | if params.Get("downloadId") != "" { 20 | c.Redirect(http.StatusMovedPermanently, "/ext/download?id="+params.Get("downloadId")) 21 | return 22 | } 23 | if params.Get("magnet") != "" { 24 | p := url.Values{} 25 | p.Add("url", params.Get("magnet")) 26 | c.Redirect(http.StatusMovedPermanently, "/ext/magnet?url="+p.Encode()) 27 | return 28 | } 29 | c.Redirect(http.StatusMovedPermanently, "/embed?"+params.Encode()) 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /handlers/profile/handler.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/services/web" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/webtor-io/web-ui/services/template" 9 | ) 10 | 11 | type Data struct{} 12 | 13 | type Handler struct { 14 | tb template.Builder[*web.Context] 15 | } 16 | 17 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 18 | h := &Handler{ 19 | tb: tm.MustRegisterViews("profile/*").WithLayout("main"), 20 | } 21 | r.GET("/profile", h.get) 22 | } 23 | 24 | func (s *Handler) get(c *gin.Context) { 25 | s.tb.Build("profile/get").HTML(http.StatusOK, web.NewContext(c).WithData(&Data{})) 26 | } 27 | -------------------------------------------------------------------------------- /handlers/resource/handler.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | cs "github.com/webtor-io/common-services" 6 | j "github.com/webtor-io/web-ui/handlers/job" 7 | "github.com/webtor-io/web-ui/services/api" 8 | "github.com/webtor-io/web-ui/services/template" 9 | "github.com/webtor-io/web-ui/services/web" 10 | "strings" 11 | ) 12 | 13 | type Handler struct { 14 | api *api.Api 15 | jobs *j.Handler 16 | tb template.Builder[*web.Context] 17 | pg *cs.PG 18 | } 19 | 20 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context], api *api.Api, jobs *j.Handler, pg *cs.PG) { 21 | helper := NewHelper() 22 | h := &Handler{ 23 | api: api, 24 | jobs: jobs, 25 | tb: tm.MustRegisterViews("resource/*").WithHelper(helper).WithLayout("main"), 26 | pg: pg, 27 | } 28 | r.POST("/", h.post) 29 | r.GET("/:resource_id", func(c *gin.Context) { 30 | if strings.HasPrefix(c.Param("resource_id"), "magnet") { 31 | h.post(c) 32 | return 33 | } 34 | h.get(c) 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /handlers/static/handler.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/urfave/cli" 10 | ) 11 | 12 | const ( 13 | AssetsPathFlag = "assets-path" 14 | AssetsHostFlag = "assets-host" 15 | ) 16 | 17 | func RegisterFlags(f []cli.Flag) []cli.Flag { 18 | return append(f, 19 | cli.StringFlag{ 20 | Name: AssetsPathFlag, 21 | Usage: "assets path", 22 | Value: "./assets/dist", 23 | EnvVar: "ASSETS_PATH", 24 | }, 25 | cli.StringFlag{ 26 | Name: AssetsHostFlag, 27 | Usage: "assets host", 28 | Value: "", 29 | EnvVar: "WEB_ASSETS_HOST", 30 | }, 31 | ) 32 | } 33 | 34 | func RegisterHandler(c *cli.Context, r *gin.Engine) error { 35 | assetsPath := c.String(AssetsPathFlag) 36 | pubPath := "pub" 37 | 38 | r.Static("/assets", assetsPath) 39 | r.Static("/pub", pubPath) 40 | 41 | err := filepath.Walk(pubPath, func(path string, info os.FileInfo, err error) error { 42 | if err != nil { 43 | return err 44 | } 45 | if !info.IsDir() { 46 | r.StaticFile(strings.TrimPrefix(path, pubPath), path) 47 | } 48 | return nil 49 | }) 50 | if err != nil { 51 | return err 52 | } 53 | r.StaticFile("/favicon.ico", assetsPath+"/favicon.ico") 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /handlers/tests/handler.go: -------------------------------------------------------------------------------- 1 | package tests 2 | 3 | import ( 4 | "github.com/webtor-io/web-ui/services/web" 5 | "net/http" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/webtor-io/web-ui/services/template" 9 | ) 10 | 11 | type Handler struct { 12 | tb template.Builder[*web.Context] 13 | } 14 | 15 | func RegisterHandler(r *gin.Engine, tm *template.Manager[*web.Context]) { 16 | h := &Handler{ 17 | tb: tm.MustRegisterViews("tests/**/*").WithLayout("main"), 18 | } 19 | 20 | r.GET("/tests/*template", h.get) 21 | } 22 | 23 | type Data struct { 24 | } 25 | 26 | func (s *Handler) get(c *gin.Context) { 27 | s.tb.Build("tests"+c.Param("template")).HTML(http.StatusOK, web.NewContext(c).WithData(&Data{})) 28 | } 29 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/urfave/cli" 8 | ) 9 | 10 | func main() { 11 | app := cli.NewApp() 12 | app.Name = "web-ui" 13 | app.Usage = "runs webtor web ui v2" 14 | app.Version = "0.0.1" 15 | log.SetFormatter(&log.TextFormatter{ 16 | FullTimestamp: true, 17 | }) 18 | configure(app) 19 | err := app.Run(os.Args) 20 | if err != nil { 21 | log.WithError(err).Fatal("failed to serve application") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /migrations/1_embed_domain.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE public.embed_domain; 2 | DROP FUNCTION public.update_updated_at(); -------------------------------------------------------------------------------- /migrations/1_embed_domain.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | CREATE TABLE public.embed_domain ( 4 | embed_domain_id uuid DEFAULT uuid_generate_v4() NOT NULL, 5 | "domain" text NOT NULL, 6 | email text NOT NULL, 7 | created_at timestamptz DEFAULT now() NOT NULL, 8 | ads bool DEFAULT true NOT NULL, 9 | updated_at timestamptz DEFAULT now() NOT NULL, 10 | CONSTRAINT embed_domain_pk PRIMARY KEY (embed_domain_id), 11 | CONSTRAINT embed_domain_unique UNIQUE (domain) 12 | ); 13 | 14 | CREATE OR REPLACE FUNCTION public.update_updated_at() 15 | RETURNS trigger 16 | LANGUAGE plpgsql 17 | AS $function$ 18 | BEGIN 19 | NEW.updated_at = now(); 20 | RETURN NEW; 21 | END; 22 | $function$ 23 | ; 24 | 25 | create trigger update_updated_at before 26 | update 27 | on 28 | public.embed_domain for each row execute function update_updated_at(); -------------------------------------------------------------------------------- /migrations/2_normalize_users.down.sql: -------------------------------------------------------------------------------- 1 | -- 1. Re-add email column to embed_domain 2 | ALTER TABLE public.embed_domain 3 | ADD COLUMN email text; 4 | 5 | -- 2. Restore email values from user table 6 | UPDATE public.embed_domain ed 7 | SET email = u.email 8 | FROM public."user" u 9 | WHERE ed.user_id = u.user_id; 10 | 11 | -- 3. Drop foreign key and user_id column 12 | ALTER TABLE public.embed_domain 13 | DROP CONSTRAINT embed_domain_user_fk; 14 | 15 | ALTER TABLE public.embed_domain 16 | DROP COLUMN user_id; 17 | 18 | -- 4. Drop user table and related trigger/function 19 | DROP TRIGGER IF EXISTS update_user_updated_at ON public."user"; 20 | DROP FUNCTION IF EXISTS public.update_user_updated_at(); 21 | DROP TABLE IF EXISTS public."user"; -------------------------------------------------------------------------------- /migrations/2_normalize_users.up.sql: -------------------------------------------------------------------------------- 1 | -- 1. Create user table 2 | CREATE TABLE public."user" ( 3 | user_id uuid DEFAULT uuid_generate_v4() PRIMARY KEY, 4 | email text NOT NULL UNIQUE, 5 | password text, 6 | created_at timestamptz DEFAULT now() NOT NULL, 7 | updated_at timestamptz DEFAULT now() NOT NULL 8 | ); 9 | 10 | -- 2. Create trigger to auto-update updated_at 11 | CREATE OR REPLACE FUNCTION public.update_user_updated_at() 12 | RETURNS trigger 13 | LANGUAGE plpgsql 14 | AS $$ 15 | BEGIN 16 | NEW.updated_at = now(); 17 | RETURN NEW; 18 | END; 19 | $$; 20 | 21 | CREATE TRIGGER update_user_updated_at 22 | BEFORE UPDATE ON public."user" 23 | FOR EACH ROW 24 | EXECUTE FUNCTION public.update_user_updated_at(); 25 | 26 | -- 3. Add user_id column to embed_domain 27 | ALTER TABLE public.embed_domain 28 | ADD COLUMN user_id uuid; 29 | 30 | -- 4. Insert distinct emails into user table with empty passwords 31 | INSERT INTO public."user" (email, password) 32 | SELECT DISTINCT email FROM public.embed_domain; 33 | 34 | -- 5. Populate user_id in embed_domain based on matching email 35 | UPDATE public.embed_domain ed 36 | SET user_id = u.user_id 37 | FROM public."user" u 38 | WHERE ed.email = u.email; 39 | 40 | -- 6. Enforce NOT NULL and foreign key constraint 41 | ALTER TABLE public.embed_domain 42 | ALTER COLUMN user_id SET NOT NULL; 43 | 44 | ALTER TABLE public.embed_domain 45 | ADD CONSTRAINT embed_domain_user_fk FOREIGN KEY (user_id) 46 | REFERENCES public."user"(user_id) 47 | ON DELETE CASCADE; 48 | 49 | -- 7. Drop old email column from embed_domain 50 | ALTER TABLE public.embed_domain 51 | DROP COLUMN email; -------------------------------------------------------------------------------- /migrations/3_create_library.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop table "library" 2 | DROP TABLE IF EXISTS public.library; -------------------------------------------------------------------------------- /migrations/3_create_library.up.sql: -------------------------------------------------------------------------------- 1 | -- 1. Create table "library" with composite primary key 2 | CREATE TABLE public.library ( 3 | user_id uuid NOT NULL, 4 | resource_id text NOT NULL, 5 | created_at timestamptz DEFAULT now() NOT NULL, 6 | 7 | CONSTRAINT library_pk PRIMARY KEY (user_id, resource_id), 8 | CONSTRAINT library_user_fk FOREIGN KEY (user_id) 9 | REFERENCES public."user"(user_id) 10 | ON DELETE CASCADE 11 | ); -------------------------------------------------------------------------------- /migrations/4_create_torrent_resource.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop torrent_resource table 2 | DROP TABLE IF EXISTS public.torrent_resource; -------------------------------------------------------------------------------- /migrations/4_create_torrent_resource.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE public.torrent_resource ( 2 | resource_id text PRIMARY KEY, 3 | name text NOT NULL, 4 | file_count integer NOT NULL, 5 | size_bytes bigint NOT NULL, 6 | created_at timestamptz DEFAULT now() NOT NULL 7 | ); -------------------------------------------------------------------------------- /migrations/5_create_media_info.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS idx_media_info_status; 2 | DROP INDEX IF EXISTS idx_media_info_created_at; 3 | 4 | DROP TRIGGER IF EXISTS trg_set_updated_at ON media_info; 5 | DROP FUNCTION IF EXISTS update_media_info_updated_at_column; 6 | DROP TABLE IF EXISTS media_info; -------------------------------------------------------------------------------- /migrations/5_create_media_info.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE media_info ( 2 | resource_id TEXT PRIMARY KEY, 3 | status SMALLINT NOT NULL, 4 | media_type SMALLINT, 5 | error TEXT, 6 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 7 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 8 | ); 9 | 10 | CREATE FUNCTION update_media_info_updated_at_column() 11 | RETURNS TRIGGER AS $$ 12 | BEGIN 13 | NEW.updated_at = now(); 14 | RETURN NEW; 15 | END; 16 | $$ LANGUAGE plpgsql; 17 | 18 | CREATE TRIGGER trg_set_updated_at 19 | BEFORE UPDATE ON media_info 20 | FOR EACH ROW 21 | EXECUTE FUNCTION update_media_info_updated_at_column(); 22 | 23 | CREATE INDEX idx_media_info_status ON media_info(status); 24 | CREATE INDEX idx_media_info_created_at ON media_info(created_at); -------------------------------------------------------------------------------- /migrations/6_create_media_structures.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop indexes 2 | DROP INDEX IF EXISTS idx_movie_resource_id; 3 | DROP INDEX IF EXISTS idx_series_resource_id; 4 | 5 | -- Drop triggers 6 | DROP TRIGGER IF EXISTS trg_set_updated_at_movie ON movie; 7 | DROP TRIGGER IF EXISTS trg_set_updated_at_series ON series; 8 | DROP TRIGGER IF EXISTS trg_set_updated_at_episode ON episode; 9 | DROP TRIGGER IF EXISTS trg_updated_at_movie_metadata ON movie_metadata; 10 | DROP TRIGGER IF EXISTS trg_updated_at_series_metadata ON series_metadata; 11 | 12 | -- Drop trigger functions 13 | DROP FUNCTION IF EXISTS update_movie_updated_at_column; 14 | DROP FUNCTION IF EXISTS update_series_updated_at_column; 15 | DROP FUNCTION IF EXISTS update_episode_updated_at_column; 16 | DROP FUNCTION IF EXISTS update_movie_metadata_updated_at_column; 17 | DROP FUNCTION IF EXISTS update_series_metadata_updated_at_column; 18 | 19 | -- Drop tables 20 | DROP TABLE IF EXISTS episode; 21 | DROP TABLE IF EXISTS series; 22 | DROP TABLE IF EXISTS movie; 23 | DROP TABLE IF EXISTS movie_metadata; 24 | DROP TABLE IF EXISTS series_metadata; -------------------------------------------------------------------------------- /migrations/7_create_omdb_info.down.sql: -------------------------------------------------------------------------------- 1 | -- Drop triggers 2 | DROP TRIGGER IF EXISTS trg_updated_at_info ON omdb.info; 3 | DROP TRIGGER IF EXISTS trg_updated_at_query ON omdb.query; 4 | 5 | -- Drop functions 6 | DROP FUNCTION IF EXISTS omdb.update_info_updated_at_column; 7 | DROP FUNCTION IF EXISTS omdb.update_query_updated_at_column; 8 | 9 | -- Drop index 10 | DROP INDEX IF EXISTS uq_query_key; 11 | 12 | -- Drop tables 13 | DROP TABLE IF EXISTS omdb.info; 14 | DROP TABLE IF EXISTS omdb.query; 15 | 16 | --- Drop schema 17 | DROP SCHEMA omdb; 18 | -------------------------------------------------------------------------------- /migrations/7_create_omdb_info.up.sql: -------------------------------------------------------------------------------- 1 | CREATE schema omdb; 2 | 3 | -- OMDB metadata cache 4 | CREATE TABLE omdb.info ( 5 | imdb_id TEXT PRIMARY KEY, 6 | title TEXT NOT NULL, 7 | year smallint, 8 | type SMALLINT NOT NULL, -- 1=movie, 2=series, 3=episode 9 | metadata JSONB NOT NULL, 10 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 11 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 12 | ); 13 | 14 | -- Triggers 15 | CREATE FUNCTION omdb.update_info_updated_at_column() 16 | RETURNS TRIGGER AS $$ 17 | BEGIN 18 | NEW.updated_at = now(); 19 | RETURN NEW; 20 | END; 21 | $$ LANGUAGE plpgsql; 22 | 23 | CREATE TRIGGER trg_updated_at_info 24 | BEFORE UPDATE ON omdb.info 25 | FOR EACH ROW EXECUTE FUNCTION omdb.update_info_updated_at_column(); 26 | 27 | -- create omdb_query table 28 | CREATE TABLE omdb.query ( 29 | query_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 30 | title TEXT NOT NULL, 31 | year SMALLINT, 32 | type SMALLINT NOT NULL, -- 1 = movie, 2 = series 33 | imdb_id TEXT, 34 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 35 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 36 | ); 37 | 38 | -- unique index on (title, year, type) 39 | CREATE UNIQUE INDEX uq_query_key 40 | ON omdb.query (title, COALESCE(year, -1), type); 41 | 42 | -- updated_at trigger 43 | CREATE FUNCTION omdb.update_query_updated_at_column() 44 | RETURNS TRIGGER AS $$ 45 | BEGIN 46 | NEW.updated_at = now(); 47 | RETURN NEW; 48 | END; 49 | $$ LANGUAGE plpgsql; 50 | 51 | CREATE TRIGGER trg_updated_at_query 52 | BEFORE UPDATE ON omdb.query 53 | FOR EACH ROW 54 | EXECUTE FUNCTION omdb.update_query_updated_at_column(); -------------------------------------------------------------------------------- /migrations/8_create_kinopoisk_unofficial_info.down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS trg_updated_at_kinopoisk_query ON kinopoisk_unofficial.query; 2 | DROP FUNCTION IF EXISTS kinopoisk_unofficial.update_query_updated_at_column(); 3 | DROP INDEX IF EXISTS uq_kinopoisk_query_key; 4 | DROP TABLE IF EXISTS kinopoisk_unofficial.query; 5 | 6 | DROP TRIGGER IF EXISTS trg_updated_at_kinopoisk_info ON kinopoisk_unofficial.info; 7 | DROP FUNCTION IF EXISTS kinopoisk_unofficial.update_info_updated_at_column(); 8 | DROP TABLE IF EXISTS kinopoisk_unofficial.info; 9 | 10 | DROP SCHEMA IF EXISTS kinopoisk_unofficial CASCADE; -------------------------------------------------------------------------------- /migrations/8_create_kinopoisk_unofficial_info.up.sql: -------------------------------------------------------------------------------- 1 | -- Schema for Kinopoisk unofficial metadata 2 | CREATE SCHEMA IF NOT EXISTS kinopoisk_unofficial; 3 | 4 | -- Kinopoisk metadata cache 5 | CREATE TABLE kinopoisk_unofficial.info 6 | ( 7 | kp_id INTEGER PRIMARY KEY, -- kinopoisk filmId 8 | imdb_id TEXT, 9 | title TEXT NOT NULL, 10 | year SMALLINT, 11 | metadata JSONB NOT NULL, 12 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 13 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 14 | ); 15 | 16 | -- Trigger function to auto-update updated_at 17 | CREATE FUNCTION kinopoisk_unofficial.update_info_updated_at_column() 18 | RETURNS TRIGGER AS 19 | $$ 20 | BEGIN 21 | NEW.updated_at = now(); 22 | RETURN NEW; 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | -- Trigger on update 27 | CREATE TRIGGER trg_updated_at_kinopoisk_info 28 | BEFORE UPDATE 29 | ON kinopoisk_unofficial.info 30 | FOR EACH ROW 31 | EXECUTE FUNCTION kinopoisk_unofficial.update_info_updated_at_column(); 32 | 33 | -- Query table for caching search results 34 | CREATE TABLE kinopoisk_unofficial.query 35 | ( 36 | query_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 37 | title TEXT NOT NULL, 38 | year SMALLINT, 39 | kp_id INTEGER, 40 | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 41 | updated_at TIMESTAMPTZ NOT NULL DEFAULT now() 42 | ); 43 | 44 | -- Partial unique index for query de-duplication 45 | CREATE UNIQUE INDEX uq_kinopoisk_query_key 46 | ON kinopoisk_unofficial.query (title, COALESCE(year, -1)) 47 | WHERE title IS NOT NULL; 48 | 49 | -- Trigger function for updated_at 50 | CREATE FUNCTION kinopoisk_unofficial.update_query_updated_at_column() 51 | RETURNS TRIGGER AS 52 | $$ 53 | BEGIN 54 | NEW.updated_at = now(); 55 | RETURN NEW; 56 | END; 57 | $$ LANGUAGE plpgsql; 58 | 59 | -- Trigger for update on query 60 | CREATE TRIGGER trg_updated_at_kinopoisk_query 61 | BEFORE UPDATE 62 | ON kinopoisk_unofficial.query 63 | FOR EACH ROW 64 | EXECUTE FUNCTION kinopoisk_unofficial.update_query_updated_at_column(); 65 | -------------------------------------------------------------------------------- /models/embed_domain.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | uuid "github.com/satori/go.uuid" 7 | ) 8 | 9 | type EmbedDomain struct { 10 | tableName struct{} `pg:"embed_domain"` 11 | ID uuid.UUID `pg:"embed_domain_id,pk,type:uuid,default:uuid_generate_v4()"` 12 | Domain string 13 | Ads bool 14 | CreatedAt time.Time 15 | UpdatedAt time.Time 16 | 17 | UserID uuid.UUID `pg:"user_id"` 18 | User *User `pg:"rel:has-one,fk:user_id"` 19 | } 20 | -------------------------------------------------------------------------------- /models/embed_settings.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type EmbedSettings struct { 4 | StreamSettings 5 | Version string `json:"version"` 6 | Magnet string `json:"magnet"` 7 | TorrentURL string `json:"torrentUrl"` 8 | Referer string `json:"referer"` 9 | PWD string `json:"pwd"` 10 | File string `json:"file"` 11 | Path string `json:"path"` 12 | } 13 | -------------------------------------------------------------------------------- /models/episode.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/satori/go.uuid" 7 | ) 8 | 9 | type Episode struct { 10 | tableName struct{} `pg:"episode"` 11 | 12 | EpisodeID uuid.UUID `pg:"episode_id,pk,type:uuid,default:uuid_generate_v4()"` 13 | SeriesID uuid.UUID `pg:"series_id"` 14 | Season *int16 `pg:"season"` 15 | Episode *int16 `pg:"episode"` 16 | ResourceID string `pg:"resource_id"` 17 | Title *string `pg:"title"` 18 | Path *string `pg:"path"` 19 | Metadata map[string]any `pg:"metadata,type:jsonb"` 20 | CreatedAt time.Time `pg:"created_at,default:now()"` 21 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 22 | 23 | Series *Series `pg:"rel:has-one,fk:series_id"` 24 | MediaInfo *MediaInfo `pg:"rel:has-one,fk:resource_id"` 25 | } 26 | -------------------------------------------------------------------------------- /models/kinopoisk_unofficial/info.go: -------------------------------------------------------------------------------- 1 | package kinopoisk_unofficial 2 | 3 | import ( 4 | "context" 5 | "github.com/go-pg/pg/v10" 6 | "github.com/pkg/errors" 7 | "time" 8 | ) 9 | 10 | type Info struct { 11 | tableName struct{} `pg:"kinopoisk_unofficial.info"` 12 | 13 | KpID int `pg:"kp_id,pk"` 14 | ImdbID *string `pg:"imdb_id"` 15 | Title string `pg:"title,notnull"` 16 | Year *int16 `pg:"year"` 17 | Metadata map[string]any `pg:"metadata,type:jsonb"` 18 | CreatedAt time.Time `pg:"created_at,default:now()"` 19 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 20 | } 21 | 22 | func GetInfoByID(ctx context.Context, db *pg.DB, kpID int) (*Info, error) { 23 | var info Info 24 | 25 | err := db.Model(&info). 26 | Context(ctx). 27 | Where("kp_id = ?", kpID). 28 | Limit(1). 29 | Select() 30 | 31 | if errors.Is(err, pg.ErrNoRows) { 32 | return nil, nil 33 | } 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return &info, nil 39 | } 40 | 41 | func UpsertInfo(ctx context.Context, db *pg.DB, kpID int, metadata map[string]any) (*Info, error) { 42 | title, ok := metadata["nameEn"].(string) 43 | if !ok { 44 | title, ok = metadata["nameOriginal"].(string) 45 | if !ok { 46 | title, ok = metadata["nameRu"].(string) 47 | if !ok { 48 | return nil, errors.Errorf("metadata.name is missing") 49 | } 50 | } 51 | } 52 | year, ok := metadata["startYear"].(float64) 53 | if !ok { 54 | year = metadata["year"].(float64) 55 | } 56 | var err error 57 | var yearPtr *int16 58 | 59 | if year != 0 { 60 | y := int16(year) 61 | yearPtr = &y 62 | } 63 | 64 | imdbID, _ := metadata["imdbId"].(string) 65 | 66 | var imdbIDPtr *string 67 | 68 | if imdbID != "" { 69 | imdbIDPtr = &imdbID 70 | } 71 | 72 | m := &Info{ 73 | KpID: kpID, 74 | ImdbID: imdbIDPtr, 75 | Metadata: metadata, 76 | Title: title, 77 | Year: yearPtr, 78 | } 79 | // Update record 80 | _, err = db.Model(m). 81 | Context(ctx). 82 | OnConflict("(kp_id) DO UPDATE"). 83 | Set("metadata = EXCLUDED.metadata, title = EXCLUDED.title, year = EXCLUDED.year, imdb_id = EXCLUDED.imdb_id"). 84 | Insert() 85 | 86 | return m, err 87 | } 88 | -------------------------------------------------------------------------------- /models/kinopoisk_unofficial/query.go: -------------------------------------------------------------------------------- 1 | package kinopoisk_unofficial 2 | 3 | import ( 4 | "context" 5 | "github.com/go-pg/pg/v10" 6 | "github.com/go-pg/pg/v10/orm" 7 | "github.com/pkg/errors" 8 | "strings" 9 | "time" 10 | 11 | "github.com/satori/go.uuid" 12 | ) 13 | 14 | type Query struct { 15 | tableName struct{} `pg:"kinopoisk_unofficial.query"` 16 | 17 | QueryID uuid.UUID `pg:"query_id,pk,type:uuid,default:uuid_generate_v4()"` 18 | Title string `pg:"title"` 19 | Year *int16 `pg:"year"` 20 | KpID *int `pg:"kp_id"` 21 | CreatedAt time.Time `pg:"created_at,default:now()"` 22 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 23 | } 24 | 25 | func GetQuery(ctx context.Context, db *pg.DB, title string, year *int16) (*Query, error) { 26 | // Normalize title: trim and lowercase 27 | normalizedTitle := strings.ToLower(strings.TrimSpace(title)) 28 | 29 | query := &Query{} 30 | 31 | err := db.Model(query). 32 | Where("title = ?", normalizedTitle). 33 | Context(ctx). 34 | Apply(func(q *orm.Query) (*orm.Query, error) { 35 | if year != nil { 36 | q = q.Where("year = ?", *year) 37 | } else { 38 | q = q.Where("year IS NULL") 39 | } 40 | return q, nil 41 | }). 42 | Limit(1). 43 | Select() 44 | 45 | if errors.Is(err, pg.ErrNoRows) { 46 | return nil, nil 47 | } 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | return query, nil 53 | } 54 | 55 | func InsertQueryIgnoreConflict(ctx context.Context, db *pg.DB, title string, year *int16, kpID *int) (*Query, error) { 56 | 57 | q := &Query{ 58 | Title: strings.ToLower(strings.TrimSpace(title)), 59 | Year: year, 60 | KpID: kpID, 61 | } 62 | 63 | _, err := db.Model(q). 64 | Context(ctx). 65 | OnConflict("DO NOTHING"). 66 | Insert() 67 | 68 | if err != nil && !errors.Is(err, pg.ErrNoRows) { 69 | return nil, err 70 | } 71 | 72 | return q, nil 73 | } 74 | -------------------------------------------------------------------------------- /models/movie.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "github.com/go-pg/pg/v10" 6 | "time" 7 | 8 | "github.com/satori/go.uuid" 9 | ) 10 | 11 | type Movie struct { 12 | *VideoContent 13 | 14 | tableName struct{} `pg:"movie"` 15 | 16 | MovieID uuid.UUID `pg:"movie_id,pk,type:uuid,default:uuid_generate_v4()"` 17 | ResourceID string `pg:"resource_id"` 18 | MovieMetadataID *uuid.UUID `pg:"movie_metadata_id"` 19 | Path *string `pg:"path"` 20 | CreatedAt time.Time `pg:"created_at,default:now()"` 21 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 22 | 23 | MediaInfo *MediaInfo `pg:"rel:has-one,fk:resource_id"` 24 | MovieMetadata *MovieMetadata `pg:"rel:has-one,fk:movie_metadata_id"` 25 | LibraryItems []*Library `pg:"rel:has-many,fk:library_id,join_fk:resource_id"` 26 | } 27 | 28 | func (s *Movie) GetMetadata() *VideoMetadata { 29 | if s.MovieMetadata == nil { 30 | return nil 31 | } 32 | return s.MovieMetadata.VideoMetadata 33 | } 34 | 35 | func (s *Movie) GetContent() *VideoContent { 36 | return s.VideoContent 37 | } 38 | 39 | func (s *Movie) GetContentType() ContentType { 40 | return ContentTypeMovie 41 | } 42 | 43 | func (s *Movie) GetIntYear() int { 44 | if s.Year == nil { 45 | return 0 46 | } 47 | return int(*s.Year) 48 | } 49 | 50 | func ReplaceMoviesForResource(ctx context.Context, db *pg.DB, resourceID string, movies []*Movie) error { 51 | tx, err := db.BeginContext(ctx) 52 | if err != nil { 53 | return err 54 | } 55 | defer func() { 56 | _ = tx.Close() 57 | }() 58 | 59 | // Delete existing movies for the resource 60 | _, err = tx.Model((*Movie)(nil)). 61 | Where("resource_id = ?", resourceID). 62 | Context(ctx). 63 | Delete() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | // Insert new movies if any 69 | if len(movies) > 0 { 70 | _, err = tx.Model(&movies). 71 | Context(ctx). 72 | Insert() 73 | if err != nil { 74 | return err 75 | } 76 | } 77 | 78 | return tx.Commit() 79 | } 80 | 81 | func GetMoviesByResourceID(ctx context.Context, db *pg.DB, resourceID string) ([]*Movie, error) { 82 | var movies []*Movie 83 | 84 | err := db.Model(&movies). 85 | Where("resource_id = ?", resourceID). 86 | Context(ctx). 87 | Select() 88 | 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | return movies, nil 94 | } 95 | -------------------------------------------------------------------------------- /models/movie_metadata.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/go-pg/pg/v10" 7 | uuid "github.com/satori/go.uuid" 8 | "time" 9 | ) 10 | 11 | type MovieMetadata struct { 12 | *VideoMetadata 13 | 14 | tableName struct{} `pg:"movie_metadata"` 15 | 16 | MovieMetadataID uuid.UUID `pg:"movie_metadata_id,pk,type:uuid,default:uuid_generate_v4()"` 17 | CreatedAt time.Time `pg:"created_at,default:now()"` 18 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 19 | } 20 | 21 | func LinkMovieToMetadata( 22 | ctx context.Context, 23 | db *pg.DB, 24 | movieID uuid.UUID, 25 | metadataID uuid.UUID, 26 | ) error { 27 | _, err := db.Model(&Movie{}). 28 | Set("movie_metadata_id = ?", metadataID). 29 | Where("movie_id = ?", movieID). 30 | Context(ctx). 31 | Update() 32 | return err 33 | } 34 | 35 | func UpsertMovieMetadata( 36 | ctx context.Context, 37 | db *pg.DB, 38 | md *VideoMetadata, 39 | ) (uuid.UUID, error) { 40 | meta := &MovieMetadata{ 41 | VideoMetadata: md, 42 | } 43 | 44 | _, err := db.Model(meta). 45 | Context(ctx). 46 | OnConflict("(video_id) DO UPDATE"). 47 | Set(` 48 | title = EXCLUDED.title, 49 | year = EXCLUDED.year, 50 | plot = EXCLUDED.plot, 51 | poster_url = EXCLUDED.poster_url, 52 | rating = EXCLUDED.rating 53 | `). 54 | Returning("movie_metadata_id"). 55 | Insert() 56 | if err != nil { 57 | return uuid.NewV4(), err 58 | } 59 | 60 | return meta.MovieMetadataID, nil 61 | } 62 | 63 | func GetMovieMetadataByVideoID( 64 | ctx context.Context, 65 | db *pg.DB, 66 | imdbID string, 67 | ) (*MovieMetadata, error) { 68 | var meta MovieMetadata 69 | 70 | err := db.Model(&meta). 71 | Context(ctx). 72 | Where("video_id = ?", imdbID). 73 | Limit(1). 74 | Select() 75 | 76 | if errors.Is(err, pg.ErrNoRows) { 77 | return nil, nil 78 | } 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return &meta, nil 84 | } 85 | -------------------------------------------------------------------------------- /models/omdb/info.go: -------------------------------------------------------------------------------- 1 | package omdb 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/go-pg/pg/v10" 7 | "regexp" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | type Info struct { 13 | tableName struct{} `pg:"omdb.info"` 14 | 15 | ImdbID string `pg:"imdb_id,pk"` 16 | Title string `pg:"title,notnull"` 17 | Year *int16 `pg:"year"` // nullable 18 | Type OmdbType `pg:"type"` 19 | Metadata map[string]any `pg:"metadata,type:jsonb"` 20 | CreatedAt time.Time `pg:"created_at,default:now()"` 21 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 22 | } 23 | 24 | type OmdbType int16 25 | 26 | const ( 27 | OmdbTypeMovie OmdbType = 1 28 | OmdbTypeSeries OmdbType = 2 29 | OmdbTypeEpisode OmdbType = 3 30 | ) 31 | 32 | var startYearRegexp = regexp.MustCompile(`^\d{4}`) 33 | 34 | func UpsertInfo(ctx context.Context, db *pg.DB, imdbID string, omdbType OmdbType, metadata map[string]any) (*Info, error) { 35 | 36 | title := metadata["Title"].(string) 37 | yearStr := metadata["Year"].(string) 38 | var year int 39 | var err error 40 | if yearStr != "" { 41 | yearMatches := startYearRegexp.FindStringSubmatch(yearStr) 42 | year, err = strconv.Atoi(yearMatches[0]) 43 | if err != nil { 44 | return nil, err 45 | } 46 | } 47 | 48 | var yearPtr *int16 49 | 50 | if year != 0 { 51 | y := int16(year) 52 | yearPtr = &y 53 | } 54 | 55 | m := &Info{ 56 | ImdbID: imdbID, 57 | Type: omdbType, 58 | Metadata: metadata, 59 | Title: title, 60 | Year: yearPtr, 61 | } 62 | // Update record 63 | _, err = db.Model(m). 64 | Context(ctx). 65 | OnConflict("(imdb_id) DO UPDATE"). 66 | Set("type = EXCLUDED.type, metadata = EXCLUDED.metadata, title = EXCLUDED.title, year = EXCLUDED.year"). 67 | Insert() 68 | 69 | return m, err 70 | } 71 | 72 | func GetInfoByID(ctx context.Context, db *pg.DB, imdbID string) (*Info, error) { 73 | var info Info 74 | 75 | err := db.Model(&info). 76 | Context(ctx). 77 | Where("imdb_id = ?", imdbID). 78 | Limit(1). 79 | Select() 80 | 81 | if errors.Is(err, pg.ErrNoRows) { 82 | return nil, nil 83 | } 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | return &info, nil 89 | } 90 | -------------------------------------------------------------------------------- /models/omdb/query.go: -------------------------------------------------------------------------------- 1 | package omdb 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/go-pg/pg/v10" 7 | "github.com/go-pg/pg/v10/orm" 8 | uuid "github.com/satori/go.uuid" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type Query struct { 14 | tableName struct{} `pg:"omdb.query"` 15 | 16 | QueryID uuid.UUID `pg:"query_id,pk,type:uuid,default:uuid_generate_v4()"` 17 | Title string `pg:"title,notnull"` 18 | Year *int16 `pg:"year"` // nullable 19 | Type OmdbType `pg:"type,notnull"` 20 | ImdbID *string `pg:"imdb_id"` // nullable FK 21 | CreatedAt time.Time `pg:"created_at,default:now()"` 22 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 23 | } 24 | 25 | func GetQuery(ctx context.Context, db *pg.DB, title string, year *int16, omdbType OmdbType) (*Query, error) { 26 | // Normalize title: trim and lowercase 27 | normalizedTitle := strings.ToLower(strings.TrimSpace(title)) 28 | 29 | query := &Query{} 30 | 31 | err := db.Model(query). 32 | Where("title = ?", normalizedTitle). 33 | Where("type = ?", omdbType). 34 | Context(ctx). 35 | Apply(func(q *orm.Query) (*orm.Query, error) { 36 | if year != nil { 37 | q = q.Where("year = ?", *year) 38 | } else { 39 | q = q.Where("year IS NULL") 40 | } 41 | return q, nil 42 | }). 43 | Limit(1). 44 | Select() 45 | 46 | if errors.Is(err, pg.ErrNoRows) { 47 | return nil, nil 48 | } 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | return query, nil 54 | } 55 | 56 | func InsertQueryIgnoreConflict(ctx context.Context, db *pg.DB, title string, year *int16, omdbType OmdbType, imdbID *string) (*Query, error) { 57 | 58 | q := &Query{ 59 | Title: strings.ToLower(strings.TrimSpace(title)), 60 | Year: year, 61 | Type: omdbType, 62 | ImdbID: imdbID, 63 | } 64 | 65 | _, err := db.Model(q). 66 | Context(ctx). 67 | OnConflict("DO NOTHING"). 68 | Insert() 69 | 70 | if err != nil && !errors.Is(err, pg.ErrNoRows) { 71 | return nil, err 72 | } 73 | 74 | return q, nil 75 | } 76 | -------------------------------------------------------------------------------- /models/series_metadata.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/go-pg/pg/v10" 7 | uuid "github.com/satori/go.uuid" 8 | "time" 9 | ) 10 | 11 | type SeriesMetadata struct { 12 | *VideoMetadata 13 | 14 | tableName struct{} `pg:"series_metadata"` 15 | 16 | SeriesMetadataID uuid.UUID `pg:"series_metadata_id,pk,type:uuid,default:uuid_generate_v4()"` 17 | CreatedAt time.Time `pg:"created_at,default:now()"` 18 | UpdatedAt time.Time `pg:"updated_at,default:now()"` 19 | } 20 | 21 | func LinkSeriesToMetadata( 22 | ctx context.Context, 23 | db *pg.DB, 24 | seriesID uuid.UUID, 25 | metadataID uuid.UUID, 26 | ) error { 27 | _, err := db.Model(&Series{}). 28 | Set("series_metadata_id = ?", metadataID). 29 | Where("series_id = ?", seriesID). 30 | Context(ctx). 31 | Update() 32 | return err 33 | } 34 | 35 | func UpsertSeriesMetadata( 36 | ctx context.Context, 37 | db *pg.DB, 38 | md *VideoMetadata, 39 | ) (uuid.UUID, error) { 40 | meta := &SeriesMetadata{ 41 | VideoMetadata: md, 42 | } 43 | 44 | _, err := db.Model(meta). 45 | Context(ctx). 46 | OnConflict("(video_id) DO UPDATE"). 47 | Set(` 48 | title = EXCLUDED.title, 49 | year = EXCLUDED.year, 50 | plot = EXCLUDED.plot, 51 | poster_url = EXCLUDED.poster_url, 52 | rating = EXCLUDED.rating 53 | `). 54 | Returning("series_metadata_id"). 55 | Insert() 56 | if err != nil { 57 | return uuid.NewV4(), err 58 | } 59 | 60 | return meta.SeriesMetadataID, nil 61 | } 62 | 63 | func GetSeriesMetadataByVideoID( 64 | ctx context.Context, 65 | db *pg.DB, 66 | videoID string, 67 | ) (*SeriesMetadata, error) { 68 | var meta SeriesMetadata 69 | 70 | err := db.Model(&meta). 71 | Context(ctx). 72 | Where("video_id = ?", videoID). 73 | Limit(1). 74 | Select() 75 | 76 | if errors.Is(err, pg.ErrNoRows) { 77 | return nil, nil 78 | } 79 | if err != nil { 80 | return nil, err 81 | } 82 | 83 | return &meta, nil 84 | } 85 | -------------------------------------------------------------------------------- /models/stream_settings.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type SettingsTrack struct { 4 | Src string `json:"src"` 5 | SrcLang string `json:"srclang,omitempty"` 6 | Label string `json:"label,omitempty"` 7 | Default *string `json:"default,omitempty"` 8 | } 9 | 10 | type StreamSettings struct { 11 | BaseURL string `json:"baseUrl"` 12 | Width string `json:"width"` 13 | Height string `json:"height"` 14 | Mode string `json:"mode"` 15 | Subtitles []SettingsTrack `json:"subtitles"` 16 | Poster string `json:"poster"` 17 | Header bool `json:"header"` 18 | Title string `json:"title"` 19 | ImdbID string `json:"imdbId"` 20 | Lang string `json:"lang"` 21 | I18n struct{} `json:"i18n"` 22 | Features map[string]bool `json:"features"` 23 | El struct{} `json:"el"` 24 | Controls *bool `json:"controls"` 25 | UserLang string `json:"userLang"` 26 | } 27 | -------------------------------------------------------------------------------- /models/torrent_resource.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "github.com/go-pg/pg/v10" 6 | "time" 7 | ) 8 | 9 | type TorrentResource struct { 10 | tableName struct{} `pg:"torrent_resource"` 11 | 12 | ResourceID string `pg:"resource_id,pk"` 13 | Name string `pg:"name"` 14 | FileCount int `pg:"file_count"` 15 | SizeBytes int64 `pg:"size_bytes"` 16 | CreatedAt time.Time `pg:"created_at"` 17 | 18 | LibraryEntries []*Library `pg:"rel:has-many,fk:resource_id"` 19 | MediaInfo *MediaInfo `pg:"rel:has-one,fk:resource_id"` 20 | } 21 | 22 | func GetResourcesWithoutMediaInfo(ctx context.Context, db *pg.DB) ([]*TorrentResource, error) { 23 | var resources []*TorrentResource 24 | 25 | err := db.Model(&resources). 26 | Context(ctx). 27 | Where("media_info.resource_id IS NULL"). 28 | Join("LEFT JOIN media_info ON media_info.resource_id = torrent_resource.resource_id"). 29 | Select() 30 | 31 | if err != nil { 32 | return nil, err 33 | } 34 | return resources, nil 35 | } 36 | 37 | func GetAllResources(ctx context.Context, db *pg.DB) ([]*TorrentResource, error) { 38 | var resources []*TorrentResource 39 | 40 | err := db.Model(&resources).Context(ctx).Select() 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | return resources, nil 46 | } 47 | 48 | func GetErrorResources(ctx context.Context, db *pg.DB) ([]*TorrentResource, error) { 49 | var resources []*TorrentResource 50 | 51 | err := db.Model(&resources). 52 | Context(ctx). 53 | Join("JOIN media_info ON media_info.resource_id = torrent_resource.resource_id"). 54 | Where("media_info.status in (?)", pg.In([]int16{ 55 | int16(MediaInfoStatusProcessing), 56 | int16(MediaInfoStatusError), 57 | })). 58 | Select() 59 | 60 | if err != nil { 61 | return nil, err 62 | } 63 | return resources, nil 64 | } 65 | 66 | func GetResourceByID(ctx context.Context, db *pg.DB, id string) (*TorrentResource, error) { 67 | var resource TorrentResource 68 | err := db.Model(&resource).Context(ctx).Where("resource_id = ?", id).Limit(1).Select() 69 | 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | return &resource, nil 75 | } 76 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/go-pg/pg/v10" 5 | "github.com/pkg/errors" 6 | "time" 7 | 8 | uuid "github.com/satori/go.uuid" 9 | ) 10 | 11 | type User struct { 12 | tableName struct{} `pg:"user"` 13 | UserID uuid.UUID `pg:"user_id,pk"` 14 | Email string 15 | Password string 16 | CreatedAt time.Time 17 | UpdatedAt time.Time 18 | } 19 | 20 | func GetOrCreateUser(db *pg.DB, email string) (*User, error) { 21 | user := &User{} 22 | err := db.Model(user).Where("email = ?", email).Limit(1).Select() 23 | if err == nil { 24 | return user, nil // Found 25 | } 26 | if !errors.Is(err, pg.ErrNoRows) { 27 | return nil, err // DB error 28 | } 29 | 30 | // Create new user 31 | user.Email = email 32 | _, err = db.Model(user).Insert() 33 | if err != nil { 34 | return nil, err 35 | } 36 | return user, nil 37 | } 38 | -------------------------------------------------------------------------------- /models/video_content.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type VideoContent struct { 4 | Title string `pg:"title"` 5 | Year *int16 `pg:"year"` 6 | Metadata map[string]any `pg:"metadata,type:jsonb"` 7 | } 8 | 9 | type ContentType string 10 | 11 | const ( 12 | ContentTypeMovie ContentType = "movie" 13 | ContentTypeSeries ContentType = "series" 14 | ) 15 | 16 | type VideoContentWithMetadata interface { 17 | GetContentType() ContentType 18 | GetContent() *VideoContent 19 | GetMetadata() *VideoMetadata 20 | } 21 | -------------------------------------------------------------------------------- /models/video_metadata.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type VideoMetadata struct { 4 | VideoID string `pg:"video_id,unique"` 5 | Title string `pg:"title"` 6 | Year *int16 `pg:"year"` 7 | Plot string `pg:"plot"` 8 | PosterURL string `pg:"poster_url"` 9 | Rating *float64 `pg:"rating"` 10 | } 11 | -------------------------------------------------------------------------------- /models/video_stream.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gin-contrib/sessions" 6 | "github.com/gin-gonic/gin" 7 | "golang.org/x/text/language" 8 | ) 9 | 10 | type VideoStreamUserData struct { 11 | ResourceID string 12 | ItemID string 13 | SubtitleID string 14 | AudioID string 15 | AcceptLangTags []language.Tag 16 | FallbackLangTag language.Tag 17 | Settings *StreamSettings 18 | } 19 | 20 | func NewVideoStreamUserData(resourceID string, itemID string, settings *StreamSettings) *VideoStreamUserData { 21 | return &VideoStreamUserData{ 22 | ResourceID: resourceID, 23 | ItemID: itemID, 24 | FallbackLangTag: language.English, 25 | Settings: settings, 26 | } 27 | } 28 | 29 | func (s *VideoStreamUserData) FetchSessionData(c *gin.Context) { 30 | session := sessions.Default(c) 31 | var subtitleID, audioID string 32 | audioKey := s.makeKey(s.ResourceID, s.ItemID, "audio") 33 | subtitleKey := s.makeKey(s.ResourceID, s.ItemID, "subtitle") 34 | if session.Get(subtitleKey) != nil { 35 | subtitleID = session.Get(subtitleKey).(string) 36 | } 37 | if session.Get(audioKey) != nil { 38 | audioID = session.Get(audioKey).(string) 39 | } 40 | accept := c.GetHeader("Accept-Language") 41 | if s.Settings.UserLang != "" { 42 | accept = s.Settings.UserLang 43 | } 44 | tags, _, err := language.ParseAcceptLanguage(accept) 45 | if err != nil { 46 | tags = []language.Tag{language.English} 47 | } 48 | s.AudioID = audioID 49 | s.SubtitleID = subtitleID 50 | s.AcceptLangTags = tags 51 | } 52 | 53 | func (s *VideoStreamUserData) makeKey(resourceID string, itemID string, name string) string { 54 | return fmt.Sprintf("%v_%v_%v_id", resourceID, itemID, name) 55 | } 56 | 57 | func (s *VideoStreamUserData) UpdateSessionData(c *gin.Context) error { 58 | session := sessions.Default(c) 59 | audioKey := s.makeKey(s.ResourceID, s.ItemID, "audio") 60 | subtitleKey := s.makeKey(s.ResourceID, s.ItemID, "subtitle") 61 | if s.SubtitleID == "" { 62 | session.Delete(subtitleKey) 63 | } else { 64 | session.Set(subtitleKey, s.SubtitleID) 65 | } 66 | if s.AudioID == "" { 67 | session.Delete(audioKey) 68 | } else { 69 | session.Set(audioKey, s.AudioID) 70 | } 71 | return session.Save() 72 | } 73 | 74 | type ExternalData struct { 75 | Poster string 76 | Tracks []ExternalTrack 77 | } 78 | 79 | type ExternalTrack struct { 80 | Src string 81 | SrcLang string 82 | Label string 83 | Default bool 84 | } 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-ui", 3 | "version": "1.0.0", 4 | "description": "Web UI v2", 5 | "private": true, 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack --mode=production", 9 | "start": "concurrently --kill-others \"npm run server\" \"npm run webpack-dev-server\"", 10 | "server": "air", 11 | "webpack-dev-server": "webpack-dev-server --mode=development" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "devDependencies": { 17 | "@babel/core": "^7.26.10", 18 | "@babel/preset-env": "^7.26.9", 19 | "@open-iframe-resizer/core": "^1.4.1", 20 | "@tailwindcss/postcss": "^4.1.4", 21 | "babel-loader": "^10.0.0", 22 | "compression-webpack-plugin": "^11.1.0", 23 | "concurrently": "^9.1.2", 24 | "copy-webpack-plugin": "^13.0.0", 25 | "crypto-js": "^4.2.0", 26 | "css-loader": "^7.1.2", 27 | "daisyui": "^5.0.28", 28 | "debug": "4.4.0", 29 | "favicons": "^7.2.0", 30 | "favicons-webpack-plugin": "^6.0.1", 31 | "hls.js": "^1.6.2", 32 | "mediaelement": "^7.0.7", 33 | "mini-css-extract-plugin": "^2.9.2", 34 | "postcss": "^8.5.3", 35 | "postcss-loader": "^8.1.1", 36 | "semver": "^7.7.1", 37 | "sha1": "^1.1.1", 38 | "style-loader": "^4.0.0", 39 | "stylelint": "^16.19.0", 40 | "stylelint-config-standard": "^38.0.0", 41 | "supertokens-web-js": "^0.15.0", 42 | "tailwindcss": "^4.1.4", 43 | "terser-webpack-plugin": "^5.3.14", 44 | "webpack": "^5.99.6", 45 | "webpack-cli": "^6.0.1", 46 | "webpack-dev-server": "^5.2.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": { 4 | optimize: { minify: true } 5 | }, 6 | }, 7 | }; -------------------------------------------------------------------------------- /pub/Sintel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webtor-io/web-ui/bcf56b9191083d04cc904b316488cd13b0029f93/pub/Sintel.jpg -------------------------------------------------------------------------------- /pub/Sintel.ru.srt: -------------------------------------------------------------------------------- 1 | 2 | 1 3 | 00:01:47,250 --> 00:01:50,500 4 | О! Это копьё с темным прошлым. 5 | 6 | 2 7 | 00:01:51,800 --> 00:01:55,800 8 | Его лезвие выпило немало невинной крови. 9 | 10 | 3 11 | 00:01:58,000 --> 00:02:01,450 12 | Только глупец путешествует в одиночку, 13 | да еще без оружия. 14 | 15 | 4 16 | 00:02:01,750 --> 00:02:04,800 17 | Тебе повезло, что осталась жива. 18 | 19 | 5 20 | 00:02:05,250 --> 00:02:06,300 21 | Спасибо. 22 | 23 | 6 24 | 00:02:07,500 --> 00:02:09,000 25 | Раскажи, 26 | 27 | 7 28 | 00:02:09,400 --> 00:02:13,800 29 | что привело тебя в страну 30 | хранителей? 31 | 32 | 8 33 | 00:02:15,000 --> 00:02:17,500 34 | Я ищу кое кого. 35 | 36 | 9 37 | 00:02:18,000 --> 00:02:22,200 38 | Кого-то очень дорогого? 39 | Родственную душу? 40 | 41 | 10 42 | 00:02:23,400 --> 00:02:25,000 43 | Дракона. 44 | 45 | 11 46 | 00:02:28,850 --> 00:02:31,750 47 | Опасное занятие для одиночки. 48 | 49 | 12 50 | 00:02:32,950 --> 00:02:35,870 51 | Сколько себя помню, 52 | я всегда была одна. 53 | 54 | 13 55 | 00:03:27,250 --> 00:03:30,500 56 | Уже почти всё. Шшшш... 57 | 58 | 14 59 | 00:03:30,750 --> 00:03:33,500 60 | Спокойно! 61 | 62 | 15 63 | 00:03:48,250 --> 00:03:52,250 64 | Спокойной ночи, Чешуйчик. 65 | 66 | 16 67 | 00:04:10,350 --> 00:04:13,850 68 | Взять, Чешуйчик! Давай! 69 | 70 | 17 71 | 00:04:25,250 --> 00:04:28,250 72 | Чешуйчик? 73 | 74 | 18 75 | 00:05:04,000 --> 00:05:07,500 76 | Да! Давай! 77 | 78 | 19 79 | 00:05:38,750 --> 00:05:42,000 80 | Чешуйчик! 81 | 82 | 20 83 | 00:07:25,850 --> 00:07:27,500 84 | У меня ничего не получилось. 85 | 86 | 21 87 | 00:07:32,800 --> 00:07:36,500 88 | Не получилось только понять, 89 | 90 | 22 91 | 00:07:37,800 --> 00:07:40,500 92 | что ты дошла до земли драконов, Синтэль. 93 | 94 | 23 95 | 00:07:40,850 --> 00:07:44,000 96 | И теперь ближе к цели, 97 | чем ты думаешь. 98 | 99 | 24 100 | 00:09:17,600 --> 00:09:19,500 101 | Чешуйчик! 102 | 103 | 25 104 | 00:10:21,600 --> 00:10:24,000 105 | Чешуйчик? 106 | 107 | 26 108 | 00:10:26,200 --> 00:10:29,800 109 | Чешуйчик... 110 | 111 | -------------------------------------------------------------------------------- /pub/Sintel.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webtor-io/web-ui/bcf56b9191083d04cc904b316488cd13b0029f93/pub/Sintel.torrent -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | #-------------------------------------------------------------------------------# 2 | # Qodana analysis is configured by qodana.yaml file # 3 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html # 4 | #-------------------------------------------------------------------------------# 5 | version: "1.0" 6 | 7 | #Specify inspection profile for code analysis 8 | profile: 9 | name: qodana.starter 10 | 11 | #Enable inspections 12 | #include: 13 | # - name: 14 | 15 | #Disable inspections 16 | #exclude: 17 | # - name: 18 | # paths: 19 | # - 20 | 21 | #Execute shell command before Qodana execution (Applied in CI/CD pipeline) 22 | #bootstrap: sh ./prepare-qodana.sh 23 | 24 | #Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) 25 | #plugins: 26 | # - id: #(plugin id can be found at https://plugins.jetbrains.com) 27 | 28 | #Specify Qodana linter for analysis (Applied in CI/CD pipeline) 29 | linter: jetbrains/qodana-go:latest 30 | -------------------------------------------------------------------------------- /services/abuse_store/client.go: -------------------------------------------------------------------------------- 1 | package abuse_store 2 | 3 | import ( 4 | "fmt" 5 | "google.golang.org/grpc/credentials/insecure" 6 | "sync" 7 | 8 | "github.com/urfave/cli" 9 | as "github.com/webtor-io/abuse-store/proto" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | const ( 14 | HostFlag = "abuse-host" 15 | PortFlag = "abuse-port" 16 | UseFlag = "use-abuse" 17 | ) 18 | 19 | func RegisterFlags(f []cli.Flag) []cli.Flag { 20 | return append(f, 21 | cli.StringFlag{ 22 | Name: HostFlag, 23 | Usage: "abuse store host", 24 | Value: "", 25 | EnvVar: "ABUSE_STORE_SERVICE_HOST", 26 | }, 27 | cli.IntFlag{ 28 | Name: PortFlag, 29 | Usage: "port of the redis service", 30 | Value: 50051, 31 | EnvVar: "ABUSE_STORE_SERVICE_PORT", 32 | }, 33 | cli.BoolFlag{ 34 | Name: UseFlag, 35 | Usage: "use abuse", 36 | EnvVar: "USE_ABUSE_STORE", 37 | }, 38 | ) 39 | } 40 | 41 | type Client struct { 42 | once sync.Once 43 | cl as.AbuseStoreClient 44 | err error 45 | host string 46 | port int 47 | conn *grpc.ClientConn 48 | } 49 | 50 | func New(c *cli.Context) *Client { 51 | if !c.Bool(UseFlag) { 52 | return nil 53 | } 54 | return &Client{ 55 | host: c.String(HostFlag), 56 | port: c.Int(PortFlag), 57 | } 58 | } 59 | 60 | func (s *Client) Get() (as.AbuseStoreClient, error) { 61 | s.once.Do(func() { 62 | addr := fmt.Sprintf("%s:%d", s.host, s.port) 63 | conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) 64 | if err != nil { 65 | s.err = err 66 | return 67 | } 68 | if err != nil { 69 | s.err = err 70 | return 71 | } 72 | s.conn = conn 73 | s.cl = as.NewAbuseStoreClient(conn) 74 | }) 75 | return s.cl, s.err 76 | } 77 | 78 | func (s *Client) Close() { 79 | if s.conn != nil { 80 | _ = s.conn.Close() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /services/claims/client.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "fmt" 5 | "google.golang.org/grpc/credentials/insecure" 6 | "sync" 7 | 8 | "github.com/urfave/cli" 9 | proto "github.com/webtor-io/claims-provider/proto" 10 | "google.golang.org/grpc" 11 | ) 12 | 13 | const ( 14 | claimsProviderHostFlag = "claims-provider-host" 15 | claimsProviderPortFlag = "claims-provider-port" 16 | ) 17 | 18 | func RegisterClientFlags(f []cli.Flag) []cli.Flag { 19 | return append(f, 20 | cli.StringFlag{ 21 | Name: claimsProviderHostFlag, 22 | Usage: "claims provider host", 23 | Value: "", 24 | EnvVar: "CLAIMS_PROVIDER_SERVICE_HOST", 25 | }, 26 | cli.IntFlag{ 27 | Name: claimsProviderPortFlag, 28 | Usage: "claims provider port", 29 | Value: 50051, 30 | EnvVar: "CLAIMS_PROVIDER_SERVICE_PORT", 31 | }, 32 | ) 33 | } 34 | 35 | type Client struct { 36 | once sync.Once 37 | cl proto.ClaimsProviderClient 38 | err error 39 | host string 40 | port int 41 | conn *grpc.ClientConn 42 | } 43 | 44 | func NewClient(c *cli.Context) *Client { 45 | return &Client{ 46 | host: c.String(claimsProviderHostFlag), 47 | port: c.Int(claimsProviderPortFlag), 48 | } 49 | } 50 | 51 | func (s *Client) Get() (proto.ClaimsProviderClient, error) { 52 | s.once.Do(func() { 53 | addr := fmt.Sprintf("%s:%d", s.host, s.port) 54 | conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) 55 | if err != nil { 56 | s.err = err 57 | return 58 | } 59 | s.conn = conn 60 | s.cl = proto.NewClaimsProviderClient(conn) 61 | }) 62 | return s.cl, s.err 63 | } 64 | 65 | func (s *Client) Close() { 66 | if s.conn != nil { 67 | _ = s.conn.Close() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /services/common.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/urfave/cli" 7 | ) 8 | 9 | var SHA1R = regexp.MustCompile("(?i)[0-9a-f]{5,40}") 10 | 11 | var ( 12 | DomainFlag = "domain" 13 | DemoMagnetFlag = "demo-magnet" 14 | DemoTorrentFlag = "demo-torrent" 15 | SMTPHostFlag = "smtp-host" 16 | SMTPUserFlag = "smtp-user" 17 | SMTPPassFlag = "smtp-pass" 18 | SMTPPortFlag = "smtp-port" 19 | SMTPSecureFlag = "smtp-secure" 20 | ) 21 | 22 | func RegisterFlags(f []cli.Flag) []cli.Flag { 23 | f = append(f, 24 | cli.StringFlag{ 25 | Name: DomainFlag, 26 | Usage: "domain", 27 | Value: "http://localhost:8080", 28 | EnvVar: "DOMAIN", 29 | }, 30 | cli.StringFlag{ 31 | Name: DemoMagnetFlag, 32 | Usage: "demo magnet", 33 | Value: "magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10", 34 | EnvVar: "DEMO_MAGNET", 35 | }, 36 | cli.StringFlag{ 37 | Name: DemoTorrentFlag, 38 | Usage: "demo torrent", 39 | Value: "https://webtorrent.io/torrents/sintel.torrent", 40 | EnvVar: "DEMO_TORRENT", 41 | }, 42 | cli.StringFlag{ 43 | Name: SMTPHostFlag, 44 | Usage: "smtp host", 45 | EnvVar: "SMTP_HOST", 46 | }, 47 | cli.StringFlag{ 48 | Name: SMTPUserFlag, 49 | Usage: "smtp user", 50 | EnvVar: "SMTP_USER", 51 | }, 52 | cli.StringFlag{ 53 | Name: SMTPPassFlag, 54 | Usage: "smtp pass", 55 | EnvVar: "SMTP_PASS", 56 | }, 57 | cli.IntFlag{ 58 | Name: SMTPPortFlag, 59 | Usage: "smtp port", 60 | EnvVar: "SMTP_PORT", 61 | Value: 465, 62 | }, 63 | cli.BoolTFlag{ 64 | Name: SMTPSecureFlag, 65 | Usage: "smtp secure", 66 | EnvVar: "SMTP_SECURE", 67 | }, 68 | ) 69 | 70 | return f 71 | } 72 | -------------------------------------------------------------------------------- /services/embed/domain_settings.go: -------------------------------------------------------------------------------- 1 | package embed 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/webtor-io/web-ui/models" 6 | "time" 7 | 8 | "github.com/go-pg/pg/v10" 9 | cs "github.com/webtor-io/common-services" 10 | "github.com/webtor-io/lazymap" 11 | "github.com/webtor-io/web-ui/services/claims" 12 | ) 13 | 14 | type DomainSettings struct { 15 | lazymap.LazyMap[*DomainSettingsData] 16 | pg *cs.PG 17 | claims *claims.Claims 18 | } 19 | type DomainSettingsData struct { 20 | Ads bool `json:"ads"` 21 | } 22 | 23 | func NewDomainSettings(pg *cs.PG, claims *claims.Claims) *DomainSettings { 24 | return &DomainSettings{ 25 | pg: pg, 26 | claims: claims, 27 | LazyMap: lazymap.New[*DomainSettingsData](&lazymap.Config{ 28 | Expire: time.Minute, 29 | ErrorExpire: 10 * time.Second, 30 | }), 31 | } 32 | } 33 | 34 | func (s *DomainSettings) Get(domain string) (*DomainSettingsData, error) { 35 | return s.LazyMap.Get(domain, func() (*DomainSettingsData, error) { 36 | if s.pg == nil || s.pg.Get() == nil || s.claims == nil { 37 | return &DomainSettingsData{}, nil 38 | } 39 | db := s.pg.Get() 40 | em := &models.EmbedDomain{} 41 | err := db.Model(em). 42 | Relation("User"). 43 | Where("embed_domain.domain = ?", domain). 44 | Select() 45 | if errors.Is(err, pg.ErrNoRows) { 46 | return &DomainSettingsData{Ads: true}, nil 47 | } else if err != nil { 48 | return nil, err 49 | } 50 | cl, err := s.claims.Get(em.User.Email) 51 | if err != nil { 52 | return nil, err 53 | } 54 | return &DomainSettingsData{Ads: em.Ads || !cl.Claims.Embed.NoAds}, nil 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /services/geoip/helper.go: -------------------------------------------------------------------------------- 1 | package geoip 2 | 3 | type Helper struct{} 4 | 5 | func NewHelper() *Helper { 6 | return &Helper{} 7 | } 8 | 9 | func (h *Helper) InCountries(data *Data, countries ...string) bool { 10 | if data == nil { 11 | return false 12 | } 13 | for _, country := range countries { 14 | if data.Country == country { 15 | return true 16 | 17 | } 18 | } 19 | return false 20 | } 21 | -------------------------------------------------------------------------------- /services/job/storage.go: -------------------------------------------------------------------------------- 1 | package job 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | cs "github.com/webtor-io/common-services" 8 | ) 9 | 10 | type State struct { 11 | ID string 12 | TTL time.Duration 13 | } 14 | 15 | type Storage interface { 16 | Pub(ctx context.Context, id string, l LogItem) error 17 | Sub(ctx context.Context, id string) (res chan LogItem, err error) 18 | GetState(ctx context.Context, id string) (state *State, ok bool, err error) 19 | Drop(ctx context.Context, id string) (err error) 20 | } 21 | 22 | type NilStorage struct{} 23 | 24 | func (s *NilStorage) Pub(_ context.Context, _ string, _ LogItem) error { 25 | return nil 26 | } 27 | 28 | func (s *NilStorage) Drop(_ context.Context, _ string) (err error) { 29 | return 30 | } 31 | 32 | func (s *NilStorage) Sub(_ context.Context, _ string) (res chan LogItem, err error) { 33 | return 34 | } 35 | 36 | func (s *NilStorage) GetState(_ context.Context, _ string) (state *State, ok bool, err error) { 37 | return nil, false, nil 38 | } 39 | 40 | var _ Storage = (*NilStorage)(nil) 41 | 42 | func NewStorage(rc *cs.RedisClient, prefix string) Storage { 43 | cl := rc.Get() 44 | if cl == nil { 45 | return &NilStorage{} 46 | } 47 | return NewRedis(cl, prefix) 48 | } 49 | -------------------------------------------------------------------------------- /services/obfuscator/helpers.go: -------------------------------------------------------------------------------- 1 | package obfuscator 2 | 3 | import "math/rand" 4 | 5 | func randomRange(lower, upper int) int { 6 | return lower + rand.Intn(upper-lower+1) 7 | } 8 | 9 | func strShuffle(str string) string { 10 | inRune := []rune(str) 11 | rand.Shuffle(len(inRune), func(i, j int) { 12 | inRune[i], inRune[j] = inRune[j], inRune[i] 13 | }) 14 | 15 | return string(inRune) 16 | } 17 | -------------------------------------------------------------------------------- /services/parse_torrent_name/field_type.go: -------------------------------------------------------------------------------- 1 | package parsetorrentname 2 | 3 | type FieldType string 4 | 5 | const ( 6 | FieldTypeTitle FieldType = "title" 7 | FieldTypeExtraTitle FieldType = "extra_title" 8 | FieldTypeSeason FieldType = "season" 9 | FieldTypeEpisode FieldType = "episode" 10 | FieldTypeYear FieldType = "year" 11 | FieldTypeResolution FieldType = "resolution" 12 | FieldTypeBitrate FieldType = "bitrate" 13 | FieldTypeColorDepth FieldType = "color_depth" 14 | FieldTypeQuality FieldType = "quality" 15 | FieldTypeCodec FieldType = "codec" 16 | FieldTypeAudio FieldType = "audio" 17 | FieldTypeRegion FieldType = "region" 18 | FieldTypeSize FieldType = "size" 19 | FieldTypeWebsite FieldType = "website" 20 | FieldTypeLanguage FieldType = "language" 21 | FieldTypeSBS FieldType = "sbs" 22 | FieldTypeContainer FieldType = "container" 23 | FieldTypeGroup FieldType = "group" 24 | FieldTypeStudio FieldType = "studio" 25 | FieldTypeExtended FieldType = "extended" 26 | FieldTypeHardcoded FieldType = "hardcoded" 27 | FieldTypeProper FieldType = "proper" 28 | FieldTypeRepack FieldType = "repack" 29 | FieldTypeWidescreen FieldType = "widescreen" 30 | FieldTypeUnrated FieldType = "unrated" 31 | FieldType3D FieldType = "threed" 32 | FieldTypeAVC FieldType = "avc" 33 | FieldTypePorn FieldType = "porn" 34 | FieldTypeSplitScenes FieldType = "split_scenes" 35 | FieldTypeScene FieldType = "scene" 36 | FieldTypeDubbing FieldType = "dubbing" 37 | FieldTypeUnknown FieldType = "unknown" 38 | ) 39 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_000.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Walking Dead", 3 | "season": 5, 4 | "episode": 3, 5 | "resolution": "720p", 6 | "quality": "HDTV", 7 | "codec": "x264", 8 | "group": "ASAP[ettv]" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_001.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Hercules", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "BrRip", 6 | "codec": "H264" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_002.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Dawn of the Planet of the Apes", 3 | "year": 2014, 4 | "quality": "HDRip", 5 | "codec": "XViD", 6 | "group": "EVO" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_003.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Big Bang Theory", 3 | "season": 8, 4 | "episode": 6, 5 | "quality": "HDTV", 6 | "codec": "XviD", 7 | "group": "LOL [eztv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_004.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "22 Jump Street", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "BrRip", 6 | "codec": "x264" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_005.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Hercules", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "WEB-DL", 6 | "codec": "H264", 7 | "audio": "DD5.1", 8 | "group": "RARBG", 9 | "extended": true 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_006.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Hercules", 3 | "year": 2014, 4 | "quality": "HDRip", 5 | "codec": "XViD", 6 | "group": "juggs[ETRG]", 7 | "extended": true 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_007.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Hercules", 3 | "year": 2014, 4 | "quality": "WEBDL", 5 | "codec": "XviD", 6 | "group": "MAX" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_008.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "WWE Hell in a Cell", 3 | "year": 2014, 4 | "quality": "PPV WEB-DL", 5 | "codec": "x264", 6 | "group": "WD -={SPARROW}=-" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_009.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "UFC 179", 3 | "quality": "PPV.HDTV", 4 | "codec": "x264", 5 | "group": "Ebi[rartv]" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_010.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Marvels Agents of S H I E L D", 3 | "season": 2, 4 | "episode": 5, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "KILLERS [eztv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_011.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "X-Men Days of Future Past", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "WEB-DL", 6 | "codec": "H264", 7 | "audio": "DD5.1", 8 | "group": "RARBG" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_012.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Guardians Of The Galaxy", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "HDCAM", 6 | "codec": "x264", 7 | "group": "JYK", 8 | "region": "6" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_013.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Marvel's Agents of S H I E L D", 3 | "season": 2, 4 | "episode": 1, 5 | "resolution": "1080p", 6 | "quality": "WEB-DL", 7 | "audio": "DD5.1" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_014.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Marvels Agents of S H I E L D", 3 | "season": 2, 4 | "episode": 6, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "KILLERS[ettv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_015.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Guardians of the Galaxy", 3 | "year": 2014, 4 | "quality": "CamRip" 5 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_016.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Walking Dead", 3 | "season": 5, 4 | "episode": 3, 5 | "resolution": "1080p", 6 | "quality": "WEB-DL", 7 | "codec": "H.264", 8 | "audio": "DD5.1", 9 | "group": "Cyphanix[rartv]" 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_017.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Brave", 3 | "year": 2012, 4 | "quality": "DVDRip", 5 | "codec": "XViD", 6 | "audio": "LiNE", 7 | "group": "UNiQUE", 8 | "region": "5" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_018.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Lets Be Cops", 3 | "year": 2014, 4 | "quality": "BRRip", 5 | "codec": "XViD", 6 | "group": "juggs[ETRG]" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_019.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "These Final Hours", 3 | "year": 2013, 4 | "quality": "WBBRip", 5 | "codec": "XViD" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_020.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Downton Abbey", 3 | "season": 5, 4 | "episode": 6, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "FoV [eztv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_021.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Annabelle", 3 | "year": 2014, 4 | "quality": "HDRip", 5 | "codec": "XViD", 6 | "audio": "AC3", 7 | "group": "juggs[ETRG]", 8 | "hardcoded": true 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_022.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Lucy", 3 | "year": 2014, 4 | "quality": "HDRip", 5 | "codec": "XViD", 6 | "group": "juggs[ETRG]", 7 | "hardcoded": true 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_023.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Flash", 3 | "season": 1, 4 | "episode": 4, 5 | "year": 2014, 6 | "quality": "HDTV", 7 | "codec": "x264", 8 | "group": "FUM[ettv]" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_024.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "South Park", 3 | "season": 18, 4 | "episode": 5, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "KILLERS [eztv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_025.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Flash", 3 | "season": 1, 4 | "episode": 3, 5 | "year": 2014, 6 | "quality": "HDTV", 7 | "codec": "x264", 8 | "group": "LOL[ettv]" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_026.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Flash", 3 | "season": 1, 4 | "episode": 1, 5 | "year": 2014, 6 | "quality": "HDTV", 7 | "codec": "x264", 8 | "group": "LOL[ettv]" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_027.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Lucy", 3 | "year": 2014, 4 | "quality": "WEBRip", 5 | "audio": "Dual-Audio", 6 | "size": "1400Mb" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_028.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Teenage Mutant Ninja Turtles", 3 | "year": 2014, 4 | "quality": "HdRip" 5 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_029.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Teenage Mutant Ninja Turtles", 3 | "year": 2014 4 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_030.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Simpsons", 3 | "season": 26, 4 | "episode": 5, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "LOL [eztv]", 8 | "proper": true 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_031.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "2047 - Sights of Death", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "BrRip", 6 | "codec": "x264" 7 | } 8 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_032.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Two and a Half Men", 3 | "season": 12, 4 | "episode": 1, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "LOL [eztv]", 8 | "repack": true 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_033.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Dinosaur 13", 3 | "year": 2014, 4 | "quality": "WEBrip", 5 | "codec": "XviD", 6 | "audio": "AC3" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_034.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Teenage Mutant Ninja Turtles", 3 | "year": 2014, 4 | "quality": "HDRip", 5 | "codec": "XviD", 6 | "audio": "MP3", 7 | "group": "RARBG" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_035.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Dawn Of The Planet of The Apes", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "WEB-DL", 6 | "codec": "H264", 7 | "audio": "DD51", 8 | "group": "RARBG" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_036.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Teenage Mutant Ninja Turtles", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "HDRip", 6 | "codec": "x264", 7 | "audio": "AC3.5.1", 8 | "group": "RARBG" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_037.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Gotham", 3 | "season": 1, 4 | "episode": 5, 5 | "quality": "WEB-DL", 6 | "codec": "x264", 7 | "audio": "AAC" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_038.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Into The Storm", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "WEB-DL", 6 | "codec": "H264", 7 | "audio": "AAC2.0", 8 | "group": "RARBG" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_039.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Lucy", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "WEBRip", 6 | "audio": "Dual-Audio" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_040.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Into The Storm", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "BRRip", 6 | "codec": "x264", 7 | "audio": "DTS", 8 | "group": "JYK" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_041.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Sin City A Dame to Kill For", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "BluRay", 6 | "codec": "x264", 7 | "group": "SPARKS" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_042.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "WWE Monday Night Raw 3rd Nov", 3 | "year": 2014, 4 | "quality": "HDTV", 5 | "codec": "x264", 6 | "group": "Sir Paul" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_043.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Jack And The Cuckoo-Clock Heart", 3 | "year": 2013, 4 | "quality": "BRRip", 5 | "codec": "XViD" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_044.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "WWE Hell in a Cell", 3 | "year": 2014, 4 | "quality": "HDTV", 5 | "codec": "x264" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_045.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Dracula Untold", 3 | "year": 2014, 4 | "quality": "TS", 5 | "codec": "XViD", 6 | "audio": "AC3", 7 | "group": "SiMPLE" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_046.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Missing", 3 | "season": 1, 4 | "episode": 1, 5 | "quality": "HDTV", 6 | "codec": "x264", 7 | "group": "FoV [eztv]" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_047.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Doctor Who", 3 | "season": 8, 4 | "episode": 11, 5 | "year": 2005, 6 | "resolution": "720p", 7 | "quality": "HDTV", 8 | "codec": "x264", 9 | "group": "FoV[rartv]" 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_048.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Gotham", 3 | "season": 1, 4 | "episode": 7, 5 | "quality": "WEB-DL", 6 | "codec": "x264", 7 | "audio": "AAC" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_049.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "One Shot", 3 | "year": 2014, 4 | "quality": "DVDRip", 5 | "codec": "XViD", 6 | "group": "ViCKY" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_050.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Shaukeens", 3 | "year": 2014, 4 | "quality": "DvDScr", 5 | "codec": "x264", 6 | "audio": "AAC" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_051.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Shaukeens", 3 | "year": 2014, 4 | "quality": "DvDScr", 5 | "codec": "x264" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_052.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Annabelle", 3 | "year": 2014, 4 | "resolution": "1080p", 5 | "quality": "WEBRip", 6 | "codec": "x264", 7 | "audio": "AAC.2.0", 8 | "group": "RARBG", 9 | "hardcoded": true, 10 | "proper": true 11 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_053.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Interstellar", 3 | "year": 2014, 4 | "quality": "CAM", 5 | "codec": "x264", 6 | "audio": "AAC", 7 | "group": "CPG" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_054.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Guardians of the Galaxy", 3 | "year": 2014, 4 | "quality": "DVDRip", 5 | "audio": "Dual Audio", 6 | "container": "AVI" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_055.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Eliza Graves", 3 | "year": 2014, 4 | "resolution": "720p", 5 | "quality": "WEB-DL", 6 | "codec": "x264", 7 | "audio": "Dual Audio", 8 | "container": "MKV" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_056.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "WWE Monday Night Raw", 3 | "year": 2014, 4 | "quality": "PDTV", 5 | "codec": "x264", 6 | "group": "RKOFAN1990 -={SPARR", 7 | "widescreen": true 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_057.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Sons of Anarchy", 3 | "season": 1, 4 | "episode": 3 5 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_058.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "doctor who", 3 | "season": 8, 4 | "episode": 12, 5 | "year": 2005, 6 | "resolution": "720p", 7 | "quality": "hdtv", 8 | "codec": "x264", 9 | "group": "fov" 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_059.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "breaking bad", 3 | "season": 1, 4 | "episode": 1, 5 | "resolution": "720p", 6 | "quality": "bluray", 7 | "codec": "x264", 8 | "group": "reward" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_060.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Game of Thrones", 3 | "season": 4, 4 | "episode": 3 5 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_061.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "sons of anarchy", 3 | "season": 5, 4 | "episode": 10, 5 | "resolution": "480p", 6 | "quality": "BluRay", 7 | "codec": "x264", 8 | "group": "GAnGSteR", 9 | "website": "720pMkv.Com" 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_062.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Sons of Anarchy", 3 | "season": 7, 4 | "episode": 7, 5 | "resolution": "720p", 6 | "quality": "HDTV", 7 | "codec": "X264", 8 | "group": "DIMENSION", 9 | "website": "www.Speed.cd" 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_063.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Community", 3 | "season": 2, 4 | "episode": 20, 5 | "resolution": "720p", 6 | "language": "rus.eng" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_064.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Jungle Book", 3 | "year": 2016, 4 | "resolution": "1080p", 5 | "quality": "BRRip", 6 | "codec": "x264", 7 | "audio": "AAC", 8 | "group": "ETRG", 9 | "sbs": "SBS", 10 | "3d": true 11 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_065.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Ant-Man", 3 | "year": 2015, 4 | "resolution": "1080p", 5 | "quality": "BRRip", 6 | "codec": "x264", 7 | "audio": "AAC", 8 | "group": "m2g", 9 | "sbs": "Half-SBS", 10 | "3d": true 11 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_066.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Ice Age Collision Course", 3 | "year": 2016, 4 | "resolution": "720p", 5 | "quality": "HDRIP", 6 | "codec": "X264", 7 | "audio": "AC3" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_067.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Red Sonja Queen Of Plagues", 3 | "year": 2016, 4 | "quality": "BDRip", 5 | "codec": "x264", 6 | "group": "W4F[PRiME]" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_068.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Purge: Election Year", 3 | "year": 2016, 4 | "resolution": "720p", 5 | "quality": "HDRiP", 6 | "hardcoded": true, 7 | "size": "900MB" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_069.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "War Dogs", 3 | "year": 2016, 4 | "quality": "HDTS", 5 | "size": "600MB" 6 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_070.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Hateful Eight", 3 | "year": 2015, 4 | "resolution": "720p", 5 | "quality": "BluRay", 6 | "codec": "x265", 7 | "size": "999MB" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_071.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Boss", 3 | "year": 2016, 4 | "resolution": "720p", 5 | "quality": "BRRip", 6 | "codec": "x264", 7 | "audio": "AAC", 8 | "group": "ETRG", 9 | "unrated": true 10 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_072.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Return To Snowy River", 3 | "year": 1988, 4 | "quality": "DVDRip", 5 | "codec": "x264", 6 | "group": "W4F[PRiME]" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_073.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Akira", 3 | "year": 2016, 4 | "resolution": "720p", 5 | "codec": "x264", 6 | "audio": "AC3 - 5.1" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_074.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Ben Hur", 3 | "year": 2016, 4 | "quality": "TELESYNC", 5 | "codec": "x264", 6 | "audio": "AC3" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_075.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "The Secret Life of Pets", 3 | "year": 2016, 4 | "quality": "HDRiP", 5 | "codec": "x264", 6 | "audio": "AAC-LC", 7 | "group": "LEGi0N" 8 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_076.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Clockwork Planet", 3 | "episode": 10, 4 | "resolution": "480p", 5 | "container": "mkv", 6 | "website": "HorribleSubs" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_077.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Detective Conan", 3 | "episode": 862, 4 | "resolution": "1080p", 5 | "container": "mkv", 6 | "website": "HorribleSubs" 7 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_078.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "thomas and friends", 3 | "season": 19, 4 | "episode": 9, 5 | "quality": "hdtv", 6 | "codec": "x264", 7 | "group": "w4f[eztv]", 8 | "container": "mkv" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_079.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Blade Runner 2049", 3 | "year": 2017, 4 | "resolution": "1080p", 5 | "quality": "WEB-DL", 6 | "codec": "H264", 7 | "audio": "DD5.1", 8 | "group": "[rarbg.to]" 9 | } 10 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_080.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "2012", 3 | "year": 2009, 4 | "resolution": "1080p", 5 | "audio": "Dual Audio" 6 | } 7 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_081.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "2012", 3 | "year": 2009, 4 | "resolution": "1080p", 5 | "quality": "BrRip", 6 | "codec": "x264", 7 | "size": "1.7GB" 8 | } 9 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_082.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "2012", 3 | "year": 2009, 4 | "resolution": "720p", 5 | "quality": "BluRay", 6 | "codec": "x264", 7 | "audio": "Dual Audio" 8 | } 9 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_083.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Little Girls Love Big Ducks 11", 3 | "scene": 1, 4 | "year": 2024, 5 | "resolution": "540p", 6 | "quality": "WEB-DL", 7 | "studio": "Crave Media", 8 | "porn": true, 9 | "split_scenes": true 10 | } 11 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_084.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Deadpool & Wolverine", 3 | "year": 2024, 4 | "resolution": "4K", 5 | "quality": "WEB-DL", 6 | "bitrate": "768Kbps", 7 | "color_depth": "SDR", 8 | "audio": "AAC", 9 | "container": "mkv", 10 | "website": "www.1TamilMV.tf" 11 | } 12 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_085.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Delicious", 3 | "year": 2025, 4 | "quality": "WEB-DLRip", 5 | "container": "mkv", 6 | "codec": "x264", 7 | "studio": "NF", 8 | "dubbing": "MVO" 9 | } 10 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_086.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Bolshaya dvadcatka", 3 | "year": 2025, 4 | "quality": "WEB-DLRip", 5 | "container": "mkv", 6 | "studio": "AMZN", 7 | "avc": true 8 | } 9 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_087.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "How to Lose a Guy in 10 Days", 3 | "extra_title": "Как отделаться от парня за 10 дней", 4 | "year": 2003, 5 | "quality": "BDRip", 6 | "container": "mkv" 7 | } 8 | -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_088.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Этернавт - The Eternaut", 3 | "season": 1, 4 | "episode": 1, 5 | "year": 2025, 6 | "resolution": "1080p", 7 | "quality": "WEB-DL", 8 | "container": "mkv" 9 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_089.json: -------------------------------------------------------------------------------- 1 | { 2 | "Title": "Havoc", 3 | "year": 2025, 4 | "resolution": "1080p", 5 | "bitrate": "768Kbps", 6 | "codec": "x264", 7 | "website": "www.1TamilBlasters.moi", 8 | "size": "8.2GB", 9 | "container": "mkv", 10 | "avc": true 11 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/testdata/golden_file_090.json: -------------------------------------------------------------------------------- 1 | { 2 | "website": "designcode.io" 3 | } -------------------------------------------------------------------------------- /services/parse_torrent_name/transformer.go: -------------------------------------------------------------------------------- 1 | package parsetorrentname 2 | 3 | import "strings" 4 | 5 | type Transformer interface { 6 | Transform(val string) (string, error) 7 | } 8 | 9 | type ReplaceTransformer struct { 10 | replacements map[string]string 11 | trimSuffixes []string 12 | trimPrefixes []string 13 | } 14 | 15 | func (t *ReplaceTransformer) Transform(val string) (string, error) { 16 | for k, v := range t.replacements { 17 | val = strings.Replace(val, k, v, -1) 18 | } 19 | for _, s := range t.trimSuffixes { 20 | val = strings.TrimSuffix(val, s) 21 | } 22 | for _, p := range t.trimPrefixes { 23 | val = strings.TrimPrefix(val, p) 24 | } 25 | val = strings.TrimSpace(val) 26 | return val, nil 27 | } 28 | 29 | func NewReplaceTransformer(replacements map[string]string, trimSuffixes []string, trimPrefixes []string) *ReplaceTransformer { 30 | return &ReplaceTransformer{ 31 | replacements: replacements, 32 | trimSuffixes: trimSuffixes, 33 | trimPrefixes: trimPrefixes, 34 | } 35 | } 36 | 37 | var titleTransformer = NewReplaceTransformer(map[string]string{ 38 | "_": " ", 39 | ".": " ", 40 | }, []string{ 41 | "(", 42 | "[", 43 | "-", 44 | "--", 45 | "---", 46 | }, []string{ 47 | ")", 48 | "]", 49 | "-", 50 | "--", 51 | "---", 52 | }) 53 | -------------------------------------------------------------------------------- /services/umami/helper.go: -------------------------------------------------------------------------------- 1 | package umami 2 | 3 | import ( 4 | "github.com/urfave/cli" 5 | ) 6 | 7 | var ( 8 | UseFlag = "umami-use" 9 | SrcFlag = "umami-src" 10 | WebsiteIDFlag = "umami-website-id" 11 | HostUrlFlag = "umami-host-url" 12 | ) 13 | 14 | func RegisterFlags(f []cli.Flag) []cli.Flag { 15 | return append(f, 16 | cli.BoolFlag{ 17 | Name: UseFlag, 18 | Usage: "use umami", 19 | EnvVar: "USE_UMAMI", 20 | }, 21 | cli.StringFlag{ 22 | Name: WebsiteIDFlag, 23 | Usage: "umami website-id", 24 | EnvVar: "UMAMI_WEBSITE_ID", 25 | }, 26 | cli.StringFlag{ 27 | Name: HostUrlFlag, 28 | Usage: "umami host url", 29 | EnvVar: "UMAMI_HOST_URL", 30 | }, 31 | ) 32 | } 33 | 34 | type Helper struct { 35 | use bool 36 | WebsiteID string `json:"website_id,omitempty"` 37 | HostURL string `json:"host_url,omitempty"` 38 | } 39 | 40 | func NewHelper(cli *cli.Context) *Helper { 41 | return &Helper{ 42 | use: cli.Bool(UseFlag), 43 | WebsiteID: cli.String(WebsiteIDFlag), 44 | HostURL: cli.String(HostUrlFlag), 45 | } 46 | } 47 | 48 | func (s *Helper) UseUmami() bool { 49 | return s.use 50 | } 51 | 52 | func (s *Helper) UmamiConfig() *Helper { 53 | return s 54 | } 55 | -------------------------------------------------------------------------------- /services/web/context.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/webtor-io/web-ui/handlers/geo" 6 | "github.com/webtor-io/web-ui/handlers/session" 7 | "github.com/webtor-io/web-ui/services/api" 8 | "github.com/webtor-io/web-ui/services/auth" 9 | "github.com/webtor-io/web-ui/services/claims" 10 | "github.com/webtor-io/web-ui/services/geoip" 11 | ) 12 | 13 | type Context struct { 14 | Data any 15 | CSRF string 16 | SessionID string 17 | Err error 18 | User *auth.User 19 | Claims *claims.Data 20 | Geo *geoip.Data 21 | ApiClaims *api.Claims 22 | ginCtx *gin.Context 23 | } 24 | 25 | func (c *Context) WithData(obj any) *Context { 26 | nc := *c 27 | nc.Data = obj 28 | return &nc 29 | } 30 | 31 | func (c *Context) WithErr(err error) *Context { 32 | nc := *c 33 | nc.Err = err 34 | return &nc 35 | } 36 | 37 | func (s *Context) GetGinContext() *gin.Context { 38 | return s.ginCtx 39 | } 40 | 41 | func NewContext(c *gin.Context) *Context { 42 | user := auth.GetUserFromContext(c) 43 | cl := claims.GetFromContext(c) 44 | sess := session.GetFromContext(c) 45 | geoData := geo.GetFromContext(c) 46 | aCl := api.GetClaimsFromContext(c) 47 | 48 | return &Context{ 49 | CSRF: sess.CSRF, 50 | User: user, 51 | Claims: cl, 52 | ApiClaims: aCl, 53 | SessionID: sess.ID, 54 | Geo: geoData, 55 | ginCtx: c, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /services/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/pkg/errors" 10 | "github.com/urfave/cli" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | const ( 16 | webHostFlag = "host" 17 | webPortFlag = "port" 18 | ) 19 | 20 | func RegisterFlags(f []cli.Flag) []cli.Flag { 21 | return append(f, 22 | cli.StringFlag{ 23 | Name: webHostFlag, 24 | Usage: "listening host", 25 | Value: "", 26 | EnvVar: "WEB_HOST", 27 | }, 28 | cli.IntFlag{ 29 | Name: webPortFlag, 30 | Usage: "http listening port", 31 | Value: 8080, 32 | EnvVar: "WEB_PORT", 33 | }, 34 | ) 35 | } 36 | 37 | type Web struct { 38 | host string 39 | port int 40 | ln net.Listener 41 | r *gin.Engine 42 | } 43 | 44 | func (s *Web) Serve() error { 45 | addr := fmt.Sprintf("%s:%d", s.host, s.port) 46 | ln, err := net.Listen("tcp", addr) 47 | s.ln = ln 48 | if err != nil { 49 | return errors.Wrap(err, "failed to web listen to tcp connection") 50 | } 51 | log.Infof("serving web at %v", addr) 52 | return http.Serve(s.ln, s.r) 53 | } 54 | 55 | func (s *Web) Close() { 56 | log.Info("closing web") 57 | defer func() { 58 | log.Info("web closed") 59 | }() 60 | if s.ln != nil { 61 | _ = s.ln.Close() 62 | } 63 | } 64 | 65 | func New(c *cli.Context, r *gin.Engine) (*Web, error) { 66 | r.UseRawPath = true 67 | 68 | return &Web{ 69 | host: c.String(webHostFlag), 70 | port: c.Int(webPortFlag), 71 | r: r, 72 | }, nil 73 | } 74 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | purge: ['./templates/**/*.html', './assets/src/**/*.js'], 4 | content: [], 5 | theme: { 6 | fontFamily: { 7 | 'baskerville': ['Libre Baskerville', 'serif'], 8 | }, 9 | extend: { 10 | minWidth: { 11 | '80': '20rem', 12 | }, 13 | }, 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /templates/layouts/embed/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Webtor Player SDK Example 5 | 6 | 7 | 8 | 17 | 18 | 19 | {{ template "main" . }} 20 | 21 | 22 | -------------------------------------------------------------------------------- /templates/partials/button.html: -------------------------------------------------------------------------------- 1 | {{ define "button" }} 2 |
3 | 4 | 5 | 9 |
10 | {{ end }} 11 | -------------------------------------------------------------------------------- /templates/partials/extend_base.html: -------------------------------------------------------------------------------- 1 | {{ define "head_extra" }}{{ end }} 2 | {{ define "get_extra" }}{{ end }} 3 | {{ define "embed_extra" }}{{ end }} 4 | {{ define "promo" }}{{ end }} 5 | {{ define "promo_example" }} 6 | Get 25% off on ad removal and extra bandwidth until January 1, 2025. 7 | {{ end }} 8 | {{ define "get_ads" }}{{ end }} 9 | {{ define "embed_ads" }}{{ end }} 10 | -------------------------------------------------------------------------------- /templates/partials/file.html: -------------------------------------------------------------------------------- 1 | {{ define "file" }} 2 | {{ with .Data }} 3 |

# {{ .Item.Name }}

4 |
5 | {{ template "stream_button" $ }} 6 | {{ template "button" makeFileDownload $ . }} 7 |
8 |
9 | {{ end }} 10 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer"}} 2 | 29 |
30 | 31 |
32 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/icons.html: -------------------------------------------------------------------------------- 1 | {{ define "icons" }} 2 | {{ if eq . "download" }} 3 | 4 | 5 | 6 | {{ else if eq . "preview" }} 7 | 8 | 9 | 10 | 11 | {{ else if eq . "stream" }} 12 | 13 | 14 | 15 | {{ end }} 16 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/library/button.html: -------------------------------------------------------------------------------- 1 | {{ define "library/button" }} 2 | {{ if .User | hasAuth }} 3 | {{ with .Data.Resource }} 4 | {{ if .InLibrary }} 5 |
6 | 7 | 10 |
11 | {{ else }} 12 |
13 | 14 | 17 |
18 | {{ end }} 19 | {{ end }} 20 | {{ else }} 21 | 26 | {{ end }} 27 | {{ end }} 28 | -------------------------------------------------------------------------------- /templates/partials/library/list.html: -------------------------------------------------------------------------------- 1 | {{ define "library/list"}} 2 | {{ if eq .Data.Args.Section "torrents" }} 3 |
4 | {{ template "library/torrent_list" .Data.Items }} 5 |
6 | {{ else }} 7 |
8 | {{ template "library/video_list" .Data.Items }} 9 |
10 | {{ end }} 11 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/library/menu.html: -------------------------------------------------------------------------------- 1 | {{ define "library/menu" }} 2 |
3 | {{ range . }} 4 | {{ .Title }} 5 | {{ end }} 6 |
7 | {{ end }} 8 | -------------------------------------------------------------------------------- /templates/partials/library/sort.html: -------------------------------------------------------------------------------- 1 | {{ define "library/sort"}} 2 |
3 | 8 | 9 |
10 | {{ end }} 11 | -------------------------------------------------------------------------------- /templates/partials/library/stars.html: -------------------------------------------------------------------------------- 1 | {{ define "library/stars" }} 2 |
3 | {{ range . }} 4 | {{ if eq .Value 0.0 }} 5 |
6 | {{ else }} 7 | {{ if .HalfStep }} 8 |
9 | {{ else }} 10 |
11 | {{ end }} 12 | {{ end }} 13 | {{ end }} 14 |
15 | {{ end }} 16 | -------------------------------------------------------------------------------- /templates/partials/library/torrent_list.html: -------------------------------------------------------------------------------- 1 | {{ define "library/torrent_list" }} 2 | 7 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/library/video_list.html: -------------------------------------------------------------------------------- 1 | {{ define "library/video_list" }} 2 | 31 | {{ end }} -------------------------------------------------------------------------------- /templates/partials/stream_button.html: -------------------------------------------------------------------------------- 1 | {{ define "stream_button" }} 2 | {{ with .Data }} 3 | {{ if eq .Item.MediaFormat "video" }} 4 | {{ template "button" makeVideo $ . }} 5 | {{ else if eq .Item.MediaFormat "audio" }} 6 | {{ template "button" makeAudio $ . }} 7 | {{ else if eq .Item.MediaFormat "image" }} 8 | {{ template "button" makeImage $ . }} 9 | {{ end }} 10 | {{ end }} 11 | {{ end }} -------------------------------------------------------------------------------- /templates/views/action/download_file.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 8 | {{- if .Data.HasAds }} 9 |
Your current download speed will be limited to {{ .Claims.Claims.Connection.Rate }}Mbps, donate to increase your speed and remove ads!
10 | {{- end }} 11 |
12 | {{- if .Data.HasAds }} 13 | 14 | 15 | 16 | 17 | donate 18 | 19 | {{- end }} 20 | copy wget cmd 21 | copy curl cmd 22 | copy url 23 | start download 24 | cancel 25 |
26 | {{ end }} 27 | -------------------------------------------------------------------------------- /templates/views/action/download_torrent.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 11 |
12 | close 13 |
14 | {{ end }} -------------------------------------------------------------------------------- /templates/views/action/post.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | {{ if .Err }} 3 |
4 |
{{ .Err | log | shortErr }}
5 | ok 6 |
7 | {{ end }} 8 | {{ if has .Data "Job" }} 9 | 12 | {{ end }} 13 | {{ "action.js" | asset }} 14 | {{ end }} -------------------------------------------------------------------------------- /templates/views/action/preview_image.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | {{ with .Data.ExportTag }} 3 | {{ .Alt }} 4 | {{ end }} 5 | {{ "action/image.js" | asset }} 6 | {{ end }} -------------------------------------------------------------------------------- /templates/views/action/stream_audio.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | {{ $MediaProbe := .Data.MediaProbe}} 3 | {{ with .Data.ExportTag }} 4 | 15 | {{ end }} 16 | {{ "mediaelement.css" | asset }} 17 | {{ "action/stream.js" | asset }} 18 | {{ end }} -------------------------------------------------------------------------------- /templates/views/auth/login.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Login{{ end }} 2 | {{ define "main" }} 3 |
4 | {{ if eq .Data.Instruction "library" }} 5 |

Authorization is required to access your library.

6 | {{ end }} 7 |

Enter your email to receive a one-time login link.

8 |

No password needed — just click the link in your inbox and you’re in!

9 |
10 |
11 | 12 | 13 |
14 | 20 | {{ "auth/login.js" | asset }} 21 | {{ end }} -------------------------------------------------------------------------------- /templates/views/auth/logout.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Logout{{ end }} 2 | {{ define "main" }} 3 | 9 | {{ "auth/logout.js" | asset }} 10 | {{ end }} -------------------------------------------------------------------------------- /templates/views/auth/refresh.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Refresh token{{ end }} 2 | {{ define "main" }} 3 | {{ "auth/refresh.js" | asset }} 4 | {{ end }} -------------------------------------------------------------------------------- /templates/views/auth/verify.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Check code{{ end }} 2 | {{ define "main" }} 3 | 12 | {{ "auth/verify.js" | asset }} 13 | {{ end }} -------------------------------------------------------------------------------- /templates/views/embed/ads.html: -------------------------------------------------------------------------------- 1 | {{ template "embed_ads" .}} 2 | {{ "embed/ads.js" | asset }} 3 | -------------------------------------------------------------------------------- /templates/views/embed/example/basic.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/events.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |
3 | 16 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/features.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |
3 | 15 | {{ end }} -------------------------------------------------------------------------------- /templates/views/embed/example/fixed_size.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/fixed_size2.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/fixed_size3.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/imdb.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/index.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |
    3 | {{ range .Data }} 4 |
  • 5 | {{ .Name }} 6 |
  • 7 | {{ end }} 8 |
9 | {{ end }} -------------------------------------------------------------------------------- /templates/views/embed/example/nocontrols.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/path.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/poster.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/pwd.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/pwd_with_file.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/responsive.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/responsive2.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 7 | 8 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/script.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 |
3 | 11 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/subtitles.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 6 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/torrent_url.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/example/user_lang.html: -------------------------------------------------------------------------------- 1 | {{ define "main" }} 2 | 3 | {{ end}} -------------------------------------------------------------------------------- /templates/views/embed/get.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 42 | {{ "embed/check.js" | asset }} 43 | 44 | 45 | 50 | 51 | -------------------------------------------------------------------------------- /templates/views/embed/post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | {{ template "embed_extra" . }} 16 | {{ "embed.css" | asset }} 17 | {{ "embed/index.js" | asset }} 18 | 19 | 20 | {{ if .Err }} 21 |
22 |
{{ .Err | log | shortErr }}
23 | ok 24 |
25 | {{ end }} 26 | {{ with .Data }} 27 | {{ if has . "Job" }} 28 | 31 | {{ end }} 32 | {{ end }} 33 | 34 | -------------------------------------------------------------------------------- /templates/views/ext/download.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | loading torrent | webtor.io 5 | 10 | {{ "ext/download.js" | asset }} 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/views/ext/magnet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | loading magnet... | webtor.io 5 | 10 | {{ "ext/magnet.js" | asset }} 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/views/library/index.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Library{{ end }} 2 | {{ define "main" }} 3 | {{ if .Claims | hasAds }}{{ template "promo" . }}{{ end }} 4 |
5 | {{ template "library/menu" .Data.Args | makeMenu }} 6 | {{ template "library/sort" .Data.Args | makeSort }} 7 |
8 | 9 | {{ if .Data.Items }} 10 | {{ template "library/list" . }} 11 | {{ else }} 12 |
13 |

No torrents yet! Hit “Add to Library” under any loaded torrent to get started.

14 |
15 | {{ end }} 16 | Add to library 17 | {{ end }} -------------------------------------------------------------------------------- /templates/views/profile/get.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Profile{{ end }} 2 | {{ define "main" }} 3 |
4 | logout 5 |
6 | {{ end }} -------------------------------------------------------------------------------- /templates/views/resource/get.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }} 2 | {{ with .Data }} 3 | {{ if has . "Item" }} 4 | {{ .Item.PathStr }} | {{ .Resource.Name }} 5 | {{ else }} 6 | {{ .Resource.Name }} 7 | {{ end }} 8 | {{ end }} 9 | {{ end }} 10 | {{ define "main" }} 11 | {{ if .Claims | hasAds }}{{ template "promo" . }}{{ end }} 12 | {{ with .Data }} 13 | {{ if .Item }} 14 |
15 | {{ template "file" $ }} 16 |
17 | {{ end }} 18 | {{ if and ($.Claims | hasAds) (not (.Resource.MagnetURI | isDemoMagnet)) }}{{ template "get_ads" $ }}{{ end }} 19 | {{ if and .Item .List }} 20 | 21 | {{ end }} 22 | {{ if .List }} 23 |
24 | {{ template "list" $ }} 25 |
26 | {{ end }} 27 |
28 |
29 | {{ template "library/button" $ }} 30 |
31 |
32 | {{ template "button" makeTorrentDownload $ . }} 33 |
34 |
35 |
36 | {{ end }} 37 | {{ template "get_extra" . }} 38 | {{ "resource/get.js" | asset }} 39 | {{ end }} -------------------------------------------------------------------------------- /templates/views/support/form.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Support{{ end }} 2 | {{ define "main" }} 3 |

Support

4 | {{ if .Err }} 5 |
6 |
{{ .Err | log | shortErr }}
7 | ok 8 |
9 | {{ end }} 10 | {{ with .Data }} 11 |
12 | 19 | {{ with .Form }} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {{ end }} 27 |
28 | 29 |
30 |
31 | {{ end }} 32 | {{ "support.js" | asset }} 33 | {{ end }} -------------------------------------------------------------------------------- /templates/views/support/success.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Support{{ end }} 2 | {{ define "main" }} 3 |

Support

4 |
5 |
success!
6 | send another one 7 |
8 | {{ end }} -------------------------------------------------------------------------------- /templates/views/tests/progress_log.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Progress Log Tests{{ end }} 2 | {{ define "main" }} 3 | 6 | 9 | 12 | 15 | 18 | 21 | 27 | 36 | 45 |
46 |
one line alert
47 | ok 48 |
49 | {{ "tests/progress_log.js" | asset }} 50 | {{ end }} -------------------------------------------------------------------------------- /templates/views/tests/theme_switcher.html: -------------------------------------------------------------------------------- 1 | {{ define "title" }}Theme switcher{{ end }} 2 | {{ define "main" }} 3 | 8 | {{ end }} --------------------------------------------------------------------------------