├── .env.example ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── Dockerfile ├── LICENSE ├── README.md ├── apps ├── backend │ ├── Dockerfile │ ├── cache │ │ └── cache.go │ ├── common │ │ └── common.go │ ├── downloader │ │ ├── alldebrid │ │ │ └── alldebrid.go │ │ └── realdebrid │ │ │ └── realdebrid.go │ ├── go.mod │ ├── go.sum │ ├── helpers │ │ └── helpers.go │ ├── imdb │ │ └── imdb.go │ ├── indexer │ │ ├── indexer.go │ │ ├── jackett │ │ │ └── jackett.go │ │ └── prowlarr │ │ │ └── prowlarr.go │ ├── main.go │ ├── migrations │ │ └── 1735493885_collections_snapshot.go │ ├── package.json │ ├── pb_public │ ├── scraper │ │ └── scraper.go │ ├── settings │ │ └── settings.go │ ├── tmdb │ │ └── tmdb.go │ ├── trakt │ │ └── trakt.go │ └── types │ │ └── types.go ├── frontend │ ├── .gitignore │ ├── .npmrc │ ├── .prettierrc │ ├── Dockerfile │ ├── README.md │ ├── app.vue │ ├── components │ │ ├── AllDebrid.vue │ │ ├── Detail │ │ │ ├── Info.vue │ │ │ └── SeasonsAndEpisodes.vue │ │ ├── Header │ │ │ └── NavBar.vue │ │ ├── MediaList.vue │ │ ├── Profile │ │ │ └── Trakt.vue │ │ ├── RealDebrid.vue │ │ ├── Streams.vue │ │ ├── User │ │ │ └── List.vue │ │ └── Video.vue │ ├── composables │ │ ├── useMedia.ts │ │ ├── usePb.ts │ │ ├── useProfile.ts │ │ ├── useSettings.ts │ │ └── useStreams.ts │ ├── layouts │ │ ├── admin.vue │ │ ├── default.vue │ │ ├── detail.vue │ │ └── empty.vue │ ├── middleware │ │ └── auth.global.ts │ ├── nuxt.config.ts │ ├── package.json │ ├── pages │ │ ├── [type] │ │ │ └── [id].vue │ │ ├── admin │ │ │ └── index.vue │ │ ├── devices.vue │ │ ├── index.vue │ │ ├── login.vue │ │ ├── movies.vue │ │ ├── profile.vue │ │ ├── search.vue │ │ ├── settings.vue │ │ └── shows.vue │ ├── plugins │ │ ├── fontawesome.ts │ │ └── video.ts │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ └── logo.svg │ ├── server │ │ └── tsconfig.json │ ├── tailwind.config.js │ ├── tsconfig.json │ └── utils │ │ └── index.ts └── services │ ├── Caddyfile │ ├── docker-compose.yaml │ ├── mosquitto.conf │ ├── package.json │ └── start.sh ├── bun.lockb ├── entrypoint.sh ├── go.work ├── go.work.sum ├── odin-turbo.code-workspace ├── package.json ├── packages ├── eslint-config │ ├── README.md │ ├── library.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── .eslintrc.js │ ├── package.json │ ├── src │ ├── button.tsx │ ├── card.tsx │ └── code.tsx │ ├── tsconfig.json │ ├── tsconfig.lint.json │ └── turbo │ └── generators │ ├── config.ts │ └── templates │ └── component.hbs ├── screenshots ├── btc_donation.png ├── connect.png ├── odin-screenshot.png └── odin-screenshot2.png ├── tsconfig.json ├── turbo.json └── version.txt /.env.example: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=info 2 | ALLDEBRID_KEY=key 3 | TMDB_KEY=key 4 | TRAKT_CLIENTID=key 5 | TRAKT_SECRET=key 6 | JACKETT_URL=url 7 | JACKETT_KEY=key 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // This configuration only applies to the package manager root. 2 | /** @type {import("eslint").Linter.Config} */ 3 | module.exports = { 4 | ignorePatterns: ["apps/**", "packages/**"], 5 | extends: ["@repo/eslint-config/library.js"], 6 | parser: "@typescript-eslint/parser", 7 | parserOptions: { 8 | project: true, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI - Build and push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | GHCR_REPO: ghcr.io/ad-on-is/odin 10 | 11 | concurrency: 12 | group: ${{ github.workflow }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build-and-push: 17 | name: Build and push 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | platform: 23 | - linux/amd64 24 | - linux/arm64 25 | steps: 26 | - name: Prepare 27 | run: | 28 | platform=${{ matrix.platform }} 29 | echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV 30 | 31 | - name: Docker meta 32 | id: meta 33 | uses: docker/metadata-action@v5 34 | with: 35 | images: ${{ env.GHCR_REPO }} 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | - name: Log in to the Container registry 39 | uses: docker/login-action@v3 40 | with: 41 | registry: ghcr.io 42 | username: ${{ github.actor }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | - name: Set up Docker 45 | uses: docker/setup-docker-action@v4 46 | with: 47 | daemon-config: | 48 | { 49 | "debug": true, 50 | "features": { 51 | "containerd-snapshotter": true 52 | } 53 | } 54 | - name: Set up QEMU 55 | uses: docker/setup-qemu-action@v3 56 | - name: Set image tags 57 | id: tags 58 | run: | 59 | if [ "${{ matrix.platform }}" = "linux/amd64" ]; then 60 | echo "" >> $GITHUB_OUTPUT 61 | else 62 | echo "IMAGE_TAG=-arm64" >> $GITHUB_OUTPUT 63 | fi 64 | - name: Build 65 | uses: docker/build-push-action@v6 66 | with: 67 | context: . 68 | file: Dockerfile 69 | push: true 70 | platforms: ${{ matrix.platform }} 71 | tags: | 72 | ghcr.io/${{ github.actor }}/odin:${{ github.ref_name }}${{steps.tags.outputs.IMAGE_TAG}} 73 | ghcr.io/${{ github.actor }}/odin:latest${{steps.tags.outputs.IMAGE_TAG}} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | deploy.sh 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | 40 | 41 | apps/backend/pb_data 42 | apps/backend/version.txt 43 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/.npmrc -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS bebuilder 2 | WORKDIR /build/ 3 | COPY ./apps/backend/go.mod . 4 | COPY ./apps/backend/go.sum . 5 | RUN CGO_ENABLED=0 GOOS=linux go mod download 6 | COPY ./apps/backend . 7 | RUN CGO_ENABLED=0 GOOS=linux go build -o odin-backend 8 | 9 | FROM node:alpine AS fe-builder 10 | WORKDIR /build/ 11 | RUN npm i -g pnpm 12 | COPY ./apps/frontend/package.json . 13 | RUN pnpm i 14 | COPY ./apps/frontend . 15 | RUN pnpm run build 16 | 17 | 18 | FROM node:alpine 19 | RUN apk --update add ca-certificates curl mailcap caddy mosquitto bash 20 | 21 | COPY --from=fe-builder /build/.output /odin-frontend 22 | COPY --from=bebuilder /build/odin-backend /odin-server 23 | COPY ./apps/services/Caddyfile /etc/caddy/Caddyfile 24 | COPY ./apps/services/mosquitto.conf /etc/mosquitto/mosquitto.conf 25 | 26 | COPY ./apps/backend/migrations/1735493885_collections_snapshot.go /migrations/1735493885_collections_snapshot.go 27 | 28 | COPY ./entrypoint.sh /entrypoint.sh 29 | COPY ./version.txt /version.txt 30 | 31 | CMD ["/entrypoint.sh"] 32 | 33 | 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Enjoy movies and TV shows.

6 | 7 |

To be used with Odin TV

8 | 9 | ![release](https://github.com/ad-on-is/odin-server/actions/workflows/ci.yml/badge.svg?branch=) 10 | [![Version](https://img.shields.io/github/v/tag/ad-on-is/odin-server.svg?style=flat)]() 11 | [![GitHub stars](https://img.shields.io/github/stars/ad-on-is/odin-server.svg?style=social&label=Stars)]() 12 | 13 | ![screenshot](./screenshots/odin-screenshot.png) 14 | ![screenshot2](./screenshots/odin-screenshot2.png) 15 | 16 | # 🚀 Key features 17 | 18 | - Discover movies and shows 19 | - Scrobble 20 | - See your watchlists 21 | - Setup custom sections from Trakt lists 22 | - Scrape Jackett for Torrents 23 | - Multi-User support 24 | - Unrestrict links with RealDebrid/AllDebrid 25 | 26 | # 💡 Prerequisites 27 | 28 | > [!WARNING] 29 | > 30 | > In order to make sure Odin works as expected, please setup these things before starting the app. 31 | 32 | - Trakt API credentials 33 | - TMDB API key 34 | - A working Jackett server 35 | - Make sure you have some good indexers set up 36 | - At least one of: 37 | - RealDebrid Account 38 | - AllDebrid API_KEY 39 | 40 | ## 🐋 Setup with Docker (docker-compose) 41 | 42 | ```yaml 43 | services: 44 | odin: 45 | image: ghcr.io/ad-on-is/odin:latest 46 | # image: ghcr.io/ad-on-is/odin:latest-arm64 47 | container_name: odin 48 | restart: always 49 | ports: 50 | - 6060:6060 51 | environment: 52 | - LOG_LEVEL=info 53 | - JACKETT_URL=http://jackett:9117 54 | - JACKETT_KEY=xxxxx 55 | - TMDB_KEY= 56 | - TRAKT_CLIENTID= 57 | - TRAKT_SECRET= 58 | - ADMIN_EMAIL= #optional 59 | - ADMIN_PASSWORD= #optional 60 | - ALLDEBRID_KEY= # if you have an AllDebrid account 61 | volumes: 62 | - ./pb_data:/pb_data 63 | 64 | jackett: 65 | image: lscr.io/linuxserver/jackett:latest 66 | container_name: jackett 67 | ports: 68 | - 9117:9117 69 | environment: 70 | - TZ=Etc/UTC 71 | - AUTO_UPDATE=true 72 | volumes: 73 | - ./jackett:/config 74 | restart: always 75 | flaresolverr: 76 | container_name: flaresolverr 77 | image: ghcr.io/flaresolverr/flaresolverr:latest 78 | restart: always 79 | environment: 80 | - LOG_LEVEL=info 81 | 82 | # use a reverse proxy to serve the app 83 | # nginx: 84 | # caddy: 85 | ``` 86 | 87 | # 1️⃣ First steps 88 | 89 | ## Trakt 90 | 91 | - Create a new App: 92 | - Note down the Trakt `clientId` and `clientSecret` 93 | 94 | ## TMDB 95 | 96 | - Create a new account and get an API key: 97 | - Note down the `apiKey` 98 | 99 | ## Jackett and flaresolverr 100 | 101 | - Configured Jackett as needed 102 | - Add flaresolverr 103 | - Add as many good quality indexers as possible 104 | - Make sure the Jackett search returns some results 105 | 106 | ## Prepare the server 107 | 108 | - Log in as admin 109 | - **E-Mail:** , **Password:** odinAdmin1 110 | - If you've set them via the environment variables, use these instead 111 | - Configure RealDebrid 112 | - Create a new user 113 | 114 | # Configuration 115 | 116 | ## RealDebrid 117 | 118 | - Connect to RealDebrid by following the steps in the frontend 119 | 120 | ## AllDebrid 121 | 122 | - Go to [Apikey manager](https://alldebrid.com/apikeys/) 123 | - Create a new API key 124 | - Use the key as environment variable 125 | 126 | ## Trakt 127 | 128 | - Log in as a **User** 129 | - Go to Profile 130 | - In the Trakt section click on "Login" and follow the steps 131 | 132 | ### Trakt Lists 133 | 134 | - Go to settings 135 | - Add lists, with title and url in the desired section 136 | 137 | > [!INFO] 138 | > 139 | > [Trakt API](https://trakt.docs.apiary.io/) for info about possible endpoints 140 | > 141 | > Example: /movies/trending 142 | 143 | #### Placeholders 144 | 145 | - `::(year|month|day)::` current year|month|day 146 | - `::(year|month|day):-1:` current year|month|day +1 (or -1) 147 | - `::monthdays::` days of the current month 148 | 149 | Examples: 150 | `/movies/popular?years=::year::,::year:-1:` -> `/movies/popular?years=2024,2023` 151 | 152 | # 📺 Connecting to Odin TV 153 | 154 | > [!NOTE] 155 | > This only works with a regular user, not an admin account. 156 | 157 | - Install the [Odin TV](https://github.com/ad-on-is/odin-tv) app on your Android TV box 158 | - Open Odin TV on your Android TV box. A screen with a code should show up. 159 | - Login as your user in the Odin frontend, and go to devices 160 | - Click on **Link device**, enter the code shown on your TV and click **Connect** 161 | 162 | ![connect](./screenshots/connect.png) 163 | 164 | > [!NOTE] 165 | > This process uses ntfy.sh to propagate the public URL from the server to the App. The public URL only needs to be accessible within your network where the Android TV is running on. 166 | 167 | # 💻 Running local dev environment 168 | 169 | ```bash 170 | # install Bun 171 | curl -fsSL https://bun.sh/install | bash 172 | 173 | # lone the repo 174 | git clone https://github.com/ad-on-is/odin-movieshow 175 | cd odin-movieshow 176 | 177 | # install dependencies 178 | bun install 179 | 180 | # copy .env.example to apps/backend/.env and apps/frontend/.env and fill in the blanks 181 | 182 | # run dev 183 | bun --bun run dev 184 | 185 | 186 | ``` 187 | 188 | # Donations 189 | 190 | 191 | 192 | ## License 193 | 194 | GPLv3 195 | 196 | --- 197 | 198 | > GitHub [@ad-on-is](https://github.com/ad-on-is)  ·  199 | > Built using [pocketbase](https://pocketbase.io/) and [Nuxt](https://nuxt.com/) 200 | -------------------------------------------------------------------------------- /apps/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | From golang:alpine as builder 2 | WORKDIR /build/ 3 | COPY go.mod . 4 | COPY go.sum . 5 | RUN CGO_ENABLED=0 GOOS=linux go mod download 6 | COPY . . 7 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix -o odin-backend 8 | 9 | 10 | FROM alpine 11 | RUN apk --update add ca-certificates curl mailcap 12 | WORKDIR / 13 | COPY --from=builder /build/odin-backend /odin-backend 14 | 15 | 16 | CMD ["/odin-backend", "serve", "--http=0.0.0.0:8090"] 17 | -------------------------------------------------------------------------------- /apps/backend/cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "time" 9 | 10 | "github.com/charmbracelet/log" 11 | "github.com/odin-movieshow/backend/types" 12 | "github.com/pocketbase/dbx" 13 | "github.com/pocketbase/pocketbase" 14 | "github.com/pocketbase/pocketbase/models" 15 | "github.com/redis/go-redis/v9" 16 | ) 17 | 18 | type Cache struct { 19 | redis *redis.Client 20 | app *pocketbase.PocketBase 21 | } 22 | 23 | func New(app *pocketbase.PocketBase) *Cache { 24 | opts, err := redis.ParseURL(os.Getenv("REDIS_URL")) 25 | if err != nil { 26 | log.Error(err) 27 | } 28 | rdb := redis.NewClient(opts) 29 | err = rdb.Set(context.Background(), "test", "test", 0).Err() 30 | if err != nil { 31 | log.Error("REDIS", "Cannot connect to", opts.Addr, opts) 32 | log.Error(err) 33 | } 34 | return &Cache{app: app, redis: rdb} 35 | } 36 | 37 | func (c *Cache) GetCachedTorrents(prefix string) []types.Torrent { 38 | ctx := context.Background() 39 | keys, err := c.redis.Keys(ctx, prefix+":*").Result() 40 | if err != nil { 41 | return nil 42 | } 43 | torrents := []types.Torrent{} 44 | for _, key := range keys { 45 | data, err := c.redis.Get(ctx, key).Result() 46 | if err != nil { 47 | continue 48 | } 49 | var torrent types.Torrent 50 | err = json.Unmarshal([]byte(data), &torrent) 51 | if err != nil { 52 | log.Error("Failed to unmarshal torrent data", "key", key, "error", err) 53 | continue 54 | } 55 | torrents = append(torrents, torrent) 56 | } 57 | 58 | return torrents 59 | } 60 | 61 | func (c *Cache) ReadCache(service string, id string, resource string) interface{} { 62 | if c.redis == nil { 63 | return nil 64 | } 65 | ctx := context.Background() 66 | key := fmt.Sprintf("%s:%s:%s", service, resource, id) 67 | 68 | data, err := c.redis.Get(ctx, key).Result() 69 | if err != nil { 70 | return nil 71 | } 72 | var res any 73 | err = json.Unmarshal([]byte(data), &res) 74 | if err != nil { 75 | return nil 76 | } 77 | log.Debug("cache hit", "for", service, "resource", resource, "id", id) 78 | return res 79 | } 80 | 81 | func (c *Cache) WriteCache(service string, id string, resource string, data any, hours int) { 82 | d := time.Duration(hours) * time.Hour 83 | 84 | if c.redis == nil { 85 | return 86 | } 87 | ctx := context.Background() 88 | key := fmt.Sprintf("%s:%s:%s", service, resource, id) 89 | 90 | if data == nil { 91 | return 92 | } 93 | log.Info("cache write", "for", service, "resource", resource, "id", id) 94 | m, err := json.Marshal(data) 95 | if err != nil { 96 | return 97 | } 98 | err = c.redis.Set(ctx, key, m, d).Err() 99 | if err != nil { 100 | log.Error(err) 101 | } 102 | } 103 | 104 | // func (c *Cache) ReadRDCache(resource string, magnet string) *types.Torrent { 105 | // record, err := c.app.Dao(). 106 | // FindFirstRecordByFilter("rd_resolved", "magnet = {:magnet}", dbx.Params{"magnet": magnet}) 107 | // var res types.Torrent 108 | // if err == nil { 109 | // err := record.UnmarshalJSONField("data", &res) 110 | // date := record.GetDateTime("updated") 111 | // now := time.Now().Add(time.Duration((-8) * time.Hour)) 112 | // if err == nil { 113 | // if date.Time().Before(now) { 114 | // return nil 115 | // } 116 | // log.Debug("cache hit", "for", "RD", "resource", resource) 117 | // return &res 118 | // } 119 | // } 120 | // return nil 121 | // } 122 | 123 | func (c *Cache) ReadRDCacheByResource(resource string) []types.Torrent { 124 | records, err := c.app.Dao(). 125 | FindRecordsByFilter("rd_resolved", "resource = {:resource}", "id", -1, 0, dbx.Params{"resource": resource}) 126 | res := make([]types.Torrent, 0) 127 | if err == nil { 128 | for _, record := range records { 129 | var r types.Torrent 130 | date := record.GetDateTime("updated") 131 | now := time.Now().Add(time.Duration((-8) * time.Hour)) 132 | // add 1 hour to date 133 | if date.Time().Before(now) { 134 | continue 135 | } 136 | err := record.UnmarshalJSONField("data", &r) 137 | if err == nil { 138 | res = append(res, r) 139 | } 140 | } 141 | } 142 | return res 143 | } 144 | 145 | func (c *Cache) WriteRDCache(resource string, magnet string, data interface{}) { 146 | // c.WriteCache("stream", resource, magnet, &data, 12) 147 | log.Info("cache write", "for", "RD", "resource", resource) 148 | record, err := c.app.Dao(). 149 | FindFirstRecordByFilter("rd_resolved", "magnet = {:magnet}", dbx.Params{"magnet": magnet}) 150 | 151 | if err == nil { 152 | record.Set("data", &data) 153 | c.app.Dao().SaveRecord(record) 154 | } else { 155 | collection, _ := c.app.Dao().FindCollectionByNameOrId("rd_resolved") 156 | record := models.NewRecord(collection) 157 | record.Set("data", &data) 158 | record.Set("magnet", magnet) 159 | record.Set("resource", resource) 160 | c.app.Dao().SaveRecord(record) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /apps/backend/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/charmbracelet/log" 12 | mqtt "github.com/eclipse/paho.mqtt.golang" 13 | "github.com/thoas/go-funk" 14 | ) 15 | 16 | type Item struct { 17 | XMLName xml.Name `xml:"item"` 18 | Title string `xml:"title"` 19 | Size int64 `xml:"size"` 20 | Link string `xml:"link"` 21 | Enclosure struct { 22 | URL string `xml:"url,attr"` 23 | Length int64 `xml:"length,attr"` 24 | Type string `xml:"type,attr"` 25 | } `xml:"enclosure"` 26 | Attrs []struct { 27 | Name string `xml:"name,attr"` 28 | Value string `xml:"value,attr"` 29 | } `xml:"http://torznab.com/schemas/2015/feed attr"` 30 | } 31 | 32 | type Rss struct { 33 | XMLName xml.Name `xml:"rss"` 34 | Channel struct { 35 | XMLName xml.Name `xml:"channel"` 36 | Link string `xml:"link"` 37 | Title string `xml:"title"` 38 | Description string `xml:"description"` 39 | Language string `xml:"language"` 40 | Category string `xml:"category"` 41 | Items []Item `xml:"item"` 42 | } `xml:"channel"` 43 | } 44 | 45 | type Torrent struct { 46 | Scraper string `json:"scraper"` 47 | Hash string `json:"hash"` 48 | Size uint64 `json:"size"` 49 | ReleaseTitle string `json:"release_title"` 50 | Magnet string `json:"magnet"` 51 | Name string `json:"name"` 52 | Quality string `json:"quality"` 53 | Info []string `json:"info"` 54 | Seeds uint64 `json:"seeds"` 55 | } 56 | 57 | type Payload struct { 58 | Type string `json:"type"` 59 | Title string `json:"title"` 60 | Year string `json:"year"` 61 | Imdb string `json:"imdb"` 62 | Trakt string `json:"trakt"` 63 | ShowImdb string `json:"show_imdb"` 64 | ShowTvdb string `json:"show_tvdb"` 65 | ShowTitle string `json:"show_title"` 66 | ShowYear string `json:"show_year"` 67 | SeasonNumber string `json:"season_number"` 68 | EpisodeImdb string `json:"episode_imdb"` 69 | EpisodeTvdb string `json:"episode_tvdb"` 70 | EpisodeTitle string `json:"episode_title"` 71 | EpisodeNumber string `json:"episode_number"` 72 | EpisodeTrakt string `json:"episode_trakt"` 73 | } 74 | 75 | func ParseDates(str string) string { 76 | re := regexp.MustCompile("::(year|month|day):(\\+|-)?(\\d+)?:") 77 | 78 | matches := re.FindAllStringSubmatch(str, -1) 79 | now := time.Now() 80 | // current := time.Now() 81 | orig := str 82 | for _, match := range matches { 83 | 84 | yearVal := 0 85 | monthVal := 0 86 | dayVal := 0 87 | if len(match) == 4 { 88 | val := 0 89 | if v, err := strconv.Atoi(match[3]); err == nil { 90 | val = v 91 | } 92 | if match[2] == "-" { 93 | val *= -1 94 | } 95 | if match[1] == "year" { 96 | 97 | yearVal = val 98 | str = strings.ReplaceAll(str, match[0], "#year#") 99 | } else if match[1] == "month" { 100 | monthVal = val 101 | str = strings.ReplaceAll(str, match[0], "#month#") 102 | } else if match[1] == "day" { 103 | dayVal = val 104 | str = strings.ReplaceAll(str, match[0], "#day#") 105 | } 106 | } 107 | now = now.AddDate(yearVal, monthVal, dayVal) 108 | 109 | } 110 | str = strings.ReplaceAll(str, "#day#", fmt.Sprintf("%d", now.Day())) 111 | str = strings.ReplaceAll(str, "#month#", fmt.Sprintf("%d", now.Month())) 112 | str = strings.ReplaceAll(str, "#year#", fmt.Sprintf("%d", now.Year())) 113 | 114 | re2 := regexp.MustCompile("::monthdays::") 115 | 116 | matches2 := re2.FindAllStringSubmatch(str, -1) 117 | dinm := daysInMonth(now) 118 | for _, match := range matches2 { 119 | str = strings.ReplaceAll(str, match[0], fmt.Sprintf("%d", dinm)) 120 | } 121 | 122 | log.Debug(orig, "new", str, "year", now.Year(), "month", now.Month(), "day", now.Day()) 123 | 124 | return str 125 | } 126 | 127 | func daysInMonth(t time.Time) int { 128 | t = time.Date(t.Year(), t.Month(), 32, 0, 0, 0, 0, time.UTC) 129 | daysInMonth := 32 - t.Day() 130 | days := make([]int, daysInMonth) 131 | for i := range days { 132 | days[i] = i + 1 133 | } 134 | 135 | d := days[len(days)-1] 136 | d += 1 137 | return d 138 | } 139 | 140 | func SeparateByQuality(torrents []Torrent, payload Payload) []Torrent { 141 | res := map[string][]Torrent{} 142 | 143 | regexpPatterns := []*regexp.Regexp{ 144 | regexp.MustCompile( 145 | fmt.Sprintf("s0?%s[.x]?e0?%s", payload.SeasonNumber, payload.EpisodeNumber), 146 | ), 147 | regexp.MustCompile( 148 | fmt.Sprintf("Season 0?%s,? ?Episode 0?%s", payload.SeasonNumber, payload.EpisodeNumber), 149 | ), 150 | } 151 | 152 | for _, q := range []string{"4K", "1080p", "720p", "SD", "CAM"} { 153 | res[q] = []Torrent{} 154 | } 155 | 156 | for _, t := range torrents { 157 | if _, ok := res[t.Quality]; !ok { 158 | res[t.Quality] = []Torrent{} 159 | } 160 | 161 | // sort SxEx episodes first 162 | if payload.Type == "episode" { 163 | title := strings.ToLower(t.ReleaseTitle) 164 | for _, pattern := range regexpPatterns { 165 | if pattern.MatchString(title) { 166 | res[t.Quality] = append([]Torrent{t}, res[t.Quality]...) 167 | break 168 | } else { 169 | res[t.Quality] = append(res[t.Quality], t) 170 | } 171 | } 172 | } else { 173 | res[t.Quality] = append(res[t.Quality], t) 174 | } 175 | } 176 | 177 | // if len(res["4K"]) > 20 { 178 | // res["4K"] = res["4K"][:20] 179 | // } 180 | 181 | // if len(res["1080p"]) > 20 { 182 | // res["1080p"] = res["1080p"][:20] 183 | // } 184 | 185 | // if len(res["720p"]) > 10 { 186 | // res["720p"] = res["720p"][:10] 187 | // } 188 | 189 | // if len(res["SD"]) > 10 { 190 | // res["SD"] = res["SD"][:10] 191 | // } 192 | 193 | // if len(res["4K"])+len(res["1080p"]) > 30 { 194 | 195 | // res["720p"] = []Torrent{} 196 | // res["SD"] = []Torrent{} 197 | // } 198 | 199 | // if len(res["4K"]) > 1 { 200 | // res["1080p"] = res["1080p"][:20] 201 | // } 202 | 203 | // if len(res["1080p"]) > 30 { 204 | // res["1080p"] = res["1080p"][:30] 205 | // } 206 | 207 | ret := append(res["4K"], res["1080p"]...) 208 | ret = append(ret, res["720p"]...) 209 | ret = append(ret, res["SD"]...) 210 | return ret 211 | } 212 | 213 | func Dedupe(torrents []Torrent) []Torrent { 214 | res := []Torrent{} 215 | hashes := []string{} 216 | for _, t := range torrents { 217 | if t.Magnet != "" && !funk.ContainsString(hashes, t.Hash) { 218 | res = append(res, t) 219 | hashes = append(hashes, t.Hash) 220 | } 221 | } 222 | return res 223 | } 224 | 225 | func GetInfos(title string) ([]string, string) { 226 | title = strings.ToLower(title) 227 | 228 | res := []string{} 229 | quality := "SD" 230 | infoTypes := map[string][]string{ 231 | "AVC": {"x264", "x 264", "h264", "h 264", "avc"}, 232 | "HEVC": {"x265", "x 265", "h265", "h 265", "hevc"}, 233 | "XVID": {"xvid"}, 234 | "DIVX": {"divx"}, 235 | "MP4": {"mp4"}, 236 | "WMV": {"wmv"}, 237 | "MPEG": {"mpeg"}, 238 | "4K": {"4k", "2160p", "216o"}, 239 | "1080p": {"1080p", "1o80", "108o", "1o8p"}, 240 | "720p": {"720", "72o"}, 241 | "REMUX": {"remux", "bdremux"}, 242 | "DV": {" dv ", "dovi", "dolby vision", "dolbyvision"}, 243 | "HDR": { 244 | " hdr ", 245 | "hdr10", 246 | "hdr 10", 247 | "uhd bluray 2160p", 248 | "uhd blu ray 2160p", 249 | "2160p uhd bluray", 250 | "2160p uhd blu ray", 251 | "2160p bluray hevc truehd", 252 | "2160p bluray hevc dts", 253 | "2160p bluray hevc lpcm", 254 | "2160p us bluray hevc truehd", 255 | "2160p us bluray hevc dts", 256 | }, 257 | "SDR": {" sdr"}, 258 | "AAC": {"aac"}, 259 | "DTS-HDMA": {"hd ma", "hdma"}, 260 | "DTS-HDHR": {"hd hr", "hdhr", "dts hr", "dtshr"}, 261 | "DTS-X": {"dtsx", " dts x"}, 262 | "ATMOS": {"atmos"}, 263 | "TRUEHD": {"truehd", "true hd"}, 264 | "DD+": {"ddp", "eac3", " e ac3", " e ac 3", "dd+", "digital plus", "digitalplus"}, 265 | "DD": { 266 | " dd ", 267 | "dd2", 268 | "dd5", 269 | "dd7", 270 | " ac3", 271 | " ac 3", 272 | "dolby digital", 273 | "dolbydigital", 274 | "dolby5", 275 | }, 276 | "MP3": {"mp3"}, 277 | "WMA": {" wma"}, 278 | "2.0": {"2 0 ", "2 0ch", "2ch"}, 279 | "5.1": {"5 1 ", "5 1ch", "6ch"}, 280 | "7.1": {"7 1 ", "7 1ch", "8ch"}, 281 | "BLURAY": {"bluray", "blu ray", "bdrip", "bd rip", "brrip", "br rip"}, 282 | "WEB": {" web ", "webrip", "webdl", "web rip", "web dl", "webmux"}, 283 | "HD-RIP": {" hdrip", " hd rip"}, 284 | "DVDRIP": {"dvdrip", "dvd rip"}, 285 | "HDTV": {"hdtv"}, 286 | "PDTV": {"pdtv"}, 287 | "CAMQUALITY": { 288 | " cam ", "camrip", "cam rip", 289 | "hdcam", "hd cam", 290 | " ts ", " ts1", " ts7", 291 | "hd ts", "hdts", 292 | "telesync", 293 | " tc ", " tc1", " tc7", 294 | "hd tc", "hdtc", 295 | "telecine", 296 | "xbet", 297 | "hcts", "hc ts", 298 | "hctc", "hc tc", 299 | "hqcam", "hq cam", 300 | }, 301 | "SCR": {"scr ", "screener"}, 302 | "HC": { 303 | "korsub", " kor ", 304 | " hc ", "hcsub", "hcts", "hctc", "hchdrip", 305 | "hardsub", "hard sub", 306 | "sub hard", 307 | "hardcode", "hard code", 308 | "vostfr", "vo stfr", 309 | }, 310 | "3D": {" 3d"}, 311 | } 312 | for baseInfo, infoType := range infoTypes { 313 | for _, info := range infoType { 314 | if strings.Contains(title, strings.ToLower(baseInfo)) { 315 | res = append(res, baseInfo) 316 | break 317 | } 318 | if strings.Contains(title, strings.ToLower(info)) { 319 | res = append(res, baseInfo) 320 | break 321 | } 322 | 323 | if strings.Contains(title, strings.ReplaceAll(strings.ToLower(info), " ", ".")) { 324 | res = append(res, baseInfo) 325 | break 326 | } 327 | } 328 | } 329 | 330 | if funk.Contains(res, "SDR") && funk.Contains(res, "HDR") { 331 | res = funk.FilterString(res, func(s string) bool { 332 | return s != "SDR" 333 | }) 334 | } 335 | 336 | if funk.Contains(res, "DD") && funk.Contains(res, "DD+") { 337 | res = funk.FilterString(res, func(s string) bool { 338 | return s != "DD" 339 | }) 340 | } 341 | 342 | if funk.ContainsString([]string{"2160p", "remux"}, title) && 343 | !funk.Contains(res, []string{"HDR", "SDR"}) { 344 | res = append(res, "HDR") 345 | } 346 | 347 | if funk.Contains(res, "720p") { 348 | quality = "720p" 349 | } 350 | if funk.Contains(res, "1080p") { 351 | quality = "1080p" 352 | } 353 | 354 | if funk.Contains(res, "4K") { 355 | quality = "4K" 356 | } 357 | if funk.Contains(res, "CAMQUALITY") { 358 | quality = "CAM" 359 | } 360 | 361 | return res, quality 362 | } 363 | 364 | func SimplifyMagnet(magnet string) string { 365 | s := "" 366 | r := regexp.MustCompile(`magnet:\?xt=urn:btih:\s*(.*?)\s*&dn`) 367 | matches := r.FindAllStringSubmatch(magnet, -1) 368 | for _, v := range matches { 369 | s = v[1] 370 | } 371 | return "magnet:?xt=urn:btih:" + s 372 | } 373 | 374 | func Strip(s string) string { 375 | var result strings.Builder 376 | for i := 0; i < len(s); i++ { 377 | b := s[i] 378 | if b >= 32 && b <= 126 { 379 | result.WriteByte(b) 380 | } 381 | } 382 | return result.String() 383 | } 384 | 385 | func MqttClient() mqtt.Client { 386 | // mqtt.DEBUG = stdlog.New(os.Stdout, "", 0) 387 | // mqtt.ERROR = stdlog.New(os.Stdout, "", 0) 388 | opts := mqtt.NewClientOptions(). 389 | AddBroker("ws://127.0.0.1:6060/ws/mqtt") 390 | opts.SetKeepAlive(2 * time.Second) 391 | opts.SetPingTimeout(1 * time.Second) 392 | 393 | c := mqtt.NewClient(opts) 394 | if token := c.Connect(); token.Wait() && token.Error() != nil { 395 | log.Error("MQTT", "conneced", c.IsConnected()) 396 | } else { 397 | log.Info("MQTT", "connected", c.IsConnected()) 398 | } 399 | 400 | return c 401 | } 402 | -------------------------------------------------------------------------------- /apps/backend/downloader/alldebrid/alldebrid.go: -------------------------------------------------------------------------------- 1 | package alldebrid 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "net/http" 7 | "os" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/odin-movieshow/backend/settings" 14 | "github.com/odin-movieshow/backend/types" 15 | 16 | "github.com/charmbracelet/log" 17 | "github.com/go-resty/resty/v2" 18 | "github.com/pocketbase/pocketbase" 19 | ) 20 | 21 | type AllDebrid struct { 22 | app *pocketbase.PocketBase 23 | settings *settings.Settings 24 | Headers map[string]any 25 | } 26 | 27 | type Magnet struct { 28 | Magnet string `json:"magnet"` 29 | Hash string `json:"hash"` 30 | Name string `json:"name"` 31 | Size int `json:"size"` 32 | Ready bool `json:"ready"` 33 | ID int `json:"id"` 34 | } 35 | 36 | type FileNode struct { 37 | N string `json:"n"` 38 | S int `json:"s"` 39 | L string `json:"l"` 40 | E []FileNode `json:"e"` 41 | } 42 | 43 | func New(app *pocketbase.PocketBase, settings *settings.Settings) *AllDebrid { 44 | return &AllDebrid{app: app, settings: settings} 45 | } 46 | 47 | func (ad *AllDebrid) CallEndpoint( 48 | endpoint string, 49 | method string, 50 | body map[string]string, 51 | res interface{}, 52 | ) (http.Header, int) { 53 | appendix := "?" 54 | if strings.Contains(endpoint, "?") { 55 | appendix = "&" 56 | } 57 | endpoint = endpoint + appendix + "agent=odinMovieShow&apikey=" + os.Getenv("ALLDEBRID_KEY") 58 | 59 | request := resty.New(). 60 | SetRetryCount(3). 61 | SetRetryWaitTime(time.Second * 1). 62 | AddRetryCondition(func(r *resty.Response, err error) bool { 63 | return r.StatusCode() == 429 64 | }). 65 | R() 66 | request.SetResult(res) 67 | var respHeaders http.Header 68 | status := 200 69 | 70 | if body != nil { 71 | request.SetFormData(body) 72 | } 73 | 74 | request.Attempt = 3 75 | 76 | var r func(url string) (*resty.Response, error) 77 | switch method { 78 | case "POST": 79 | r = request.Post 80 | case "PATCH": 81 | r = request.Patch 82 | case "PUT": 83 | r = request.Put 84 | case "DELETE": 85 | r = request.Delete 86 | default: 87 | r = request.Get 88 | 89 | } 90 | 91 | resp, err := r("https://api.alldebrid.com/v4" + endpoint) 92 | if err == nil { 93 | respHeaders = resp.Header() 94 | status = resp.StatusCode() 95 | } else { 96 | log.Error("alldebrid", "url", endpoint, "error", err) 97 | return respHeaders, status 98 | } 99 | 100 | if status > 299 { 101 | log.Error( 102 | "alldebrid", 103 | "status", 104 | status, 105 | "url", 106 | endpoint, 107 | "data", 108 | strings.ReplaceAll(string(resp.Body()), "\n", ""), 109 | ) 110 | } else { 111 | log.Debug("alldebrid", "call", fmt.Sprintf("%s %s", method, endpoint)) 112 | } 113 | 114 | return respHeaders, status 115 | } 116 | 117 | func getLinks(files []FileNode) []string { 118 | links := []string{} 119 | for _, v := range files { 120 | if v.L != "" { 121 | links = append(links, v.L) 122 | continue 123 | } 124 | if len(v.E) > 0 { 125 | links = append(links, getLinks(v.E)...) 126 | } 127 | } 128 | return links 129 | } 130 | 131 | func (ad *AllDebrid) Unrestrict(m string) []types.Unrestricted { 132 | var res struct { 133 | Data struct { 134 | Magnets []Magnet `json:"magnets"` 135 | } `json:"data"` 136 | } 137 | 138 | ad.CallEndpoint("/magnet/upload?magnets[]="+m, "GET", nil, &res) 139 | 140 | if len(res.Data.Magnets) == 0 { 141 | log.Debug("No magnets found for", m) 142 | return nil 143 | } 144 | 145 | magnetId := strconv.Itoa(int(res.Data.Magnets[0].ID)) 146 | magnetReady := res.Data.Magnets[0].Ready 147 | defer ad.CallEndpoint( 148 | fmt.Sprintf("/magnet/delete?id=%s", magnetId), 149 | "GET", 150 | nil, 151 | nil, 152 | ) 153 | 154 | if !magnetReady { 155 | log.Debug("Magnet not available in cache", magnetId) 156 | return nil 157 | } 158 | 159 | var files struct { 160 | Data struct { 161 | Magnets []struct { 162 | Files []FileNode `json:"files"` 163 | } `json:"magnets"` 164 | } `json:"data"` 165 | } 166 | 167 | ad.CallEndpoint("/magnet/files?id[]="+magnetId, "GET", nil, &files) 168 | 169 | if len(files.Data.Magnets) == 0 || len(files.Data.Magnets[0].Files) == 0 { 170 | log.Debug("No files found for magnet", magnetId) 171 | return nil 172 | } 173 | 174 | us := []types.Unrestricted{} 175 | 176 | for _, f := range files.Data.Magnets[0].Files { 177 | 178 | link := f.L 179 | if link == "" { 180 | continue 181 | } 182 | var u struct { 183 | Data struct { 184 | Link string `json:"link"` 185 | Filename string `json:"filename"` 186 | Filesize int `json:"filesize"` 187 | } `json:"data"` 188 | } 189 | ad.CallEndpoint("/link/unlock?link="+link, "GET", nil, &u) 190 | fname := u.Data.Filename 191 | mimetype := mime.TypeByExtension(fname[strings.LastIndex(fname, "."):]) 192 | 193 | isVideo := strings.Contains( 194 | mimetype, 195 | "video", 196 | ) 197 | 198 | match, _ := regexp.MatchString("^[Ss]ample[ -_]?[0-9].", fname) 199 | 200 | if !match && isVideo { 201 | un := types.Unrestricted{Filename: fname, Filesize: u.Data.Filesize, Download: u.Data.Link} 202 | us = append(us, un) 203 | } 204 | 205 | } 206 | return us 207 | } 208 | -------------------------------------------------------------------------------- /apps/backend/downloader/realdebrid/realdebrid.go: -------------------------------------------------------------------------------- 1 | package realdebrid 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/odin-movieshow/backend/settings" 13 | "github.com/odin-movieshow/backend/types" 14 | 15 | "github.com/charmbracelet/log" 16 | "github.com/go-resty/resty/v2" 17 | "github.com/pocketbase/pocketbase" 18 | "github.com/pocketbase/pocketbase/models" 19 | "github.com/thoas/go-funk" 20 | ) 21 | 22 | type RealDebrid struct { 23 | app *pocketbase.PocketBase 24 | settings *settings.Settings 25 | Headers map[string]any 26 | } 27 | 28 | func New(app *pocketbase.PocketBase, settings *settings.Settings) *RealDebrid { 29 | return &RealDebrid{app: app, settings: settings} 30 | } 31 | 32 | func (rd *RealDebrid) RemoveByType(t string) { 33 | var res interface{} 34 | headers, _ := rd.CallEndpoint(fmt.Sprintf("/%s/?limit=1", t), "GET", nil, &res, false) 35 | if res == nil { 36 | return 37 | } 38 | 39 | count := 0 40 | if headers.Get("X-Total-Count") != "" { 41 | c, err := strconv.Atoi(headers.Get("X-Total-Count")) 42 | if err == nil { 43 | count = c 44 | } 45 | } 46 | 47 | for count > 0 { 48 | 49 | rd.CallEndpoint(fmt.Sprintf("/%s/?limit=200", t), "GET", nil, &res, false) 50 | for _, v := range res.([]any) { 51 | rd.CallEndpoint( 52 | fmt.Sprintf("/%s/delete/%s", t, v.(map[string]any)["id"].(string), nil), 53 | "DELETE", 54 | nil, 55 | nil, 56 | false, 57 | ) 58 | } 59 | count -= 200 60 | } 61 | 62 | log.Info("realdebrid cleanup", "type", t, "count", count) 63 | } 64 | 65 | func (rd *RealDebrid) RefreshTokens() { 66 | records := []models.Record{} 67 | rd.app.Dao().RecordQuery("settings").All(&records) 68 | if len(records) == 0 { 69 | return 70 | } 71 | data := make(map[string]any) 72 | r := records[0] 73 | 74 | rdsets := rd.settings.GetRealDebrid() 75 | request := resty.New().SetRetryCount(3).SetRetryWaitTime(time.Second * 3).R() 76 | request.SetHeader("Authorization", "Bearer "+rdsets.AccessToken) 77 | 78 | for k, v := range rd.Headers { 79 | 80 | if funk.Contains([]string{"Host", "Connection"}, k) { 81 | continue 82 | } 83 | request.SetHeader(k, v.(string)) 84 | } 85 | if _, err := request.SetFormData(map[string]string{ 86 | "client_id": rdsets.ClientId, 87 | "client_secret": rdsets.ClientSecret, 88 | "code": rdsets.RefreshToken, 89 | "grant_type": "http://oauth.net/grant_type/device/1.0", 90 | }).SetResult(&data).Post("https://api.real-debrid.com/oauth/v2/token"); err == nil { 91 | if data["access_token"] == nil || data["refresh_token"] == nil { 92 | return 93 | } 94 | rdsets.AccessToken = data["access_token"].(string) 95 | rdsets.RefreshToken = data["refresh_token"].(string) 96 | r.Set("real_debrid", rdsets) 97 | log.Info("realdebrid", "token", "refreshed") 98 | rd.app.Dao().SaveRecord(&r) 99 | } 100 | } 101 | 102 | func (rd *RealDebrid) CallEndpoint( 103 | endpoint string, 104 | method string, 105 | body map[string]string, 106 | data any, 107 | isAuth bool, 108 | ) (http.Header, int) { 109 | request := resty.New(). 110 | SetRetryCount(3). 111 | SetRetryWaitTime(time.Second * 1). 112 | AddRetryCondition(func(r *resty.Response, err error) bool { 113 | return r.StatusCode() == 429 114 | }). 115 | R() 116 | request.SetResult(&data) 117 | var respHeaders http.Header 118 | status := 200 119 | 120 | request.SetFormData(body) 121 | 122 | if !isAuth { 123 | 124 | rdsets := rd.settings.GetRealDebrid() 125 | request.SetHeader("Authorization", "Bearer "+rdsets.AccessToken) 126 | } 127 | 128 | request.Attempt = 3 129 | 130 | var r func(url string) (*resty.Response, error) 131 | switch method { 132 | case "POST": 133 | r = request.Post 134 | case "PATCH": 135 | r = request.Patch 136 | case "PUT": 137 | r = request.Put 138 | case "DELETE": 139 | r = request.Delete 140 | default: 141 | r = request.Get 142 | 143 | } 144 | 145 | host := "https://api.real-debrid.com/rest/1.0" 146 | if isAuth { 147 | host = "https://api.real-debrid.com/oauth/v2" 148 | } 149 | 150 | resp, err := r(host + endpoint) 151 | if err == nil { 152 | respHeaders = resp.Header() 153 | status = resp.StatusCode() 154 | } else { 155 | log.Error("realdebrid", "url", endpoint, "error", err) 156 | return respHeaders, status 157 | } 158 | 159 | if status > 299 { 160 | log.Error( 161 | "realdebrid", 162 | "status", 163 | status, 164 | "url", 165 | endpoint, 166 | "data", 167 | strings.Replace(string(resp.Body()), "\n", "", -1), 168 | ) 169 | } else { 170 | log.Debug("realdebrid", "call", fmt.Sprintf("%s %s", method, endpoint)) 171 | } 172 | 173 | return respHeaders, status 174 | } 175 | 176 | type Magnet struct { 177 | Id string `json:"id"` 178 | } 179 | 180 | type Info struct { 181 | Links []string `json:"links"` 182 | } 183 | 184 | type Link struct { 185 | Id string `json:"id"` 186 | Filename string `json:"filename"` 187 | Filesize uint `json:"filesize"` 188 | Download string `json:"download"` 189 | Streamable int `json:"streamable` 190 | } 191 | 192 | func (rd *RealDebrid) Unrestrict(m string) []types.Unrestricted { 193 | magnet := Magnet{} 194 | rd.CallEndpoint("/torrents/addMagnet", "POST", map[string]string{ 195 | "host": "real-debrid.com", 196 | "magnet": m, 197 | }, &magnet, false) 198 | 199 | if magnet.Id == "" { 200 | return nil 201 | } 202 | 203 | defer rd.CallEndpoint( 204 | fmt.Sprintf("/torrents/delete/%s", magnet.Id), 205 | "DELETE", 206 | nil, 207 | nil, 208 | false, 209 | ) 210 | 211 | rd.CallEndpoint("/torrents/selectFiles/"+magnet.Id, "POST", map[string]string{ 212 | "files": "all", 213 | }, nil, false) 214 | 215 | info := Info{} 216 | rd.CallEndpoint("/torrents/info/"+magnet.Id, "GET", nil, &info, false) 217 | 218 | if len(info.Links) == 0 { 219 | return nil 220 | } 221 | 222 | us := []types.Unrestricted{} 223 | 224 | for _, v := range info.Links { 225 | u := Link{} 226 | rd.CallEndpoint("/unrestrict/link", "POST", map[string]string{ 227 | "link": v, 228 | }, &u, false) 229 | if u.Filename == "" { 230 | continue 231 | } 232 | fname := u.Filename 233 | 234 | mimetype := mime.TypeByExtension(fname[strings.LastIndex(fname, "."):]) 235 | 236 | isVideo := strings.Contains( 237 | mimetype, 238 | "video", 239 | ) 240 | 241 | match, _ := regexp.MatchString("^[Ss]ample[ -_]?[0-9].", fname) 242 | 243 | if !match && isVideo { 244 | log.Debug("realdebrid unrestricted", "file", fname) 245 | streams := []string{} 246 | if u.Streamable == 1 { 247 | streams = append(streams, "https://real-debrid.com/streaming-"+u.Id) 248 | } 249 | un := types.Unrestricted{Filename: fname, Filesize: int(u.Filesize), Download: u.Download, Streams: streams} 250 | us = append(us, un) 251 | } 252 | } 253 | 254 | return us 255 | } 256 | 257 | func (rd *RealDebrid) Cleanup() { 258 | rd.RemoveByType("downloads") 259 | } 260 | -------------------------------------------------------------------------------- /apps/backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/odin-movieshow/backend 2 | 3 | go 1.22.7 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/charmbracelet/log v0.4.0 9 | github.com/eclipse/paho.mqtt.golang v1.5.0 10 | github.com/go-resty/resty/v2 v2.15.3 11 | github.com/joho/godotenv v1.5.1 12 | github.com/labstack/echo/v5 v5.0.0-20230722203903-ec5b858dab61 13 | github.com/pocketbase/dbx v1.10.1 14 | github.com/pocketbase/pocketbase v0.22.23 15 | github.com/thoas/go-funk v0.9.3 16 | ) 17 | 18 | require ( 19 | cloud.google.com/go/iam v1.2.1 // indirect 20 | github.com/AlecAivazis/survey/v2 v2.3.7 // indirect 21 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 22 | github.com/aws/aws-sdk-go-v2 v1.32.4 // indirect 23 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.6 // indirect 24 | github.com/aws/aws-sdk-go-v2/config v1.28.2 // indirect 25 | github.com/aws/aws-sdk-go-v2/credentials v1.17.43 // indirect 26 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.19 // indirect 27 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.36 // indirect 28 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.23 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.23 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.23 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.0 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.4 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.4 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.4 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/s3 v1.66.3 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.4 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.4 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/sts v1.32.4 // indirect 40 | github.com/aws/smithy-go v1.22.0 // indirect 41 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 42 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 43 | github.com/charmbracelet/lipgloss v1.0.0 // indirect 44 | github.com/charmbracelet/x/ansi v0.4.2 // indirect 45 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 46 | github.com/disintegration/imaging v1.6.2 // indirect 47 | github.com/domodwyer/mailyak/v3 v3.6.2 // indirect 48 | github.com/dustin/go-humanize v1.0.1 // indirect 49 | github.com/fatih/color v1.18.0 // indirect 50 | github.com/gabriel-vasile/mimetype v1.4.6 // indirect 51 | github.com/ganigeorgiev/fexpr v0.4.1 // indirect 52 | github.com/go-logfmt/logfmt v0.6.0 // indirect 53 | github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect 54 | github.com/goccy/go-json v0.10.3 // indirect 55 | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect 56 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 57 | github.com/google/pprof v0.0.0-20241101162523-b92577c0c142 // indirect 58 | github.com/google/uuid v1.6.0 // indirect 59 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect 60 | github.com/gorilla/websocket v1.5.3 // indirect 61 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 62 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 63 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 64 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 65 | github.com/mattn/go-colorable v0.1.13 // indirect 66 | github.com/mattn/go-isatty v0.0.20 // indirect 67 | github.com/mattn/go-runewidth v0.0.16 // indirect 68 | github.com/mattn/go-sqlite3 v1.14.24 // indirect 69 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 70 | github.com/muesli/termenv v0.15.2 // indirect 71 | github.com/ncruces/go-strftime v0.1.9 // indirect 72 | github.com/redis/go-redis/v9 v9.9.0 // indirect 73 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 74 | github.com/rivo/uniseg v0.4.7 // indirect 75 | github.com/spf13/cast v1.7.0 // indirect 76 | github.com/spf13/cobra v1.8.1 // indirect 77 | github.com/spf13/pflag v1.0.5 // indirect 78 | github.com/valyala/bytebufferpool v1.0.0 // indirect 79 | github.com/valyala/fasttemplate v1.2.2 // indirect 80 | go.opencensus.io v0.24.0 // indirect 81 | gocloud.dev v0.40.0 // indirect 82 | golang.org/x/crypto v0.28.0 // indirect 83 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect 84 | golang.org/x/image v0.21.0 // indirect 85 | golang.org/x/net v0.30.0 // indirect 86 | golang.org/x/oauth2 v0.23.0 // indirect 87 | golang.org/x/sync v0.8.0 // indirect 88 | golang.org/x/sys v0.26.0 // indirect 89 | golang.org/x/term v0.25.0 // indirect 90 | golang.org/x/text v0.19.0 // indirect 91 | golang.org/x/time v0.7.0 // indirect 92 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 93 | google.golang.org/api v0.205.0 // indirect 94 | google.golang.org/genproto/googleapis/api v0.0.0-20241015192408-796eee8c2d53 // indirect 95 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect 96 | google.golang.org/grpc v1.68.0 // indirect 97 | google.golang.org/protobuf v1.35.1 // indirect 98 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect 99 | modernc.org/libc v1.61.0 // indirect 100 | modernc.org/mathutil v1.6.0 // indirect 101 | modernc.org/memory v1.8.0 // indirect 102 | modernc.org/sqlite v1.33.1 // indirect 103 | modernc.org/strutil v1.2.0 // indirect 104 | modernc.org/token v1.1.0 // indirect 105 | ) 106 | -------------------------------------------------------------------------------- /apps/backend/helpers/helpers.go: -------------------------------------------------------------------------------- 1 | package helpers 2 | 3 | import ( 4 | "fmt" 5 | "os/user" 6 | 7 | "github.com/pocketbase/pocketbase" 8 | ) 9 | 10 | type Helpers struct { 11 | app *pocketbase.PocketBase 12 | } 13 | 14 | func New(app *pocketbase.PocketBase) *Helpers { 15 | return &Helpers{app: app} 16 | } 17 | 18 | func (h *Helpers) GetHomeDir() string { 19 | currentUser, err := user.Current() 20 | if err != nil { 21 | fmt.Println(err) 22 | return "" 23 | } 24 | return currentUser.HomeDir 25 | } 26 | -------------------------------------------------------------------------------- /apps/backend/imdb/imdb.go: -------------------------------------------------------------------------------- 1 | package imdb 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | 8 | "github.com/charmbracelet/log" 9 | "github.com/go-resty/resty/v2" 10 | ) 11 | 12 | func Get(id string) map[string]interface{} { 13 | request := resty.New(). 14 | SetRetryCount(3). 15 | SetTimeout(time.Second * 30). 16 | SetRetryWaitTime(time.Second). 17 | R() 18 | 19 | res, err := request.Get("https://www.imdb.com/title/" + id) 20 | r := make(map[string]interface{}) 21 | if err == nil { 22 | b := string(res.Body()) 23 | start := "" 25 | startIndex := strings.Index(b, start) 26 | endIndex := strings.Index(b[startIndex+len(start):], end) 27 | str := b[startIndex+len(start) : endIndex+startIndex+len(start)] 28 | if err := json.Unmarshal([]byte(str), &r); err != nil { 29 | log.Error("imdb", "id", id, "err", err) 30 | } else { 31 | log.Info("imdb", "id", id, "parsed", "success") 32 | } 33 | } 34 | return r 35 | } 36 | -------------------------------------------------------------------------------- /apps/backend/indexer/indexer.go: -------------------------------------------------------------------------------- 1 | package indexer 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/charmbracelet/log" 7 | mqtt "github.com/eclipse/paho.mqtt.golang" 8 | "github.com/odin-movieshow/backend/common" 9 | "github.com/odin-movieshow/backend/indexer/jackett" 10 | ) 11 | 12 | type Indexer struct { 13 | mqt mqtt.Client 14 | } 15 | 16 | func New(mqt mqtt.Client) *Indexer { 17 | return &Indexer{mqt: mqt} 18 | } 19 | 20 | func Index(data common.Payload) { 21 | jackettUrl := os.Getenv("JACKETT_URL") 22 | jackettKey := os.Getenv("JACKETT_KEY") 23 | if jackettUrl == "" || jackettKey == "" { 24 | log.Error("missing env vars JACKETT_URL and JACKETT_KEY") 25 | return 26 | } 27 | 28 | jackett.Search(data) 29 | } 30 | -------------------------------------------------------------------------------- /apps/backend/indexer/jackett/jackett.go: -------------------------------------------------------------------------------- 1 | package jackett 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/charmbracelet/log" 15 | "github.com/go-resty/resty/v2" 16 | "github.com/odin-movieshow/backend/common" 17 | ) 18 | 19 | type Indexer struct { 20 | Id string `xml:"id,attr"` 21 | Title string `xml:"title"` 22 | Caps struct { 23 | Searching struct { 24 | Search struct { 25 | Available string `xml:"available,attr"` 26 | SupportedParams string `xml:"supportedParams,attr"` 27 | } `xml:"search"` 28 | MovieSearch struct { 29 | Available string `xml:"available,attr"` 30 | SupportedParams string `xml:"supportedParams,attr"` 31 | } `xml:"movie-search"` 32 | TvSearch struct { 33 | Available string `xml:"available,attr"` 34 | SupportedParams string `xml:"supportedParams,attr"` 35 | } `xml:"tv-search"` 36 | } `xml:"searching"` 37 | Categories struct { 38 | Category []struct { 39 | ID string `xml:"id,attr"` 40 | Name string `xml:"name,attr"` 41 | } `xml:"category"` 42 | } `xml:"categories"` 43 | } `xml:"caps"` 44 | } 45 | 46 | func (indexer *Indexer) SearchAvailable() bool { 47 | return indexer.Caps.Searching.Search.Available == "yes" 48 | } 49 | 50 | func (indexer *Indexer) MovieSearchAvailable() bool { 51 | return indexer.Caps.Searching.MovieSearch.Available == "yes" 52 | } 53 | 54 | func (indexer *Indexer) TvSearchAvailable() bool { 55 | return indexer.Caps.Searching.TvSearch.Available == "yes" 56 | } 57 | 58 | func (indexer *Indexer) HasMovieParam(param string) bool { 59 | return strings.Contains(indexer.Caps.Searching.MovieSearch.SupportedParams, param) 60 | } 61 | 62 | func (indexer *Indexer) HasTvParam(param string) bool { 63 | return strings.Contains(indexer.Caps.Searching.TvSearch.SupportedParams, param) 64 | } 65 | 66 | func Search(payload common.Payload) { 67 | mq := common.MqttClient() 68 | 69 | payload.Title = url.QueryEscape(common.Strip(payload.Title)) 70 | payload.EpisodeTitle = url.QueryEscape(common.Strip(payload.EpisodeTitle)) 71 | payload.ShowTitle = url.QueryEscape(common.Strip(payload.ShowTitle)) 72 | 73 | indexers := getIndexerList(payload) 74 | l := log.Debug 75 | wg := sync.WaitGroup{} 76 | indexertopic := "odin-movieshow/indexer/" + payload.Type 77 | if payload.Type == "episode" { 78 | indexertopic += "/" + payload.EpisodeTrakt 79 | } else { 80 | indexertopic += "/" + payload.Trakt 81 | } 82 | total := 0 83 | log.Debug("MQTT", "topic", indexertopic) 84 | for _, indexer := range indexers { 85 | wg.Add(1) 86 | go func(indexer Indexer) { 87 | defer wg.Done() 88 | t1 := time.Now() 89 | ts := getTorrents(indexer, payload) 90 | t2 := time.Now() 91 | if len(ts) > 0 { 92 | l = log.Info 93 | } 94 | l( 95 | "indexer", 96 | "id", 97 | indexer.Title, 98 | "type", 99 | payload.Type, 100 | "torrents", 101 | len(ts), 102 | "took", 103 | fmt.Sprintf("%.1fs", t2.Sub(t1).Seconds()), 104 | ) 105 | if ts != nil { 106 | total += len(ts) 107 | tsstr, _ := json.Marshal(ts) 108 | mq.Publish( 109 | indexertopic, 110 | 0, 111 | false, 112 | tsstr, 113 | ) 114 | } 115 | }(indexer) 116 | } 117 | 118 | wg.Wait() 119 | mq.Publish(indexertopic, 0, false, "INDEXING_DONE") 120 | log.Info("Indexing done", "total", total) 121 | } 122 | 123 | func getIndexerList(payload common.Payload) []Indexer { 124 | cat := "Movies" 125 | 126 | if payload.Type == "episode" { 127 | cat = "TV" 128 | } 129 | var indexers struct { 130 | Indexers []Indexer `xml:"indexer"` 131 | } 132 | 133 | jackettUrl := os.Getenv("JACKETT_URL") 134 | jackettKey := os.Getenv("JACKETT_KEY") 135 | 136 | request := resty.New(). 137 | SetRetryCount(3). 138 | SetTimeout(time.Second * 60). 139 | SetRetryWaitTime(time.Second * 2). 140 | R() 141 | resp, err := request.Get( 142 | fmt.Sprintf( 143 | "%s/api/v2.0/indexers/type:public/results/torznab/api?apikey=%s&t=indexers&configured=true", 144 | jackettUrl, 145 | jackettKey, 146 | ), 147 | ) 148 | if err != nil { 149 | log.Error("getting indexers", "error", err.Error()) 150 | return []Indexer{} 151 | } 152 | 153 | if err := xml.Unmarshal(resp.Body(), &indexers); err != nil { 154 | log.Error("indexers", err) 155 | } 156 | 157 | neededIndexers := []Indexer{} 158 | for _, indexer := range indexers.Indexers { 159 | for _, category := range indexer.Caps.Categories.Category { 160 | if category.Name == cat { 161 | neededIndexers = append(neededIndexers, indexer) 162 | break 163 | } 164 | } 165 | } 166 | log.Info("indexers", "cat", cat, "total", len(indexers.Indexers), "needed", len(neededIndexers)) 167 | return neededIndexers 168 | } 169 | 170 | func getTorrents(indexer Indexer, payload common.Payload) []common.Torrent { 171 | var rss common.Rss 172 | t := "search" 173 | q := "" 174 | season := "" 175 | ep := "" 176 | traktid := "" 177 | imdbid := "" 178 | tvdbid := "" 179 | tmdbid := "" 180 | 181 | if indexer.SearchAvailable() { 182 | q = payload.Title + "+" + payload.Year 183 | if payload.Type == "episode" { 184 | q = payload.ShowTitle 185 | } 186 | } 187 | 188 | if indexer.MovieSearchAvailable() && payload.Type == "movie" { 189 | t = "movie" 190 | if indexer.HasMovieParam("imdbid") { 191 | imdbid = payload.Imdb 192 | } 193 | if indexer.HasMovieParam("traktid") { 194 | traktid = payload.Trakt 195 | } 196 | } 197 | if indexer.TvSearchAvailable() && payload.Type == "episode" { 198 | t = "tvsearch" 199 | if indexer.HasTvParam("imdbid") { 200 | imdbid = payload.EpisodeImdb 201 | } 202 | if indexer.HasTvParam("tvdbid") { 203 | tvdbid = payload.EpisodeTvdb 204 | } 205 | if indexer.HasTvParam("season") { 206 | q = payload.ShowTitle 207 | season = payload.SeasonNumber 208 | } 209 | if indexer.HasTvParam("ep") { 210 | q = payload.ShowTitle 211 | ep = payload.EpisodeNumber 212 | } 213 | 214 | if indexer.HasTvParam("traktid") { 215 | traktid = payload.Trakt 216 | } 217 | 218 | } 219 | 220 | query := "" 221 | if q != "" { 222 | query = "&q=" + q 223 | } 224 | if imdbid != "" { 225 | query = query + "&imdbid=" + imdbid 226 | } 227 | 228 | if tvdbid != "" { 229 | query = query + "&tvdbid=" + tvdbid 230 | } 231 | 232 | if tmdbid != "" { 233 | query = query + "&tmdbid=" + tmdbid 234 | } 235 | 236 | if season != "" { 237 | query = query + "&season=" + season 238 | } 239 | 240 | if ep != "" { 241 | query = query + "&ep=" + ep 242 | } 243 | 244 | if traktid != "" { 245 | query = query + "&traktid=" + traktid 246 | } 247 | 248 | var torrents []common.Torrent 249 | 250 | request := resty.New(). 251 | // SetRetryCount(1). 252 | SetTimeout(time.Second * 60). 253 | // SetRetryWaitTime(time.Second * 2). 254 | R() 255 | 256 | jackettUrl := os.Getenv("JACKETT_URL") 257 | jackettKey := os.Getenv("JACKETT_KEY") 258 | 259 | url := fmt.Sprintf( 260 | "%s/api/v2.0/indexers/%s/results/torznab/api?apikey=%s&t=%s%s", 261 | jackettUrl, 262 | indexer.Id, 263 | jackettKey, 264 | t, 265 | query, 266 | ) 267 | resp, err := request.Get(url) 268 | if err != nil { 269 | log.Error("get request", "indexer", indexer.Id, "error", err.Error()) 270 | return torrents 271 | } 272 | 273 | if err := xml.Unmarshal(resp.Body(), &rss); err != nil { 274 | if err.Error() != "EOF" { 275 | log.Error("xml unmarshall", "indexer", indexer.Id, "error", err.Error()) 276 | } 277 | return torrents 278 | } 279 | 280 | for _, item := range rss.Channel.Items { 281 | t := common.Torrent{} 282 | t.Scraper = indexer.Id 283 | t.Size = uint64(item.Size) 284 | t.ReleaseTitle = item.Title 285 | infos, q := common.GetInfos(item.Title) 286 | t.Info = infos 287 | t.Quality = q 288 | t.Name = item.Title 289 | for _, attr := range item.Attrs { 290 | if attr.Name == "magneturl" { 291 | t.Magnet = common.SimplifyMagnet(attr.Value) 292 | } 293 | if attr.Name == "infohash" { 294 | t.Hash = attr.Value 295 | } 296 | if attr.Name == "seeders" { 297 | c, err := strconv.Atoi(attr.Value) 298 | if err == nil { 299 | t.Seeds = uint64(c) 300 | } 301 | } 302 | } 303 | if t.Magnet != "" { 304 | log.Debug("Torrent", "hash", t.Hash, "name", t.Name) 305 | torrents = append(torrents, t) 306 | } 307 | } 308 | 309 | return torrents 310 | } 311 | -------------------------------------------------------------------------------- /apps/backend/indexer/prowlarr/prowlarr.go: -------------------------------------------------------------------------------- 1 | // WIP - probably won't use 2 | 3 | package prowlarr 4 | 5 | import ( 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/odin-movieshow/backend/common" 14 | 15 | "github.com/charmbracelet/log" 16 | "github.com/go-resty/resty/v2" 17 | "github.com/thoas/go-funk" 18 | ) 19 | 20 | type Indexer struct { 21 | Id uint32 `json:"id"` 22 | Title string `json:"name"` 23 | Caps struct { 24 | SupportsRawSearch bool `json:"supportsRawSearch"` 25 | SearchParams []string `json:"searchParams"` 26 | MovieSearchParams []string `json:"movieSearchParams"` 27 | TvSearchParams []string `json:"tvSearchParams"` 28 | Categories []struct { 29 | ID uint32 `json:"id"` 30 | Name string `json:"name"` 31 | } `json:"categories"` 32 | } `json:"capabilities"` 33 | } 34 | 35 | func (indexer *Indexer) SearchAvailable() bool { 36 | return indexer.Caps.SupportsRawSearch 37 | } 38 | 39 | func (indexer *Indexer) MovieSearchAvailable() bool { 40 | return indexer.Caps.MovieSearchParams != nil && len(indexer.Caps.MovieSearchParams) > 0 41 | } 42 | 43 | func (indexer *Indexer) TvSearchAvailable() bool { 44 | return indexer.Caps.TvSearchParams != nil && len(indexer.Caps.TvSearchParams) > 0 45 | } 46 | 47 | func (indexer *Indexer) HasMovieParam(param string) bool { 48 | return indexer.MovieSearchAvailable() && funk.Contains(indexer.Caps.MovieSearchParams, param) 49 | } 50 | 51 | func (indexer *Indexer) HasTvParam(param string) bool { 52 | return indexer.TvSearchAvailable() && funk.Contains(indexer.Caps.TvSearchParams, param) 53 | } 54 | 55 | func Search(payload common.Payload) { 56 | payload.Title = url.QueryEscape(common.Strip(payload.Title)) 57 | payload.EpisodeTitle = url.QueryEscape(common.Strip(payload.EpisodeTitle)) 58 | payload.ShowTitle = url.QueryEscape(common.Strip(payload.ShowTitle)) 59 | 60 | indexers := getIndexerList(payload) 61 | 62 | allTorrents := []common.Torrent{} 63 | l := log.Warn 64 | wg := sync.WaitGroup{} 65 | for _, indexer := range indexers { 66 | wg.Add(1) 67 | go func(indexer Indexer) { 68 | defer wg.Done() 69 | t1 := time.Now() 70 | ts := getTorrents(indexer, payload) 71 | t2 := time.Now() 72 | if len(ts) > 0 { 73 | l = log.Info 74 | } 75 | l( 76 | "indexer", 77 | "id", 78 | indexer.Title, 79 | "torrents", 80 | len(ts), 81 | "took", 82 | fmt.Sprintf("%.1fs", t2.Sub(t1).Seconds()), 83 | ) 84 | allTorrents = append(allTorrents, ts...) 85 | }(indexer) 86 | } 87 | 88 | wg.Wait() 89 | } 90 | 91 | func getIndexerList(payload common.Payload) []Indexer { 92 | cat := "Movies" 93 | 94 | prowlarrUrl := os.Getenv("PROWLARR_URL") 95 | prowlarrKey := os.Getenv("PROWLARR_KEY") 96 | 97 | if payload.Type == "episode" { 98 | cat = "TV" 99 | } 100 | var indexers []Indexer 101 | 102 | request := resty.New(). 103 | SetRetryCount(3). 104 | SetTimeout(time.Second * 30). 105 | SetRetryWaitTime(time.Second * 2). 106 | R() 107 | _, err := request.SetResult(&indexers).Get( 108 | fmt.Sprintf( 109 | "%s/api/v1/indexer?apikey=%s&t=indexers&configured=true", 110 | prowlarrUrl, 111 | prowlarrKey, 112 | ), 113 | ) 114 | if err != nil { 115 | log.Error("getting indexers", "error", err.Error()) 116 | return []Indexer{} 117 | } 118 | neededIndexers := []Indexer{} 119 | for _, indexer := range indexers { 120 | for _, category := range indexer.Caps.Categories { 121 | if category.Name == cat { 122 | neededIndexers = append(neededIndexers, indexer) 123 | break 124 | } 125 | } 126 | } 127 | 128 | log.Info("indexers", "cat", cat, "total", len(indexers), "needed", len(neededIndexers)) 129 | 130 | return neededIndexers 131 | } 132 | 133 | func getTorrents(indexer Indexer, payload common.Payload) []common.Torrent { 134 | // var rss common.Rss 135 | t := "search" 136 | q := "" 137 | season := "" 138 | ep := "" 139 | traktid := "" 140 | imdbid := "" 141 | tvdbid := "" 142 | tmdbid := "" 143 | if indexer.SearchAvailable() { 144 | q = payload.Title + "+" + payload.Year 145 | if payload.Type == "episode" { 146 | q = payload.ShowTitle + "+S" + payload.SeasonNumber + "+E" + payload.EpisodeNumber 147 | } 148 | } 149 | 150 | if indexer.MovieSearchAvailable() && payload.Type == "movie" { 151 | t = "movie" 152 | if indexer.HasMovieParam("imdbid") { 153 | imdbid = payload.Imdb 154 | } 155 | if indexer.HasMovieParam("traktid") { 156 | traktid = payload.Trakt 157 | } 158 | } 159 | if indexer.TvSearchAvailable() && payload.Type == "episode" { 160 | t = "tvsearch" 161 | if indexer.HasTvParam("imdbid") { 162 | imdbid = payload.EpisodeImdb 163 | } 164 | if indexer.HasTvParam("tvdbid") { 165 | tvdbid = payload.EpisodeTvdb 166 | } 167 | if indexer.HasTvParam("season") { 168 | // q = payload.ShowTitle 169 | season = payload.SeasonNumber 170 | } 171 | if indexer.HasTvParam("ep") { 172 | // q = payload.ShowTitle 173 | ep = payload.EpisodeNumber 174 | } 175 | 176 | if indexer.HasTvParam("traktid") { 177 | traktid = payload.Trakt 178 | } 179 | 180 | } 181 | 182 | query := "&t=" + t 183 | if q != "" { 184 | query = "&query=" + q 185 | } 186 | if imdbid != "" { 187 | query = query + "&imdbid=" + imdbid 188 | } 189 | 190 | if tvdbid != "" { 191 | query = query + "&tvdbid=" + tvdbid 192 | } 193 | 194 | if tmdbid != "" { 195 | query = query + "&tmdbid=" + tmdbid 196 | } 197 | 198 | if season != "" { 199 | query = query + "&season=" + season 200 | } 201 | 202 | if ep != "" { 203 | query = query + "&ep=" + ep 204 | } 205 | 206 | if traktid != "" { 207 | query = query + "&traktid=" + traktid 208 | } 209 | 210 | torrents := []common.Torrent{} 211 | 212 | request := resty.New(). 213 | // SetRetryCount(1). 214 | SetTimeout(time.Second * 30). 215 | // SetRetryWaitTime(time.Second * 2). 216 | R() 217 | 218 | prowlarrUrl := os.Getenv("PROWLARR_URL") 219 | prowlarrKey := os.Getenv("PROWLARR_KEY") 220 | 221 | url := fmt.Sprintf( 222 | "%s/api/v1/search?apikey=%s&indexerId=%d%s", 223 | prowlarrUrl, 224 | prowlarrKey, 225 | indexer.Id, 226 | query, 227 | ) 228 | 229 | log.Info(url) 230 | 231 | type Res struct { 232 | Title string `json:"title"` 233 | Seeders uint64 `json:"seeders"` 234 | DownloadUrl string `json:"downloadUrl"` 235 | Indexer string `json:"indexer"` 236 | Size uint32 `json:"size"` 237 | } 238 | 239 | res := []Res{} 240 | _, err := request.SetResult(&res).Get(url) 241 | if err != nil { 242 | log.Error("get request", "indexer", indexer.Id, "error", err.Error()) 243 | return torrents 244 | } 245 | 246 | wg := sync.WaitGroup{} 247 | 248 | for _, item := range res { 249 | 250 | wg.Add(1) 251 | go func(item Res, indexer *Indexer, wg *sync.WaitGroup, torrents *[]common.Torrent) { 252 | defer wg.Done() 253 | t := common.Torrent{} 254 | t.Scraper = indexer.Title 255 | t.Size = uint64(item.Size) 256 | t.ReleaseTitle = item.Title 257 | infos, q := common.GetInfos(item.Title) 258 | t.Info = infos 259 | t.Quality = q 260 | 261 | r := resty.New(). 262 | SetTimeout(time.Second * 10). 263 | SetRedirectPolicy(resty.FlexibleRedirectPolicy(0)). 264 | R() 265 | magnet := "" 266 | resp, err := r.Get(item.DownloadUrl) 267 | // log.Debug(resp.Request.URL) 268 | if err == nil { 269 | } 270 | 271 | l := resp.Header().Get("Location") 272 | if strings.Contains(l, "magnet") { 273 | magnet = l 274 | } 275 | 276 | t.Name = item.Title 277 | t.Magnet = magnet 278 | t.Hash = magnet 279 | t.Seeds = item.Seeders 280 | *torrents = append(*torrents, t) 281 | }( 282 | item, 283 | &indexer, 284 | &wg, 285 | &torrents, 286 | ) 287 | 288 | } 289 | 290 | wg.Wait() 291 | 292 | return torrents 293 | } 294 | -------------------------------------------------------------------------------- /apps/backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/charmbracelet/log" 14 | "github.com/joho/godotenv" 15 | "github.com/odin-movieshow/backend/cache" 16 | "github.com/odin-movieshow/backend/common" 17 | "github.com/odin-movieshow/backend/downloader/alldebrid" 18 | "github.com/odin-movieshow/backend/downloader/realdebrid" 19 | "github.com/odin-movieshow/backend/helpers" 20 | "github.com/odin-movieshow/backend/imdb" 21 | "github.com/odin-movieshow/backend/scraper" 22 | "github.com/odin-movieshow/backend/settings" 23 | "github.com/odin-movieshow/backend/tmdb" 24 | "github.com/odin-movieshow/backend/trakt" 25 | "github.com/odin-movieshow/backend/types" 26 | 27 | "github.com/labstack/echo/v5" 28 | _ "github.com/odin-movieshow/backend/migrations" 29 | "github.com/pocketbase/dbx" 30 | "github.com/pocketbase/pocketbase" 31 | "github.com/pocketbase/pocketbase/apis" 32 | "github.com/pocketbase/pocketbase/core" 33 | "github.com/pocketbase/pocketbase/models" 34 | "github.com/pocketbase/pocketbase/plugins/migratecmd" 35 | "github.com/pocketbase/pocketbase/tools/cron" 36 | "github.com/thoas/go-funk" 37 | ) 38 | 39 | func getDevice(app *pocketbase.PocketBase, c echo.Context) (*models.Record, error) { 40 | device := c.Request().Header.Get("Device") 41 | return app.Dao().FindRecordById("devices", device) 42 | } 43 | 44 | func getDeps() echo.MiddlewareFunc { 45 | return func(next echo.HandlerFunc) echo.HandlerFunc { 46 | return func(c echo.Context) error { 47 | return next(c) 48 | } 49 | } 50 | } 51 | 52 | func RequireDeviceOrRecordAuth(app *pocketbase.PocketBase) echo.MiddlewareFunc { 53 | mut := sync.RWMutex{} 54 | return func(next echo.HandlerFunc) echo.HandlerFunc { 55 | return func(c echo.Context) error { 56 | mut.Lock() 57 | record, _ := c.Get("authRecord").(*models.Record) 58 | mut.Unlock() 59 | if record == nil { 60 | d, _ := getDevice(app, c) 61 | if d != nil { 62 | if d.GetBool("verified") { 63 | u, err := app.Dao().FindRecordById("users", d.Get("user").(string)) 64 | if err == nil { 65 | mut.Lock() 66 | c.Set("authRecord", u) 67 | mut.Unlock() 68 | } 69 | } 70 | } 71 | } 72 | 73 | if c.Get("authRecord") == nil { 74 | return apis.NewBadRequestError("Verified device code or Auth are required", nil) 75 | } 76 | 77 | return next(c) 78 | } 79 | } 80 | } 81 | 82 | func getVersion() string { 83 | b, err := os.ReadFile("./version.txt") 84 | if err == nil { 85 | v := string(b) 86 | v = strings.ReplaceAll(v, "\n", "") 87 | return v 88 | } 89 | 90 | return "Missing file: version.txt" 91 | } 92 | 93 | func main() { 94 | godotenv.Load() 95 | 96 | log.SetReportCaller(true) 97 | l, err := log.ParseLevel(os.Getenv("LOG_LEVEL")) 98 | 99 | if err == nil { 100 | log.SetLevel(l) 101 | } else { 102 | log.SetLevel(log.InfoLevel) 103 | } 104 | 105 | conf := pocketbase.Config{DefaultDev: true} 106 | app := pocketbase.NewWithConfig(conf) 107 | migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{ 108 | Automigrate: true, 109 | }) 110 | 111 | app.OnBeforeServe().Add(func(e *core.ServeEvent) error { 112 | settings := settings.New(app) 113 | cache := cache.New(app) 114 | helpers := helpers.New(app) 115 | tmdb := tmdb.New(settings, cache) 116 | trakt := trakt.New(app, tmdb, settings, cache) 117 | realdebrid := realdebrid.New(app, settings) 118 | alldebrid := alldebrid.New(app, settings) 119 | scraper := scraper.New(app, settings, cache, helpers, realdebrid, alldebrid) 120 | 121 | date := time.Now() 122 | 123 | email := "admin@odin.local" 124 | if os.Getenv("ADMIN_EMAIL") != "" { 125 | email = os.Getenv("ADMIN_EMAIL") 126 | } 127 | 128 | password := "odinAdmin1" 129 | if os.Getenv("ADMIN_PASSWORD") != "" { 130 | password = os.Getenv("ADMIN_PASSWORD") 131 | } 132 | 133 | a, _ := app.Dao().FindAdminByEmail(email) 134 | if a == nil { 135 | a = &models.Admin{Email: email} 136 | a.SetPassword(password) 137 | app.Dao().SaveAdmin(a) 138 | } else { 139 | a.SetPassword(password) 140 | app.Dao().SaveAdmin(a) 141 | } 142 | 143 | scheduler := cron.New() 144 | scheduler.MustAdd("hourly", "0 * * * *", func() { 145 | trakt.RefreshTokens() 146 | realdebrid.RefreshTokens() 147 | trakt.SyncHistory("") 148 | }) 149 | scheduler.MustAdd("daily", "0 4 * * *", func() { 150 | realdebrid.Cleanup() 151 | }) 152 | scheduler.Start() 153 | 154 | go func() { 155 | // trakt.SyncHistory() 156 | }() 157 | 158 | e.Router.POST("/-/scrape", func(c echo.Context) error { 159 | mq := common.MqttClient() 160 | var pl common.Payload 161 | log.Debug("Scraping") 162 | c.Bind(&pl) 163 | log.Debug(pl) 164 | go func() { 165 | scraper.GetLinks(pl, mq) 166 | }() 167 | return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) 168 | }, RequireDeviceOrRecordAuth(app)) 169 | 170 | e.Router.GET("/-/imdb/:id", func(c echo.Context) error { 171 | id := c.PathParam("id") 172 | return c.JSON(http.StatusOK, imdb.Get(id)) 173 | }) 174 | 175 | e.Router.GET("/-/device/verify/:id/:name", func(c echo.Context) error { 176 | id := c.PathParam("id") 177 | name := c.PathParam("name") 178 | d, err := app.Dao().FindRecordById("devices", id) 179 | if err != nil { 180 | return c.JSON(http.StatusNotFound, nil) 181 | } 182 | uid := d.Get("user").(string) 183 | u, err := app.Dao().FindRecordById("users", uid) 184 | if err != nil { 185 | return c.JSON(http.StatusNotFound, nil) 186 | } 187 | d.Set("verified", true) 188 | d.Set("name", name) 189 | app.Dao().SaveRecord(d) 190 | log.Info("Device verified", "id", id) 191 | return c.JSON(http.StatusOK, u) 192 | }, apis.RequireGuestOnly()) 193 | 194 | e.Router.GET("/-/user", func(c echo.Context) error { 195 | u := c.Get("authRecord") 196 | sections := make(map[string]any) 197 | err := u.(*models.Record).UnmarshalJSONField("trakt_sections", §ions) 198 | 199 | if err == nil { 200 | for _, t := range []string{"home", "movies", "shows"} { 201 | s := sections[t].([]any) 202 | for i := range s { 203 | title := common.ParseDates(sections[t].([]any)[i].(map[string]any)["title"].(string)) 204 | url := common.ParseDates(sections[t].([]any)[i].(map[string]any)["url"].(string)) 205 | sections[t].([]any)[i].(map[string]any)["title"] = title 206 | sections[t].([]any)[i].(map[string]any)["url"] = url 207 | } 208 | 209 | } 210 | 211 | str, err := json.Marshal(sections) 212 | if err == nil { 213 | u.(*models.Record).Set("trakt_sections", string(str)) 214 | } 215 | } 216 | 217 | return c.JSON(http.StatusOK, u) 218 | }, RequireDeviceOrRecordAuth(app)) 219 | 220 | e.Router.GET("/-/refreshHistory", func(c echo.Context) error { 221 | info := apis.RequestInfo(c) 222 | id := info.AuthRecord.Id 223 | 224 | q := app.Dao().DB().Delete("history", dbx.NewExp("user = {:user}", dbx.Params{"user": id})) 225 | q.Execute() 226 | 227 | trakt.SyncHistory(id) 228 | 229 | // q := app.Dao().DB().NewQuery("DELETE FROM history WHERE user = {:user}").Bind(dbx.Params{"user": id}) 230 | 231 | return c.String(http.StatusOK, "OK") 232 | }, RequireDeviceOrRecordAuth(app)) 233 | 234 | e.Router.Any("/-/trakt/*", func(c echo.Context) error { 235 | info := apis.RequestInfo(c) 236 | 237 | id := info.AuthRecord.Id 238 | url := strings.ReplaceAll(c.Request().URL.String(), "/-/trakt", "") 239 | 240 | t := make(map[string]any) 241 | u, _ := app.Dao().FindRecordById("users", id) 242 | u.UnmarshalJSONField("trakt_token", &t) 243 | // delete(trakt.Headers, "authorization") 244 | // 245 | theaders := map[string]string{} 246 | 247 | if t != nil && t["access_token"] != nil { 248 | theaders["authorization"] = "Bearer " + t["access_token"].(string) 249 | } 250 | if strings.Contains(url, "fresh=true") { 251 | delete(theaders, "authorization") 252 | } 253 | 254 | jsonData := apis.RequestInfo(c).Data 255 | 256 | if strings.Contains(url, "scrobble/stop") { 257 | go func() { 258 | trakt.SyncHistory(id) 259 | }() 260 | } 261 | url = common.ParseDates(url) 262 | result, headers, status := trakt.CallEndpoint( 263 | url, 264 | c.Request().Method, 265 | types.TraktParams{ 266 | Body: jsonData, 267 | Donorm: true, 268 | Headers: theaders, 269 | FetchTMDB: true, 270 | }, 271 | ) 272 | 273 | for k, v := range headers { 274 | if funk.Contains([]string{ 275 | "Content-Encoding", 276 | "Access-Control-Allow-Origin", 277 | }, k) { 278 | continue 279 | } 280 | c.Response().Header().Add(k, v[0]) 281 | } 282 | c.Response().Status = status 283 | 284 | return c.JSON(http.StatusOK, result) 285 | }, RequireDeviceOrRecordAuth(app)) 286 | 287 | e.Router.Any("/-/realdebrid/*", func(c echo.Context) error { 288 | url := strings.ReplaceAll(c.Request().URL.String(), "/-/realdebrid", "") 289 | var result interface{} 290 | var headers http.Header 291 | isAuth := false 292 | status := 0 293 | if strings.Contains(url, "/isAuth") { 294 | url = strings.ReplaceAll(url, "/isAuth", "") 295 | isAuth = true 296 | } 297 | 298 | data := apis.RequestInfo(c).Data 299 | 300 | var body map[string]string 301 | if data != nil { 302 | jm, err := json.Marshal(data) 303 | log.Debug(string(jm)) 304 | if err == nil { 305 | err := json.Unmarshal(jm, &body) 306 | if err != nil { 307 | log.Warn(body) 308 | } 309 | } 310 | } 311 | 312 | headers, status = realdebrid.CallEndpoint(url, c.Request().Method, body, &result, isAuth) 313 | 314 | for k, v := range headers { 315 | if funk.Contains([]string{ 316 | "Content-Encoding", 317 | "Access-Control-Allow-Origin", 318 | }, k) { 319 | continue 320 | } 321 | c.Response().Header().Add(k, v[0]) 322 | } 323 | c.Response().Status = status 324 | return c.JSON(http.StatusOK, result) 325 | }, apis.RequireAdminAuth()) 326 | 327 | e.Router.Any("/-/alldebrid/*", func(c echo.Context) error { 328 | url := strings.ReplaceAll(c.Request().URL.String(), "/-/alldebrid", "") 329 | var result interface{} 330 | headers, status := alldebrid.CallEndpoint(url, c.Request().Method, nil, &result) 331 | 332 | for k, v := range headers { 333 | if funk.Contains([]string{ 334 | "Content-Encoding", 335 | "Access-Control-Allow-Origin", 336 | }, k) { 337 | continue 338 | } 339 | c.Response().Header().Add(k, v[0]) 340 | } 341 | c.Response().Status = status 342 | return c.JSON(http.StatusOK, result) 343 | }, apis.RequireAdminAuth()) 344 | 345 | e.Router.GET("/-/traktseasons/:id", func(c echo.Context) error { 346 | fmt.Println(c.PathParam("id")) 347 | id, _ := strconv.Atoi(c.PathParam("id")) 348 | res := trakt.GetSeasons(id) 349 | return c.JSON(http.StatusOK, res) 350 | }, RequireDeviceOrRecordAuth(app)) 351 | 352 | e.Router.GET("/-/secrets", func(c echo.Context) error { 353 | return c.JSON(http.StatusOK, map[string]string{"TRAKT_CLIENTID": os.Getenv("TRAKT_CLIENTID"), "TRAKT_SECRET": os.Getenv("TRAKT_SECRET")}) 354 | }) 355 | 356 | e.Router.GET("/-/health", func(c echo.Context) error { 357 | ping := c.QueryParam("ping") 358 | if ping != "" { 359 | return c.String(http.StatusOK, "pong") 360 | } 361 | 362 | info := apis.RequestInfo(c) 363 | 364 | id := info.AuthRecord.Id 365 | 366 | t := make(map[string]any) 367 | u, _ := app.Dao().FindRecordById("users", id) 368 | u.UnmarshalJSONField("trakt_token", &t) 369 | // delete(trakt.Headers, "authorization") 370 | // 371 | theaders := map[string]string{} 372 | 373 | if t != nil && t["access_token"] != nil { 374 | theaders["authorization"] = "Bearer " + t["access_token"].(string) 375 | } 376 | 377 | var rd any 378 | realdebrid.CallEndpoint("/user", "GET", nil, &rd, false) 379 | var ad any 380 | alldebrid.CallEndpoint("/user", "GET", nil, &ad) 381 | tr, _, _ := trakt.CallEndpoint("/users/settings", "GET", types.TraktParams{Headers: theaders}) 382 | return c.JSON(http.StatusOK, map[string]any{"realdebrid": rd, "alldebrid": ad, "trakt": tr, "version": getVersion(), "date": date}) 383 | }, RequireDeviceOrRecordAuth(app)) 384 | 385 | e.Router.GET("/-/tmdbseasons/:id", func(c echo.Context) error { 386 | fmt.Println(c.PathParam("id")) 387 | seasons := c.QueryParam("seasons") 388 | res := tmdb.GetEpisodes(c.PathParam("id"), strings.Split(seasons, ",")) 389 | return c.JSON(http.StatusOK, res) 390 | }, RequireDeviceOrRecordAuth(app)) 391 | 392 | return nil 393 | }) 394 | 395 | if err := app.Start(); err != nil { 396 | log.Fatal(err) 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /apps/backend/migrations/1735493885_collections_snapshot.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/pocketbase/dbx" 7 | "github.com/pocketbase/pocketbase/daos" 8 | m "github.com/pocketbase/pocketbase/migrations" 9 | "github.com/pocketbase/pocketbase/models" 10 | ) 11 | 12 | func init() { 13 | m.Register(func(db dbx.Builder) error { 14 | jsonData := `[ 15 | { 16 | "id": "_pb_users_auth_", 17 | "created": "2023-12-15 18:30:15.247Z", 18 | "updated": "2023-12-24 21:24:28.471Z", 19 | "name": "users", 20 | "type": "auth", 21 | "system": false, 22 | "schema": [ 23 | { 24 | "system": false, 25 | "id": "users_name", 26 | "name": "name", 27 | "type": "text", 28 | "required": false, 29 | "presentable": false, 30 | "unique": false, 31 | "options": { 32 | "min": null, 33 | "max": null, 34 | "pattern": "" 35 | } 36 | }, 37 | { 38 | "system": false, 39 | "id": "users_avatar", 40 | "name": "avatar", 41 | "type": "file", 42 | "required": false, 43 | "presentable": false, 44 | "unique": false, 45 | "options": { 46 | "mimeTypes": [ 47 | "image/jpeg", 48 | "image/png", 49 | "image/svg+xml", 50 | "image/gif", 51 | "image/webp" 52 | ], 53 | "thumbs": null, 54 | "maxSelect": 1, 55 | "maxSize": 5242880, 56 | "protected": false 57 | } 58 | }, 59 | { 60 | "system": false, 61 | "id": "ebrrsidx", 62 | "name": "trakt_sections", 63 | "type": "json", 64 | "required": false, 65 | "presentable": false, 66 | "unique": false, 67 | "options": { 68 | "maxSize": 2000000 69 | } 70 | }, 71 | { 72 | "system": false, 73 | "id": "hpy6zodi", 74 | "name": "trakt_token", 75 | "type": "json", 76 | "required": false, 77 | "presentable": false, 78 | "unique": false, 79 | "options": { 80 | "maxSize": 2000000 81 | } 82 | } 83 | ], 84 | "indexes": [], 85 | "listRule": "id = @request.auth.id", 86 | "viewRule": "id = @request.auth.id", 87 | "createRule": null, 88 | "updateRule": "id = @request.auth.id", 89 | "deleteRule": "id = @request.auth.id", 90 | "options": { 91 | "allowEmailAuth": true, 92 | "allowOAuth2Auth": true, 93 | "allowUsernameAuth": true, 94 | "exceptEmailDomains": null, 95 | "manageRule": null, 96 | "minPasswordLength": 8, 97 | "onlyEmailDomains": null, 98 | "onlyVerified": false, 99 | "requireEmail": false 100 | } 101 | }, 102 | { 103 | "id": "kog9lz3zq2kj07s", 104 | "created": "2023-12-15 18:32:16.845Z", 105 | "updated": "2024-12-25 07:47:18.193Z", 106 | "name": "settings", 107 | "type": "base", 108 | "system": false, 109 | "schema": [ 110 | { 111 | "system": false, 112 | "id": "qcycymzs", 113 | "name": "app", 114 | "type": "json", 115 | "required": false, 116 | "presentable": false, 117 | "unique": false, 118 | "options": { 119 | "maxSize": 2000000 120 | } 121 | }, 122 | { 123 | "system": false, 124 | "id": "wfnmmcoj", 125 | "name": "real_debrid", 126 | "type": "json", 127 | "required": false, 128 | "presentable": false, 129 | "unique": false, 130 | "options": { 131 | "maxSize": 2000000 132 | } 133 | } 134 | ], 135 | "indexes": [], 136 | "listRule": "@request.auth.id != \"\"", 137 | "viewRule": "@request.auth.id != \"\"", 138 | "createRule": null, 139 | "updateRule": null, 140 | "deleteRule": null, 141 | "options": {} 142 | }, 143 | { 144 | "id": "uvaxc6sfjgaxw9c", 145 | "created": "2023-12-15 18:34:04.439Z", 146 | "updated": "2024-11-03 08:06:04.441Z", 147 | "name": "tmdb", 148 | "type": "base", 149 | "system": false, 150 | "schema": [ 151 | { 152 | "system": false, 153 | "id": "txaj4w1q", 154 | "name": "data", 155 | "type": "json", 156 | "required": false, 157 | "presentable": false, 158 | "unique": false, 159 | "options": { 160 | "maxSize": 2000000 161 | } 162 | }, 163 | { 164 | "system": false, 165 | "id": "5e3mgwxm", 166 | "name": "type", 167 | "type": "select", 168 | "required": false, 169 | "presentable": false, 170 | "unique": false, 171 | "options": { 172 | "maxSelect": 1, 173 | "values": [ 174 | "movie", 175 | "show" 176 | ] 177 | } 178 | }, 179 | { 180 | "system": false, 181 | "id": "0li9qnvg", 182 | "name": "tmdb_id", 183 | "type": "number", 184 | "required": false, 185 | "presentable": false, 186 | "unique": false, 187 | "options": { 188 | "min": null, 189 | "max": null, 190 | "noDecimal": false 191 | } 192 | } 193 | ], 194 | "indexes": [ 195 | "CREATE INDEX ` + "`" + `idx_1ir7EEI` + "`" + ` ON ` + "`" + `tmdb` + "`" + ` (` + "`" + `id` + "`" + `)", 196 | "CREATE INDEX ` + "`" + `idx_eWT8ulS` + "`" + ` ON ` + "`" + `tmdb` + "`" + ` (` + "`" + `tmdb_id` + "`" + `)", 197 | "CREATE INDEX ` + "`" + `idx_7QPyCnz` + "`" + ` ON ` + "`" + `tmdb` + "`" + ` (` + "`" + `type` + "`" + `)" 198 | ], 199 | "listRule": null, 200 | "viewRule": null, 201 | "createRule": null, 202 | "updateRule": null, 203 | "deleteRule": null, 204 | "options": {} 205 | }, 206 | { 207 | "id": "jib32sgrokndtt2", 208 | "created": "2023-12-20 14:59:04.811Z", 209 | "updated": "2023-12-20 16:23:08.540Z", 210 | "name": "history", 211 | "type": "base", 212 | "system": false, 213 | "schema": [ 214 | { 215 | "system": false, 216 | "id": "xfytwctq", 217 | "name": "user", 218 | "type": "relation", 219 | "required": false, 220 | "presentable": false, 221 | "unique": false, 222 | "options": { 223 | "collectionId": "_pb_users_auth_", 224 | "cascadeDelete": false, 225 | "minSelect": null, 226 | "maxSelect": 1, 227 | "displayFields": null 228 | } 229 | }, 230 | { 231 | "system": false, 232 | "id": "axymvzt6", 233 | "name": "data", 234 | "type": "json", 235 | "required": false, 236 | "presentable": false, 237 | "unique": false, 238 | "options": { 239 | "maxSize": 2000000 240 | } 241 | }, 242 | { 243 | "system": false, 244 | "id": "vv5en8cb", 245 | "name": "type", 246 | "type": "select", 247 | "required": false, 248 | "presentable": false, 249 | "unique": false, 250 | "options": { 251 | "maxSelect": 1, 252 | "values": [ 253 | "movie", 254 | "episode" 255 | ] 256 | } 257 | }, 258 | { 259 | "system": false, 260 | "id": "293urao6", 261 | "name": "trakt_id", 262 | "type": "number", 263 | "required": false, 264 | "presentable": false, 265 | "unique": false, 266 | "options": { 267 | "min": null, 268 | "max": null, 269 | "noDecimal": false 270 | } 271 | }, 272 | { 273 | "system": false, 274 | "id": "spgro5mw", 275 | "name": "watched_at", 276 | "type": "date", 277 | "required": false, 278 | "presentable": false, 279 | "unique": false, 280 | "options": { 281 | "min": "", 282 | "max": "" 283 | } 284 | }, 285 | { 286 | "system": false, 287 | "id": "09ahphxh", 288 | "name": "runtime", 289 | "type": "number", 290 | "required": false, 291 | "presentable": false, 292 | "unique": false, 293 | "options": { 294 | "min": null, 295 | "max": null, 296 | "noDecimal": false 297 | } 298 | }, 299 | { 300 | "system": false, 301 | "id": "xeh8twko", 302 | "name": "show_id", 303 | "type": "number", 304 | "required": false, 305 | "presentable": false, 306 | "unique": false, 307 | "options": { 308 | "min": null, 309 | "max": null, 310 | "noDecimal": false 311 | } 312 | } 313 | ], 314 | "indexes": [ 315 | "CREATE INDEX ` + "`" + `idx_SHZ0Jzt` + "`" + ` ON ` + "`" + `history` + "`" + ` (` + "`" + `show_id` + "`" + `)", 316 | "CREATE INDEX ` + "`" + `idx_WaWH7nI` + "`" + ` ON ` + "`" + `history` + "`" + ` (` + "`" + `trakt_id` + "`" + `)" 317 | ], 318 | "listRule": "user.id = @request.auth.id", 319 | "viewRule": "user.id = @request.auth.id", 320 | "createRule": "", 321 | "updateRule": null, 322 | "deleteRule": null, 323 | "options": {} 324 | }, 325 | { 326 | "id": "gy7oibx0bg0num2", 327 | "created": "2023-12-21 16:39:00.621Z", 328 | "updated": "2023-12-21 17:11:57.884Z", 329 | "name": "devices", 330 | "type": "base", 331 | "system": false, 332 | "schema": [ 333 | { 334 | "system": false, 335 | "id": "17ici2x4", 336 | "name": "user", 337 | "type": "relation", 338 | "required": false, 339 | "presentable": false, 340 | "unique": false, 341 | "options": { 342 | "collectionId": "_pb_users_auth_", 343 | "cascadeDelete": false, 344 | "minSelect": null, 345 | "maxSelect": 1, 346 | "displayFields": null 347 | } 348 | }, 349 | { 350 | "system": false, 351 | "id": "iy8rm0eq", 352 | "name": "token", 353 | "type": "text", 354 | "required": false, 355 | "presentable": false, 356 | "unique": false, 357 | "options": { 358 | "min": null, 359 | "max": null, 360 | "pattern": "" 361 | } 362 | }, 363 | { 364 | "system": false, 365 | "id": "xqkkkixs", 366 | "name": "verified", 367 | "type": "bool", 368 | "required": false, 369 | "presentable": false, 370 | "unique": false, 371 | "options": {} 372 | }, 373 | { 374 | "system": false, 375 | "id": "u20rxpwn", 376 | "name": "name", 377 | "type": "text", 378 | "required": false, 379 | "presentable": false, 380 | "unique": false, 381 | "options": { 382 | "min": null, 383 | "max": null, 384 | "pattern": "" 385 | } 386 | } 387 | ], 388 | "indexes": [ 389 | "CREATE INDEX ` + "`" + `idx_Q19a20R` + "`" + ` ON ` + "`" + `devices` + "`" + ` (` + "`" + `user` + "`" + `)", 390 | "CREATE INDEX ` + "`" + `idx_JuaFpI7` + "`" + ` ON ` + "`" + `devices` + "`" + ` (` + "`" + `token` + "`" + `)", 391 | "CREATE INDEX ` + "`" + `idx_7TjpMBM` + "`" + ` ON ` + "`" + `devices` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `token` + "`" + `\n)" 392 | ], 393 | "listRule": "user.id = @request.auth.id", 394 | "viewRule": "user.id = @request.auth.id", 395 | "createRule": "user.id = @request.auth.id", 396 | "updateRule": null, 397 | "deleteRule": null, 398 | "options": {} 399 | }, 400 | { 401 | "id": "7n09aifcpshv459", 402 | "created": "2024-02-22 10:21:47.021Z", 403 | "updated": "2024-10-18 09:43:52.826Z", 404 | "name": "trakt_seasons", 405 | "type": "base", 406 | "system": false, 407 | "schema": [ 408 | { 409 | "system": false, 410 | "id": "hjqjynor", 411 | "name": "data", 412 | "type": "json", 413 | "required": false, 414 | "presentable": false, 415 | "unique": false, 416 | "options": { 417 | "maxSize": 2000000 418 | } 419 | }, 420 | { 421 | "system": false, 422 | "id": "uaatrqia", 423 | "name": "trakt_id", 424 | "type": "number", 425 | "required": false, 426 | "presentable": false, 427 | "unique": false, 428 | "options": { 429 | "min": null, 430 | "max": null, 431 | "noDecimal": false 432 | } 433 | } 434 | ], 435 | "indexes": [ 436 | "CREATE INDEX ` + "`" + `idx_O92766T` + "`" + ` ON ` + "`" + `trakt_seasons` + "`" + ` (` + "`" + `trakt_id` + "`" + `)" 437 | ], 438 | "listRule": null, 439 | "viewRule": null, 440 | "createRule": null, 441 | "updateRule": null, 442 | "deleteRule": null, 443 | "options": {} 444 | }, 445 | { 446 | "id": "n434wzwv45po9ib", 447 | "created": "2024-10-18 09:42:49.313Z", 448 | "updated": "2024-10-18 11:54:29.844Z", 449 | "name": "rd_resolved", 450 | "type": "base", 451 | "system": false, 452 | "schema": [ 453 | { 454 | "system": false, 455 | "id": "pyyh9opk", 456 | "name": "resource", 457 | "type": "text", 458 | "required": false, 459 | "presentable": false, 460 | "unique": false, 461 | "options": { 462 | "min": null, 463 | "max": null, 464 | "pattern": "" 465 | } 466 | }, 467 | { 468 | "system": false, 469 | "id": "kkyx6hfp", 470 | "name": "data", 471 | "type": "json", 472 | "required": false, 473 | "presentable": false, 474 | "unique": false, 475 | "options": { 476 | "maxSize": 2000000 477 | } 478 | }, 479 | { 480 | "system": false, 481 | "id": "ncpi3amv", 482 | "name": "magnet", 483 | "type": "text", 484 | "required": false, 485 | "presentable": false, 486 | "unique": false, 487 | "options": { 488 | "min": null, 489 | "max": null, 490 | "pattern": "" 491 | } 492 | } 493 | ], 494 | "indexes": [ 495 | "CREATE INDEX ` + "`" + `idx_PgBvcoT` + "`" + ` ON ` + "`" + `rd_resolved` + "`" + ` (` + "`" + `resource` + "`" + `)" 496 | ], 497 | "listRule": null, 498 | "viewRule": null, 499 | "createRule": null, 500 | "updateRule": null, 501 | "deleteRule": null, 502 | "options": {} 503 | } 504 | ]` 505 | 506 | collections := []*models.Collection{} 507 | if err := json.Unmarshal([]byte(jsonData), &collections); err != nil { 508 | return err 509 | } 510 | 511 | return daos.New(db).ImportCollections(collections, true, nil) 512 | }, func(db dbx.Builder) error { 513 | return nil 514 | }) 515 | } 516 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "watchexec -r -e go -e .env --stop-signal SIGINT -- go run main.go serve --http 0.0.0.0:8090", 7 | "build": "mkdir build || true && CGO_ENABLED=0 go build -o build/odin-server" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/pb_public: -------------------------------------------------------------------------------- 1 | ../frontend/public -------------------------------------------------------------------------------- /apps/backend/scraper/scraper.go: -------------------------------------------------------------------------------- 1 | package scraper 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | mqtt "github.com/eclipse/paho.mqtt.golang" 7 | "github.com/odin-movieshow/backend/cache" 8 | "github.com/odin-movieshow/backend/common" 9 | "github.com/odin-movieshow/backend/downloader/alldebrid" 10 | "github.com/odin-movieshow/backend/downloader/realdebrid" 11 | "github.com/odin-movieshow/backend/helpers" 12 | "github.com/odin-movieshow/backend/indexer" 13 | "github.com/odin-movieshow/backend/settings" 14 | "github.com/odin-movieshow/backend/types" 15 | "github.com/thoas/go-funk" 16 | 17 | "github.com/charmbracelet/log" 18 | "github.com/pocketbase/pocketbase" 19 | ) 20 | 21 | type Scraper struct { 22 | app *pocketbase.PocketBase 23 | settings *settings.Settings 24 | helpers *helpers.Helpers 25 | cache *cache.Cache 26 | realdebrid *realdebrid.RealDebrid 27 | alldebrid *alldebrid.AllDebrid 28 | } 29 | 30 | func New( 31 | app *pocketbase.PocketBase, 32 | settings *settings.Settings, 33 | cache *cache.Cache, 34 | helpers *helpers.Helpers, 35 | realdebrid *realdebrid.RealDebrid, 36 | alldebrid *alldebrid.AllDebrid, 37 | ) *Scraper { 38 | return &Scraper{app: app, settings: settings, helpers: helpers, cache: cache, realdebrid: realdebrid, alldebrid: alldebrid} 39 | } 40 | 41 | func (s *Scraper) GetLinks(data common.Payload, mqt mqtt.Client) { 42 | topic := "odin-movieshow/" + data.Type 43 | indexertopic := "odin-movieshow/indexer/" + data.Type 44 | if data.Type == "episode" { 45 | topic += "/" + data.EpisodeTrakt 46 | indexertopic += "/" + data.EpisodeTrakt 47 | } else { 48 | topic += "/" + data.Trakt 49 | indexertopic += "/" + data.Trakt 50 | } 51 | 52 | log.Debug("test") 53 | 54 | log.Debug("MQTT", "indexer topic", indexertopic) 55 | log.Debug("MQTT", "result topic", topic) 56 | torrentQueueLowPrio := make(chan types.Torrent) 57 | torrentQueueNormalPrio := make(chan types.Torrent) 58 | torrentQueueHighPrio := make(chan types.Torrent) 59 | 60 | allTorrentsUnrestricted := s.cache.GetCachedTorrents("stream:" + topic) 61 | for _, u := range allTorrentsUnrestricted { 62 | cstr, _ := json.Marshal(u) 63 | mqt.Publish(topic, 0, false, cstr) 64 | } 65 | 66 | if token := mqt.Subscribe(indexertopic, 0, func(client mqtt.Client, msg mqtt.Message) { 67 | newTorrents := []types.Torrent{} 68 | json.Unmarshal(msg.Payload(), &newTorrents) 69 | go func() { 70 | for _, t := range newTorrents { 71 | switch t.Quality { 72 | case "4K": 73 | torrentQueueHighPrio <- t 74 | case "1080p": 75 | torrentQueueNormalPrio <- t 76 | default: 77 | torrentQueueLowPrio <- t 78 | } 79 | } 80 | }() 81 | }); token.Wait() && 82 | token.Error() != nil { 83 | log.Error("mqtt-subscribe-indexer", "error", token.Error()) 84 | } 85 | 86 | i := 0 87 | d := 0 88 | 89 | done := []string{} 90 | go func() { 91 | for k := range torrentQueueHighPrio { 92 | s.handlePrio(&i, &d, &done, k, &allTorrentsUnrestricted, mqt, topic) 93 | } 94 | }() 95 | 96 | go func() { 97 | for k := range torrentQueueNormalPrio { 98 | s.handlePrio(&i, &d, &done, k, &allTorrentsUnrestricted, mqt, topic) 99 | } 100 | }() 101 | 102 | go func() { 103 | for k := range torrentQueueLowPrio { 104 | s.handlePrio(&i, &d, &done, k, &allTorrentsUnrestricted, mqt, topic) 105 | } 106 | }() 107 | 108 | indexer.Index(data) 109 | 110 | go func() { 111 | <-torrentQueueLowPrio 112 | }() 113 | 114 | go func() { 115 | <-torrentQueueNormalPrio 116 | }() 117 | 118 | <-torrentQueueHighPrio 119 | mqt.Publish(topic, 0, false, "SCRAPING_DONE") 120 | log.Warn("Scraping done", "unrestricted", d) 121 | } 122 | 123 | func (s *Scraper) handlePrio(i *int, d *int, done *[]string, k types.Torrent, allTorrentsUnrestricted *[]types.Torrent, mqt mqtt.Client, topic string) { 124 | *i++ 125 | // Filter quality from settings 126 | if !funk.Contains(*done, k.Magnet) { 127 | 128 | isUnrestricted := funk.Find(*allTorrentsUnrestricted, func(s types.Torrent) bool { 129 | return s.Magnet == k.Magnet 130 | }) != nil 131 | 132 | if !isUnrestricted { 133 | if s.unrestrict(k, mqt, topic) { 134 | *d++ 135 | } 136 | } 137 | *done = append(*done, k.Magnet) 138 | } 139 | // } 140 | } 141 | 142 | func (s *Scraper) unrestrict( 143 | k types.Torrent, 144 | mqt mqtt.Client, 145 | topic string, 146 | ) bool { 147 | us := s.alldebrid.Unrestrict(k.Magnet) 148 | if len(us) == 0 { 149 | us = s.realdebrid.Unrestrict(k.Magnet) 150 | } 151 | if len(us) == 0 { 152 | return false 153 | } 154 | k.Links = us 155 | log.Info("Found streams for ", k.ReleaseTitle) 156 | kstr, _ := json.Marshal(k) 157 | s.cache.WriteCache("stream", k.Hash, topic, k, 12) 158 | mqt.Publish(topic, 0, false, kstr) 159 | return true 160 | } 161 | -------------------------------------------------------------------------------- /apps/backend/settings/settings.go: -------------------------------------------------------------------------------- 1 | package settings 2 | 3 | import ( 4 | "github.com/pocketbase/pocketbase" 5 | "github.com/pocketbase/pocketbase/models" 6 | ) 7 | 8 | type RealDebridSettings struct { 9 | AccessToken string `json:"access_token"` 10 | ClientId string `json:"client_id"` 11 | ClientSecret string `json:"client_secret"` 12 | RefreshToken string `json:"refresh_token"` 13 | } 14 | 15 | type Settings struct { 16 | app *pocketbase.PocketBase 17 | } 18 | 19 | func New(app *pocketbase.PocketBase) *Settings { 20 | return &Settings{app: app} 21 | } 22 | 23 | func (s *Settings) GetRealDebrid() *RealDebridSettings { 24 | sets := s.getSettings() 25 | if sets != nil { 26 | r := RealDebridSettings{} 27 | if err := sets.UnmarshalJSONField("real_debrid", &r); err == nil { 28 | return &r 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func (s *Settings) getSettings() *models.Record { 35 | sets := []*models.Record{} 36 | s.app.Dao().RecordQuery("settings").All(&sets) 37 | if len(sets) > 0 { 38 | return sets[0] 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /apps/backend/tmdb/tmdb.go: -------------------------------------------------------------------------------- 1 | package tmdb 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "github.com/odin-movieshow/backend/cache" 11 | "github.com/odin-movieshow/backend/settings" 12 | "github.com/odin-movieshow/backend/types" 13 | "github.com/thoas/go-funk" 14 | 15 | "github.com/charmbracelet/log" 16 | 17 | resty "github.com/go-resty/resty/v2" 18 | ) 19 | 20 | type Tmdb struct { 21 | settings *settings.Settings 22 | cache *cache.Cache 23 | } 24 | 25 | func New(settings *settings.Settings, cache *cache.Cache) *Tmdb { 26 | return &Tmdb{settings: settings, cache: cache} 27 | } 28 | 29 | const ( 30 | TMDB_URL = "https://api.themoviedb.org/3" 31 | ) 32 | 33 | func (t *Tmdb) PopulateTMDB( 34 | k int, 35 | objmap []types.TraktItem, 36 | ) { 37 | resource := "movie" 38 | tmdbResource := "movie" 39 | if objmap[k].Type == "show" || objmap[k].Type == "episode" && objmap[k].Show != nil { 40 | resource = "show" 41 | tmdbResource = "tv" 42 | } 43 | if objmap[k].Season > 0 { 44 | resource = "season" 45 | tmdbResource = "tv" 46 | } 47 | id := objmap[k].IDs.Tmdb 48 | 49 | if objmap[k].Show != nil { 50 | id = objmap[k].Show.IDs.Tmdb 51 | } 52 | if objmap[k].IDs.Tmdb == 0 { 53 | return 54 | } 55 | var tmdbObj any 56 | url := fmt.Sprintf("/%s/%d", tmdbResource, id) 57 | cache := t.cache.ReadCache("tmdb", fmt.Sprintf("%d", id), resource) 58 | if cache != nil { 59 | tmdbObj = cache 60 | } else { 61 | tmdbObj = t.CallEndpoint(url) 62 | } 63 | 64 | if tmdbObj == nil { 65 | return 66 | } 67 | tmdb := t.prepare(tmdbObj) 68 | tmdbObj = t.tmdbToObj(tmdb) 69 | objmap[k].Tmdb = tmdbObj 70 | if objmap[k].Show != nil { 71 | objmap[k].Show.Tmdb = tmdbObj 72 | } 73 | if cache == nil { 74 | t.cache.WriteCache("tmdb", fmt.Sprintf("%d", id), resource, &tmdbObj, 12) 75 | } 76 | } 77 | 78 | func (t *Tmdb) prepare(obj any) *types.TmdbItem { 79 | tmdb := types.TmdbItem{} 80 | ms, err := json.Marshal(obj) 81 | if err != nil { 82 | log.Error(err) 83 | return nil 84 | } 85 | err = json.Unmarshal(ms, &tmdb) 86 | if err != nil { 87 | log.Error(err) 88 | return nil 89 | } 90 | tmdb.Original = &obj 91 | 92 | if (tmdb).Credits != nil { 93 | if (tmdb).Credits.Crew != nil { 94 | (tmdb).Credits.Crew = nil 95 | } 96 | if tmdb.Credits.Cast != nil { 97 | // strip down cast 98 | cast := tmdb.Credits.Cast 99 | castlen := len(*cast) 100 | if castlen > 15 { 101 | castlen = 15 102 | } 103 | castcut := (*cast)[0:castlen] 104 | tmdb.Credits.Cast = &castcut 105 | } 106 | } 107 | 108 | if tmdb.Images != nil && 109 | tmdb.Images.Logos != nil { 110 | 111 | for _, l := range *tmdb.Images.Logos { 112 | if l.Iso_639_1 != nil && *(l.Iso_639_1) == "en" { 113 | tmdb.LogoPath = l.FilePath 114 | break 115 | } 116 | } 117 | if tmdb.LogoPath == "" && len(*tmdb.Images.Logos) > 0 { 118 | tmdb.LogoPath = (*tmdb.Images.Logos)[0].FilePath 119 | } 120 | tmdb.Images = nil 121 | } else { 122 | tmdb.LogoPath = "" 123 | } 124 | 125 | return &tmdb 126 | } 127 | 128 | func (t *Tmdb) tmdbToObj(tmdb *types.TmdbItem) any { 129 | var obj any 130 | ms, err := json.Marshal(tmdb) 131 | if err == nil { 132 | err = json.Unmarshal(ms, &obj) 133 | if err == nil { 134 | orig := *(tmdb.Original) 135 | if (orig) == nil { 136 | return obj 137 | } 138 | for k, v := range (orig).(map[string]interface{}) { 139 | if funk.Contains([]string{"images", "credits"}, k) { 140 | continue 141 | } 142 | obj.(map[string]any)[k] = v 143 | } 144 | obj.(map[string]any)["original"] = nil 145 | } 146 | } 147 | return obj 148 | } 149 | 150 | func (t *Tmdb) CallEndpoint(endpoint string) any { 151 | var data any 152 | request := resty.New(). 153 | SetRetryCount(3). 154 | SetTimeout(time.Second * 30). 155 | SetRetryWaitTime(time.Second). 156 | R() 157 | url := TMDB_URL + endpoint + "?api_key=" + os.Getenv("TMDB_KEY") + "&append_to_response=credits,videos,images" 158 | if res, err := request.SetResult(&data).SetHeader("content-type", "application/json").Get(url); err != nil { 159 | log.Error("TMDB", endpoint, "status", res.StatusCode()) 160 | } 161 | return data 162 | } 163 | 164 | func (t *Tmdb) GetEpisodes(showId string, seasons []string) *[]any { 165 | var wg sync.WaitGroup 166 | res := make([]any, 0) 167 | 168 | for _, s := range seasons { 169 | wg.Add(1) 170 | go func(s string) { 171 | endpoint := fmt.Sprintf("/tv/%s/season/%s", showId, s) 172 | 173 | defer wg.Done() 174 | obj := t.CallEndpoint(endpoint) 175 | res = append(res, obj) 176 | }(s) 177 | } 178 | wg.Wait() 179 | 180 | return &res 181 | } 182 | -------------------------------------------------------------------------------- /apps/backend/trakt/trakt.go: -------------------------------------------------------------------------------- 1 | package trakt 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "github.com/odin-movieshow/backend/cache" 14 | "github.com/odin-movieshow/backend/settings" 15 | "github.com/odin-movieshow/backend/tmdb" 16 | "github.com/odin-movieshow/backend/types" 17 | 18 | "github.com/pocketbase/dbx" 19 | "github.com/pocketbase/pocketbase" 20 | "github.com/pocketbase/pocketbase/models" 21 | ptypes "github.com/pocketbase/pocketbase/tools/types" 22 | "github.com/thoas/go-funk" 23 | 24 | "github.com/go-resty/resty/v2" 25 | 26 | "github.com/charmbracelet/log" 27 | ) 28 | 29 | const ( 30 | TRAKT_URL = "https://api.trakt.tv" 31 | ) 32 | 33 | type Trakt struct { 34 | app *pocketbase.PocketBase 35 | tmdb *tmdb.Tmdb 36 | settings *settings.Settings 37 | cache *cache.Cache 38 | } 39 | 40 | func New(app *pocketbase.PocketBase, tmdb *tmdb.Tmdb, settings *settings.Settings, cache *cache.Cache) *Trakt { 41 | return &Trakt{ 42 | app: app, 43 | tmdb: tmdb, 44 | settings: settings, 45 | cache: cache, 46 | } 47 | } 48 | 49 | func (t *Trakt) removeDuplicates(objmap []types.TraktItem) []types.TraktItem { 50 | showsSeen := []uint{} 51 | toRemove := []int{} 52 | for i, o := range objmap { 53 | if o.Type != "episode" { 54 | continue 55 | } 56 | id := o.IDs.Trakt 57 | 58 | if o.Show != nil { 59 | id = o.Show.IDs.Trakt 60 | } 61 | if !funk.Contains(showsSeen, id) { 62 | showsSeen = append(showsSeen, id) 63 | } else { 64 | toRemove = append(toRemove, i) 65 | } 66 | } 67 | 68 | newmap := []types.TraktItem{} 69 | 70 | for i, o := range objmap { 71 | if !funk.ContainsInt(toRemove, i) { 72 | newmap = append(newmap, o) 73 | } 74 | } 75 | 76 | return newmap 77 | } 78 | 79 | func (t *Trakt) removeWatched(objmap []types.TraktItem) []types.TraktItem { 80 | return funk.Filter(objmap, func(o types.TraktItem) bool { 81 | return !o.Watched 82 | }).([]types.TraktItem) 83 | } 84 | 85 | func (t *Trakt) removeSeason0(objmap []types.TraktItem) []types.TraktItem { 86 | toKeep := []types.TraktItem{} 87 | for _, o := range objmap { 88 | if o.Number > 0 && o.Season > 0 { 89 | toKeep = append(toKeep, o) 90 | } 91 | } 92 | 93 | return toKeep 94 | } 95 | 96 | func (t *Trakt) SyncHistory(id string) { 97 | users := []*models.Record{} 98 | t.app.Dao().RecordQuery("users").All(&users) 99 | var wg sync.WaitGroup 100 | for _, u := range users { 101 | if id != "" && u.Id != id { 102 | continue 103 | } 104 | records, _ := t.app.Dao().FindRecordsByFilter("history", "user = {:user}", "-watched_at", 1, 0, dbx.Params{"user": u.Get("id")}) 105 | last_watched := ptypes.DateTime{} 106 | if len(records) > 0 { 107 | last_watched = records[0].GetDateTime("watched_at") 108 | } 109 | 110 | token := make(map[string]any) 111 | if err := u.UnmarshalJSONField("trakt_token", &token); err != nil { 112 | continue 113 | } 114 | 115 | wg.Add(1) 116 | go t.syncByType(&wg, "movies", last_watched, u.Get("id").(string), "Bearer "+token["access_token"].(string)) 117 | wg.Add(1) 118 | go t.syncByType(&wg, "episodes", last_watched, u.Get("id").(string), "Bearer "+token["access_token"].(string)) 119 | 120 | wg.Wait() 121 | log.Info("Done synching trakt history", "user", u.Get("id")) 122 | 123 | } 124 | } 125 | 126 | func (t *Trakt) syncByType(wg *sync.WaitGroup, typ string, last_history ptypes.DateTime, user string, accesToken string) { 127 | defer wg.Done() 128 | limit := 200 129 | url := "/sync/history/" + typ + "?limit=" + fmt.Sprint(limit) 130 | collection, _ := t.app.Dao().FindCollectionByNameOrId("history") 131 | if !last_history.IsZero() { 132 | url += "&start_at=" + last_history.Time().Add(time.Second*1).Format(time.RFC3339) 133 | } 134 | _, headers, _ := t.CallEndpoint(url, "GET", types.TraktParams{Headers: map[string]string{"authorization": accesToken}}) 135 | pages, _ := strconv.Atoi(headers.Get("X-Pagination-Page-Count")) 136 | 137 | for i := 1; i <= pages; i++ { 138 | // wg.Add(1) 139 | // go func(i int, wg *sync.WaitGroup) { 140 | // defer wg.Done() 141 | pageurl := url + "&page=" + fmt.Sprint(i) 142 | 143 | data, _, _ := t.CallEndpoint(pageurl, "GET", types.TraktParams{Headers: map[string]string{"authorization": accesToken}}) 144 | if data == nil { 145 | continue 146 | } 147 | for _, o := range data.([]types.TraktItem) { 148 | o.Original = nil 149 | o.Watched = true 150 | record := models.NewRecord(collection) 151 | record.Set("watched_at", o.WatchedAt) 152 | record.Set("user", user) 153 | record.Set("type", o.Type) 154 | record.Set("trakt_id", o.IDs.Trakt) 155 | record.Set("runtime", o.Runtime) 156 | switch typ { 157 | case "movies": 158 | record.Set("data", o) 159 | case "episodes": 160 | record.Set("show_id", o.Show.IDs.Trakt) 161 | record.Set("data", o.Show) 162 | } 163 | t.app.Dao().SaveRecord(record) 164 | 165 | } 166 | // }(i, wg) 167 | } 168 | } 169 | 170 | func (t *Trakt) RefreshTokens() { 171 | records := []*models.Record{} 172 | t.app.Dao().RecordQuery("users").All(&records) 173 | 174 | for _, r := range records { 175 | token := make(map[string]any) 176 | if err := r.UnmarshalJSONField("trakt_token", &t); err == nil { 177 | data, _, status := t.CallEndpoint("/oauth/token", "POST", types.TraktParams{Body: map[string]any{"grant_type": "refresh_token", "client_id": os.Getenv("TRAKT_CLIENTID"), "client_secret": os.Getenv("TRAKT_SECRET"), "code": token["device_code"], "refresh_token": token["refresh_token"]}}) 178 | if status < 300 && data != nil { 179 | data.(map[string]any)["device_code"] = token["device_code"] 180 | r.Set("trakt_token", data) 181 | t.app.Dao().Save(r) 182 | log.Info("trakt refresh token", "user", r.Get("id")) 183 | } 184 | } 185 | 186 | } 187 | } 188 | 189 | func (t *Trakt) normalize(objmap []types.TraktItem, isShow bool) []types.TraktItem { 190 | for i, o := range objmap { 191 | if o.Movie != nil || o.Episode != nil || o.Show != nil { 192 | m := types.TraktItem{} 193 | if o.Movie != nil { 194 | m = *o.Movie 195 | m.Movie = nil 196 | m.Type = "movie" 197 | } else { 198 | if o.Episode != nil && o.Show != nil { 199 | m = *o.Episode 200 | m.Episode = nil 201 | m.Show = o.Show 202 | m.Type = "episode" 203 | } else { 204 | m = *o.Show 205 | m.Show = nil 206 | m.Type = "show" 207 | } 208 | } 209 | orig := *o.Original 210 | if orig.(map[string]any)[m.Type] != nil { 211 | orig = (*o.Original).(map[string]any)[m.Type] 212 | } 213 | m.Original = &orig 214 | m.WatchedAt = o.WatchedAt 215 | objmap[i] = m 216 | } else { 217 | t := "movie" 218 | if isShow { 219 | t = "show" 220 | } 221 | if objmap[i].Episodes != nil { 222 | t = "season" 223 | } 224 | objmap[i].Type = t 225 | } 226 | } 227 | return objmap 228 | } 229 | 230 | func (t *Trakt) objToItems(objmap []any, isShow bool) []types.TraktItem { 231 | jm, err := json.Marshal(objmap) 232 | if err == nil { 233 | items := []types.TraktItem{} 234 | err = json.Unmarshal(jm, &items) 235 | 236 | if err == nil { 237 | if len(items) == 0 { 238 | return items 239 | } 240 | for i, item := range items { 241 | items[i].Original = &objmap[i] 242 | if item.Show != nil { 243 | sorig := objmap[i].(map[string]any)["show"] 244 | (*items[i].Show).Original = &sorig 245 | } 246 | if item.Episodes != nil && len(*item.Episodes) > 0 { 247 | for e := range *item.Episodes { 248 | (*items[i].Episodes)[e].Original = &objmap[i].(map[string]any)["episodes"].([]any)[e] 249 | } 250 | } 251 | } 252 | return t.normalize(items, isShow) 253 | } 254 | } 255 | return []types.TraktItem{} 256 | } 257 | 258 | func (t *Trakt) itemsToObj(items []types.TraktItem) []map[string]any { 259 | m, err := json.Marshal(items) 260 | o := []map[string]any{} 261 | if err != nil { 262 | return o 263 | } 264 | 265 | err = json.Unmarshal(m, &o) 266 | if err != nil { 267 | return o 268 | } 269 | 270 | for i := range o { 271 | orig := items[i].Original 272 | for k, v := range (*orig).(map[string]any) { 273 | if o[i][k] == nil && v != nil { 274 | o[i][k] = v 275 | } 276 | } 277 | o[i]["original"] = nil 278 | o[i]["movie"] = nil 279 | if o[i]["episode"] != nil { 280 | o[i]["episode"] = nil 281 | } 282 | if items[i].Show != nil { 283 | for k, v := range (*items[i].Show.Original).(map[string]any) { 284 | if o[i]["show"].(map[string]any)[k] == nil { 285 | o[i]["show"].(map[string]any)[k] = v 286 | } 287 | } 288 | o[i]["show"].(map[string]any)["original"] = nil 289 | } 290 | 291 | if items[i].Episodes != nil && len(*items[i].Episodes) > 0 { 292 | for e, ep := range *items[i].Episodes { 293 | for k, v := range (*ep.Original).(map[string]any) { 294 | if o[i]["episodes"].([]any)[e].(map[string]any)[k] == nil { 295 | o[i]["episodes"].([]any)[e].(map[string]any)[k] = v 296 | } 297 | } 298 | o[i]["episodes"].([]any)[e].(map[string]any)["original"] = nil 299 | } 300 | } 301 | } 302 | 303 | return o 304 | } 305 | 306 | func (t *Trakt) CallEndpoint(endpoint string, method string, params types.TraktParams) (any, http.Header, int) { 307 | var objmap any 308 | 309 | hs := map[string]string{} 310 | if params.Headers != nil { 311 | hs = params.Headers 312 | } 313 | 314 | request := resty.New().SetRetryCount(10).SetRetryWaitTime(time.Second * 3).R() 315 | request.SetHeader("trakt-api-version", "2").SetHeader("content-type", "application/json").SetHeader("trakt-api-key", os.Getenv("TRAKT_CLIENTID")).AddRetryCondition(func(r *resty.Response, err error) bool { 316 | return r.StatusCode() == 401 317 | }).SetHeaders(hs) 318 | 319 | var respHeaders http.Header 320 | status := 200 321 | if params.Body != nil { 322 | request.SetBody(params.Body) 323 | } 324 | request.Attempt = 3 325 | var r func(url string) (*resty.Response, error) 326 | switch method { 327 | case "POST": 328 | r = request.Post 329 | case "PATCH": 330 | r = request.Patch 331 | case "PUT": 332 | r = request.Put 333 | case "DELETE": 334 | r = request.Delete 335 | default: 336 | r = request.Get 337 | 338 | } 339 | 340 | if !strings.Contains(endpoint, "oauth") { 341 | if !strings.Contains(endpoint, "extended=") { 342 | if strings.Contains(endpoint, "?") { 343 | endpoint += "&" 344 | } else { 345 | endpoint += "?" 346 | } 347 | endpoint += "extended=full&limit=30" 348 | if !strings.Contains(endpoint, "limit=") { 349 | endpoint += "&limit=30" 350 | } 351 | } 352 | } 353 | 354 | if resp, err := r(fmt.Sprintf("%s%s", TRAKT_URL, endpoint)); err == nil { 355 | respHeaders = resp.Header() 356 | status = resp.StatusCode() 357 | if status > 299 { 358 | log.Error("trakt error", "fetch", endpoint, "status", status) 359 | log.Debug("trakt error", "res", string(resp.Body()), "body", params.Body, "headers", respHeaders) 360 | } else { 361 | log.Debug("trakt fetch", "url", endpoint, "method", method, "status", status) 362 | } 363 | err := json.Unmarshal(resp.Body(), &objmap) 364 | if err != nil { 365 | log.Error("trakt", "unmarshal", err) 366 | } 367 | 368 | switch objmap := objmap.(type) { 369 | 370 | case []any: 371 | items := t.objToItems(objmap, strings.Contains(endpoint, "/shows")) 372 | var wg sync.WaitGroup 373 | var mux sync.Mutex 374 | 375 | if len(items) == 0 || strings.Contains(endpoint, "sync/history") { 376 | if params.FetchTMDB { 377 | t.getTMDB(&wg, &mux, items) 378 | } 379 | wg.Wait() 380 | return items, respHeaders, status 381 | } 382 | 383 | t.GetWatched(items) 384 | 385 | if strings.Contains(endpoint, "calendars") { 386 | items = t.removeSeason0(items) 387 | items = t.removeWatched(items) 388 | items = t.removeDuplicates(items) 389 | } 390 | 391 | if params.FetchTMDB { 392 | t.getTMDB(&wg, &mux, items) 393 | } 394 | 395 | wg.Wait() 396 | 397 | return t.itemsToObj(items), respHeaders, status 398 | 399 | default: 400 | 401 | } 402 | 403 | } else { 404 | log.Error("trakt", "endpoint", endpoint, "body", params.Body, "err", err) 405 | } 406 | 407 | return objmap, respHeaders, status 408 | } 409 | 410 | func (t *Trakt) getTMDB(wg *sync.WaitGroup, _ *sync.Mutex, objmap []types.TraktItem) { 411 | for k := range objmap { 412 | wg.Add(1) 413 | go func() { 414 | t.tmdb.PopulateTMDB(k, objmap) 415 | wg.Done() 416 | }() 417 | } 418 | wg.Wait() 419 | } 420 | 421 | type Watched struct { 422 | LastWatchedAt time.Time `json:"last_watched_at"` 423 | LastUpdatedAt time.Time `json:"last_updated_at"` 424 | Seasons []struct { 425 | Episodes []struct { 426 | LastWatchedAt time.Time `json:"last_watched_at"` 427 | Number int `json:"number"` 428 | Plays int `json:"plays"` 429 | } `json:"episodes"` 430 | Number int `json:"number"` 431 | } `json:"seasons"` 432 | Movie types.TraktItem `json:"movie"` 433 | Show types.TraktItem `json:"show"` 434 | Plays int `json:"plays"` 435 | } 436 | 437 | func (t *Trakt) GetWatched(objmap []types.TraktItem) []types.TraktItem { 438 | if len(objmap) == 0 { 439 | return objmap 440 | } 441 | return t.AssignWatched(objmap, objmap[0].Type) 442 | } 443 | 444 | func (t *Trakt) getHistory(htype string) []any { 445 | records, _ := t.app.Dao().FindRecordsByFilter("history", "type = {:htype}", "-watched_at", -1, 0, dbx.Params{"htype": htype}) 446 | data := make([]any, 0) 447 | for _, r := range records { 448 | item := make(map[string]any) 449 | item["type"] = r.Get("type") 450 | item["trakt_id"] = r.Get("trakt_id") 451 | data = append(data, item) 452 | } 453 | return data 454 | } 455 | 456 | func (t *Trakt) AssignWatched(objmap []types.TraktItem, typ string) []types.TraktItem { 457 | if typ == "season" { 458 | typ = "episode" 459 | } 460 | history := t.getHistory(typ) 461 | for i, o := range objmap { 462 | if o.Episodes != nil { 463 | for j, e := range *o.Episodes { 464 | oid := e.IDs.Trakt 465 | (*objmap[i].Episodes)[j].Watched = false 466 | for _, h := range history { 467 | hid := uint(h.(map[string]any)["trakt_id"].(float64)) 468 | if hid == oid { 469 | (*objmap[i].Episodes)[j].Watched = true 470 | log.Debug(oid, "watched", (*objmap[i].Episodes)[j].Watched) 471 | break 472 | } 473 | } 474 | } 475 | } else { 476 | 477 | oid := o.IDs.Trakt 478 | objmap[i].Watched = false 479 | for _, h := range history { 480 | hid := uint(h.(map[string]any)["trakt_id"].(float64)) 481 | if hid == oid { 482 | objmap[i].Watched = true 483 | break 484 | } 485 | } 486 | } 487 | } 488 | newmap := make([]types.TraktItem, 0) 489 | 490 | for _, o := range objmap { 491 | newmap = append(newmap, o) 492 | } 493 | 494 | return newmap 495 | } 496 | 497 | func (t *Trakt) GetSeasons(id int) any { 498 | cache := t.cache.ReadCache("trakt", fmt.Sprintf("%d", id), "seasons") 499 | if cache != nil { 500 | return cache 501 | } 502 | endpoint := fmt.Sprintf("/shows/%d/seasons?extended=full,episodes", id) 503 | result, _, _ := t.CallEndpoint(endpoint, "GET", types.TraktParams{}) 504 | t.cache.WriteCache("trakt", fmt.Sprintf("%d", id), "seasons", &result, 12) 505 | return result 506 | } 507 | -------------------------------------------------------------------------------- /apps/backend/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type Torrent struct { 4 | Scraper string `json:"scraper"` 5 | Hash string `json:"hash"` 6 | ReleaseTitle string `json:"release_title"` 7 | Magnet string `json:"magnet"` 8 | Url string `json:"url"` 9 | Name string `json:"name"` 10 | Quality string `json:"quality"` 11 | Info []string `json:"info"` 12 | Links []Unrestricted `json:"links"` 13 | Size uint64 `json:"size"` 14 | Seeds uint64 `json:"seeds"` 15 | } 16 | 17 | type TmdbItem struct { 18 | Credits *struct { 19 | Cast *[]any `json:"cast"` 20 | Crew *[]any `json:"crew"` 21 | } `json:"credits,omitempty"` 22 | Images *struct { 23 | Logos *[]struct { 24 | Iso_639_1 *string `json:"iso_639_1"` 25 | FilePath string `json:"file_path"` 26 | } `json:"logos"` 27 | } `json:"images,omitempty"` 28 | Original *interface{} `json:"original,omitempty"` 29 | LogoPath string `json:"logo_path"` 30 | } 31 | 32 | type TraktItem struct { 33 | Tmdb any `json:"tmdb"` 34 | Original *interface{} `json:"original,omitempty"` 35 | Show *TraktItem `json:"show,omitempty"` 36 | Movie *TraktItem `json:"movie,omitempty"` 37 | Episode *TraktItem `json:"episode,omitempty"` 38 | Episodes *[]TraktItem `json:"episodes,omitempty"` 39 | Title string `json:"title"` 40 | Type string `json:"type"` 41 | WatchedAt string `json:"watched_at"` 42 | Genres []string `json:"genres"` 43 | IDs struct { 44 | Slug string `json:"slug"` 45 | Imdb string `json:"imdb"` 46 | Trakt uint `json:"trakt"` 47 | Tmdb uint `json:"tmdb"` 48 | Tvdb uint `json:"tvdb"` 49 | } `json:"ids"` 50 | Runtime uint `json:"runtime"` 51 | Season uint `json:"season"` 52 | Number uint `json:"number"` 53 | Watched bool `json:"watched"` 54 | } 55 | 56 | type Unrestricted struct { 57 | Filename string `json:"filename"` 58 | Download string `json:"download"` 59 | Streams []string `json:"stream"` 60 | Filesize int `json:"filesize"` 61 | } 62 | 63 | type TraktParams struct { 64 | Body map[string]any 65 | Headers map[string]string 66 | Donorm bool 67 | FetchTMDB bool 68 | } 69 | -------------------------------------------------------------------------------- /apps/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /apps/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /apps/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "semi": false, 4 | "singleQuote": true, 5 | "useTabs": true, 6 | "quoteProps": "consistent", 7 | "arrowParens": "always", 8 | "printWidth": 180, 9 | "vueIndentScriptAndStyle": true 10 | } -------------------------------------------------------------------------------- /apps/frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as builder 2 | WORKDIR /build/ 3 | RUN npm i -g pnpm 4 | COPY package.json . 5 | RUN pnpm i 6 | COPY . . 7 | RUN pnpm run build 8 | 9 | FROM node:alpine 10 | WORKDIR /app 11 | COPY --from=builder /build/.output /app 12 | 13 | CMD ["node", "server/index.mjs"] 14 | -------------------------------------------------------------------------------- /apps/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 Minimal Starter 2 | 3 | Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. 4 | 5 | ## Setup 6 | 7 | Make sure to install the dependencies: 8 | 9 | ```bash 10 | # npm 11 | npm install 12 | 13 | # pnpm 14 | pnpm install 15 | 16 | # yarn 17 | yarn install 18 | 19 | # bun 20 | bun install 21 | ``` 22 | 23 | ## Development Server 24 | 25 | Start the development server on `http://localhost:3000`: 26 | 27 | ```bash 28 | # npm 29 | npm run dev 30 | 31 | # pnpm 32 | pnpm run dev 33 | 34 | # yarn 35 | yarn dev 36 | 37 | # bun 38 | bun run dev 39 | ``` 40 | 41 | ## Production 42 | 43 | Build the application for production: 44 | 45 | ```bash 46 | # npm 47 | npm run build 48 | 49 | # pnpm 50 | pnpm run build 51 | 52 | # yarn 53 | yarn build 54 | 55 | # bun 56 | bun run build 57 | ``` 58 | 59 | Locally preview production build: 60 | 61 | ```bash 62 | # npm 63 | npm run preview 64 | 65 | # pnpm 66 | pnpm run preview 67 | 68 | # yarn 69 | yarn preview 70 | 71 | # bun 72 | bun run preview 73 | ``` 74 | 75 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 76 | -------------------------------------------------------------------------------- /apps/frontend/app.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 20 | 21 | 50 | -------------------------------------------------------------------------------- /apps/frontend/components/AllDebrid.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 63 | -------------------------------------------------------------------------------- /apps/frontend/components/Detail/Info.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 41 | -------------------------------------------------------------------------------- /apps/frontend/components/Detail/SeasonsAndEpisodes.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 84 | -------------------------------------------------------------------------------- /apps/frontend/components/Header/NavBar.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 70 | -------------------------------------------------------------------------------- /apps/frontend/components/MediaList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 38 | 51 | -------------------------------------------------------------------------------- /apps/frontend/components/Profile/Trakt.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 97 | -------------------------------------------------------------------------------- /apps/frontend/components/RealDebrid.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 83 | -------------------------------------------------------------------------------- /apps/frontend/components/Streams.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 112 | -------------------------------------------------------------------------------- /apps/frontend/components/User/List.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 210 | 211 | 216 | -------------------------------------------------------------------------------- /apps/frontend/components/Video.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 10 | 42 | -------------------------------------------------------------------------------- /apps/frontend/composables/useMedia.ts: -------------------------------------------------------------------------------- 1 | export const useMedia = defineStore('useMedia', () => { 2 | const detail = ref<{ [id: string]: any }>({}) 3 | 4 | const lists = ref<{ [id: string]: [] }>({}) 5 | 6 | async function getDetail(id: string, type: string) { 7 | const trakt = id.split('-').at(-1) || '' 8 | if (!detail.value[id] && id) { 9 | const res = await usePb().send(`/-/trakt/search/trakt/${trakt}?type=${type}`, { method: 'GET' }) 10 | if (res.length > 0) { 11 | const item = res[0] 12 | if (item.type === 'show') { 13 | item.seasons = await usePb().send(`/-/traktseasons/${item.ids.trakt}`, { method: 'GET' }) 14 | } 15 | detail.value[id] = item 16 | } 17 | } else { 18 | if (detail.value[id].type === 'show') { 19 | if (!detail.value[id].seasons) { 20 | detail.value[id].seasons = await usePb().send(`/-/traktseasons/${detail.value[id].ids.trakt}`, { method: 'GET' }) 21 | } 22 | } 23 | } 24 | return detail.value[id] 25 | } 26 | 27 | async function search(term: string) { 28 | if (term.length < 2) { 29 | return [] 30 | } 31 | const movies = await usePb().send(`/-/trakt/search/movie?query=${term}`, { method: 'GET' }) 32 | const shows = await usePb().send(`/-/trakt/search/show?query=${term}`, { method: 'GET' }) 33 | 34 | return { movies, shows } 35 | } 36 | 37 | const getId = (item: any) => { 38 | if (item['type'] === 'episode') { 39 | return `${item['show']['ids']['slug']}-${item['show']['ids']['trakt']}` 40 | } 41 | return `${item['ids']['slug']}-${item['ids']['trakt']}` 42 | } 43 | 44 | const getLink = (item: any) => { 45 | if (item['type'] === 'episode') { 46 | return `/show/${getId(item)}` 47 | } 48 | return `/${item['type']}/${getId(item)}` 49 | } 50 | 51 | const setDetail = (item: any) => { 52 | if (!detail.value[getId(item)]) { 53 | if (item.type === 'episode') { 54 | detail.value[getId(item)] = item.show 55 | } else { 56 | detail.value[getId(item)] = item 57 | } 58 | } 59 | } 60 | 61 | const getList = async (url: string) => { 62 | if (!lists.value[url]) { 63 | try { 64 | const res = await usePb().send(`/-/trakt${url}`, { 65 | method: 'GET', 66 | cache: 'no-cache', 67 | }) 68 | lists.value[url] = res 69 | } catch (e) { 70 | lists.value[url] = [] 71 | } 72 | } 73 | return lists.value[url] 74 | } 75 | 76 | return { 77 | setDetail, 78 | getDetail, 79 | getList, 80 | getLink, 81 | detail, 82 | search, 83 | } 84 | }) 85 | 86 | if (import.meta.hot) { 87 | import.meta.hot.accept(acceptHMRUpdate(useMedia, import.meta.hot)) 88 | } 89 | -------------------------------------------------------------------------------- /apps/frontend/composables/usePb.ts: -------------------------------------------------------------------------------- 1 | import pocketbase, { LocalAuthStore } from 'pocketbase' 2 | 3 | export const usePb = () => { 4 | return new pocketbase('/', new LocalAuthStore('odin')) 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/composables/useProfile.ts: -------------------------------------------------------------------------------- 1 | export const useProfile = defineStore('useProfile', () => { 2 | let me = ref() 3 | async function init() { 4 | if (usePb().authStore.isAdmin) { 5 | me.value = await usePb().admins.getOne(usePb().authStore.model!.id) 6 | } else { 7 | me.value = await usePb().collection('users').getOne(usePb().authStore.model?.id) 8 | } 9 | } 10 | return { 11 | me, 12 | init, 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /apps/frontend/composables/useSettings.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export const useSettings = defineStore('useSettings', () => { 4 | const settings = ref() 5 | const config = ref() 6 | async function init() { 7 | const existing = (await usePb().collection('settings').getList()).items[0] ?? {} 8 | settings.value = _.merge( 9 | { 10 | app: {}, 11 | trakt: { 12 | clientId: '', 13 | clientSecret: '', 14 | }, 15 | 16 | real_debrid: {}, 17 | 18 | tmdb: { 19 | key: '', 20 | }, 21 | 22 | fanart: { 23 | key: '', 24 | }, 25 | }, 26 | existing 27 | ) 28 | } 29 | 30 | async function save() { 31 | if (settings.value.id) { 32 | await usePb().collection('settings').update(settings.value.id, settings.value) 33 | } else { 34 | settings.value = await usePb().collection('settings').create(settings.value) 35 | } 36 | } 37 | return { 38 | settings, 39 | config, 40 | init, 41 | save, 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /apps/frontend/composables/useStreams.ts: -------------------------------------------------------------------------------- 1 | type EpisodeStream = { 2 | type: string 3 | 4 | show_imdb?: string 5 | show_tvdb?: string 6 | show_title: string 7 | show_year: string 8 | season_number: string 9 | season_aired?: string 10 | episode_trakt?: string 11 | episode_imdb?: string 12 | episode_tvdb?: string 13 | episode_title: string 14 | episode_number: string 15 | episode_year: string 16 | no_seasons?: string 17 | country?: string 18 | } 19 | 20 | type MovieStream = { 21 | trakt: string 22 | imdb?: string 23 | title: string 24 | year: string 25 | type: string 26 | } 27 | import mqtt from 'mqtt' 28 | 29 | type Stream = MovieStream | EpisodeStream 30 | 31 | export const useStreams = defineStore('useStreams', () => { 32 | const list = ref<{ [id: string]: [] }>({}) 33 | 34 | const data = ref() 35 | const streams = ref([]) 36 | const topic = ref('') 37 | 38 | const videoUrl = ref('') 39 | const proto = location.protocol.includes('https') ? 'wss' : 'ws' 40 | const url = `${proto}://${location.host}/ws/mqtt` 41 | const mqttClient = mqtt.connect(url) 42 | 43 | mqttClient.on('connect', () => { 44 | console.log('MQTT', mqttClient?.connected, url) 45 | }) 46 | mqttClient.on('error', (e) => { 47 | console.error('MQTT', e.message) 48 | }) 49 | mqttClient.on('disconnect', () => { 50 | console.log('MQTT', mqttClient?.connected, url) 51 | }) 52 | 53 | async function getStreams() { 54 | streams.value = [] 55 | if (!data.value) return [] 56 | let id = '' 57 | if (data.value.type === 'movie') { 58 | id = `${(data.value as MovieStream).title}-${(data.value as MovieStream).year}` 59 | topic.value = `odin-movieshow/movie/${(data.value as MovieStream).trakt}` 60 | } else { 61 | id = `${(data.value as EpisodeStream).show_title}-${(data.value as EpisodeStream).season_number}-${(data.value as EpisodeStream).episode_number}` 62 | topic.value = `odin-movieshow/episode/${(data.value as EpisodeStream).episode_trakt}` 63 | } 64 | 65 | console.log('MQTT', topic.value) 66 | mqttClient.subscribe(topic.value) 67 | mqttClient.on('message', (topic: string, message: Buffer) => { 68 | try { 69 | const m = JSON.parse(message.toString()) 70 | streams.value = [...streams.value, m] 71 | } catch (_) {} 72 | }) 73 | 74 | if (!list.value[id]) { 75 | list.value[id] = await usePb().send('/-/scrape', { 76 | method: 'POST', 77 | body: data.value, 78 | cache: 'no-cache', 79 | }) 80 | } 81 | return list.value[id] 82 | } 83 | 84 | const triggerModal = ref(false) 85 | const triggerVideoModal = ref(false) 86 | const openModal = (item: any, show?: any, season?: string) => { 87 | data.value = { 88 | type: 'movie', 89 | trakt: `${item.ids.trakt}`, 90 | imdb: `${item.ids.imdb}`, 91 | title: `${item.title}`, 92 | year: `${item.year}`, 93 | } as MovieStream 94 | if (show) { 95 | data.value = { 96 | type: 'episode', 97 | show_imdb: `${show.ids.imdb}`, 98 | show_tvdb: `${show.ids.tvdb}`, 99 | show_title: `${show.title}`, 100 | show_year: `${show.year}`, 101 | season_number: `${item.season}`, 102 | episode_imdb: `${item.ids.imdb}`, 103 | episode_trakt: `${item.ids.trakt}`, 104 | episode_tvdb: `${item.ids.tvdb}`, 105 | episode_title: `${item.title}`, 106 | episode_number: `${item.number}`, 107 | episode_year: `${item.year}`, 108 | season_aired: `${show.year}`, 109 | no_seasons: '10', 110 | country: '', 111 | } as EpisodeStream 112 | } 113 | 114 | triggerModal.value = !triggerModal.value 115 | } 116 | const openVideoModal = (url: string) => { 117 | videoUrl.value = url 118 | triggerVideoModal.value = !triggerVideoModal.value 119 | } 120 | return { 121 | getStreams, 122 | triggerModal, 123 | openModal, 124 | triggerVideoModal, 125 | openVideoModal, 126 | videoUrl, 127 | mqttClient, 128 | streams, 129 | topic, 130 | } 131 | }) 132 | -------------------------------------------------------------------------------- /apps/frontend/layouts/admin.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 37 | -------------------------------------------------------------------------------- /apps/frontend/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 15 | -------------------------------------------------------------------------------- /apps/frontend/layouts/detail.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /apps/frontend/layouts/empty.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /apps/frontend/middleware/auth.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async (to, _) => { 2 | if (process.server) return 3 | const id = usePb().authStore.model?.id || null 4 | console.log('ID', id) 5 | console.log('valid', usePb().authStore.isValid) 6 | console.log('admin', usePb().authStore.isAdmin) 7 | console.log('paths', to.path) 8 | if (id !== null && usePb().authStore.isValid) { 9 | let a = null 10 | let u = null 11 | if (usePb().authStore.isAdmin) { 12 | try { 13 | a = await usePb().admins.getOne(id) 14 | } catch (_) {} 15 | if (a === null) { 16 | usePb().authStore.clear() 17 | return navigateTo('/login') 18 | } 19 | } else { 20 | try { 21 | u = await usePb().collection('users').getOne(id) 22 | } catch (_) {} 23 | if (u === null) { 24 | usePb().authStore.clear() 25 | return navigateTo('/login') 26 | } else { 27 | if (to.path === '/admin') { 28 | return navigateTo('/settings') 29 | } 30 | } 31 | } 32 | } 33 | if (usePb().authStore.isValid && ['/login'].includes(to.path)) { 34 | if (usePb().authStore.isAdmin) { 35 | return navigateTo('/admin') 36 | } 37 | return navigateTo('/') 38 | } 39 | if (!usePb().authStore.isValid && !['/login'].includes(to.path)) { 40 | return navigateTo('/login') 41 | } 42 | 43 | // if (usePb().authStore.isValid && from.path !== "/" && to.path === "/") { 44 | // return navigateTo(from.fullPath); 45 | // } 46 | }) 47 | -------------------------------------------------------------------------------- /apps/frontend/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | ssr: false, 4 | devtools: { enabled: true }, 5 | modules: ['nuxt-primevue', '@nuxtjs/tailwindcss', '@pinia/nuxt'], 6 | css: ['primevue/resources/themes/lara-dark-indigo/theme.css'], 7 | compatibilityDate: '2024-07-03', 8 | devServer: { 9 | host: "0.0.0.0" 10 | }, 11 | app: { 12 | pageTransition: { name: 'page', mode: 'out-in' }, 13 | layoutTransition: { name: 'layout', mode: 'out-in' }, 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /apps/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev --qr=false --host", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "devDependencies": { 13 | "@nuxt/devtools": "latest", 14 | "@nuxtjs/tailwindcss": "^6.10.1", 15 | "@types/lodash": "^4.14.202", 16 | "@types/node": "^20.10.4", 17 | "nuxt": "^3.13.2", 18 | "nuxt-primevue": "^0.3.0", 19 | "prettier": "^3.1.1", 20 | "vue": "^3.3.10", 21 | "vue-router": "^4.2.5" 22 | }, 23 | "dependencies": { 24 | "@fortawesome/fontawesome-svg-core": "^6.5.1", 25 | "@fortawesome/free-brands-svg-icons": "^6.5.1", 26 | "@fortawesome/free-regular-svg-icons": "^6.5.1", 27 | "@fortawesome/free-solid-svg-icons": "^6.5.1", 28 | "@fortawesome/vue-fontawesome": "^3.0.5", 29 | "@pinia/nuxt": "^0.5.1", 30 | "@tailwindcss/typography": "^0.5.10", 31 | "@videojs-player/vue": "^1.0.0", 32 | "daisyui": "^4.4.20", 33 | "mqtt": "^5.10.1", 34 | "pinia": "^2.1.7", 35 | "pocketbase": "^0.20.1", 36 | "primevue": "^3.48.1", 37 | "video.js": "^8.6.1", 38 | "vuedraggable": "^4.1.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /apps/frontend/pages/[type]/[id].vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 33 | 34 | 45 | -------------------------------------------------------------------------------- /apps/frontend/pages/admin/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 28 | -------------------------------------------------------------------------------- /apps/frontend/pages/devices.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 88 | -------------------------------------------------------------------------------- /apps/frontend/pages/index.vue: -------------------------------------------------------------------------------- 1 | 9 | 15 | -------------------------------------------------------------------------------- /apps/frontend/pages/login.vue: -------------------------------------------------------------------------------- 1 | 16 | 42 | -------------------------------------------------------------------------------- /apps/frontend/pages/movies.vue: -------------------------------------------------------------------------------- 1 | 9 | 12 | -------------------------------------------------------------------------------- /apps/frontend/pages/profile.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /apps/frontend/pages/search.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /apps/frontend/pages/settings.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 52 | 53 | 89 | -------------------------------------------------------------------------------- /apps/frontend/pages/shows.vue: -------------------------------------------------------------------------------- 1 | 9 | 12 | -------------------------------------------------------------------------------- /apps/frontend/plugins/fontawesome.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from "nuxt/app"; 2 | 3 | import { library, config } from "@fortawesome/fontawesome-svg-core"; 4 | import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; 5 | import { fas } from "@fortawesome/free-solid-svg-icons"; 6 | import { far } from "@fortawesome/free-regular-svg-icons"; 7 | import { fab } from "@fortawesome/free-brands-svg-icons"; 8 | 9 | library.add(fas, far, fab); 10 | 11 | export default defineNuxtPlugin((nuxtApp) => { 12 | nuxtApp.vueApp.component("FaIcon", FontAwesomeIcon); 13 | }); 14 | -------------------------------------------------------------------------------- /apps/frontend/plugins/video.ts: -------------------------------------------------------------------------------- 1 | import VueVideoPlayer from '@videojs-player/vue' 2 | import 'video.js/dist/video-js.css' 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.component('Player', VueVideoPlayer) 5 | }) 6 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 19 | 29 | 32 | 36 | 40 | 41 | 42 | 56 | 63 | 70 | 71 | -------------------------------------------------------------------------------- /apps/frontend/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require("@tailwindcss/typography"), require("daisyui")], 3 | theme: { 4 | extend: { 5 | colors: { 6 | primary: "#cba6f7", 7 | secondary: "#74c7ec", 8 | accent: "#94e2d5", 9 | neutral: "#313244", 10 | "base-100": "#1e1e2e", 11 | info: "#74c7ec", 12 | success: "#a6e3a1", 13 | warning: "#f9e2af", 14 | error: "#f38ba8", 15 | }, 16 | }, 17 | }, 18 | daisyui: { 19 | themes: [ 20 | "dark", 21 | { 22 | odin: { 23 | primary: "#cba6f7", 24 | secondary: "#74c7ec", 25 | accent: "#94e2d5", 26 | neutral: "#313244", 27 | "base-100": "#1e1e2e", 28 | info: "#74c7ec", 29 | success: "#a6e3a1", 30 | warning: "#f9e2af", 31 | error: "#f38ba8", 32 | }, 33 | }, 34 | ], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": ["./.nuxt/tsconfig.json"], 4 | "compilerOptions": { 5 | "sourceMap": true 6 | }, 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "esModuleInterop": true 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function parseTitle(title: string) { 2 | const r = new RegExp(/::(year|month|day):(\+|-)?(\d+)?:/, 'g') 3 | let m 4 | let t = title 5 | while ((m = r.exec(title)) !== null) { 6 | let now = new Date() 7 | let val = 0 8 | if (m[3]) { 9 | try { 10 | val = parseInt(m[3]) 11 | } catch (e) { 12 | val = 0 13 | } 14 | } 15 | if (m[2] === '-') { 16 | val = -val 17 | } 18 | if (m[1] === 'year') { 19 | t = t.replace(m[0], (now.getFullYear() + val).toString()) 20 | } else if (m[1] === 'month') { 21 | t = t.replace(m[0], (now.getMonth() + val).toString()) 22 | } else if (m[1] === 'day') { 23 | t = t.replace(m[0], (now.getDate() + val).toString()) 24 | } 25 | } 26 | return t 27 | } 28 | -------------------------------------------------------------------------------- /apps/services/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | auto_https off 3 | } 4 | 5 | :6060 { 6 | reverse_proxy /api/* 127.0.0.1:8090 7 | reverse_proxy /_/* 127.0.0.1:8090 8 | reverse_proxy /-/* 127.0.0.1:8090 9 | reverse_proxy /ws/* 127.0.0.1:9001 10 | reverse_proxy /* 127.0.0.1:3000 11 | 12 | } 13 | -------------------------------------------------------------------------------- /apps/services/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | container_name: odin-caddy 4 | image: caddy 5 | network_mode: host 6 | volumes: 7 | - ./Caddyfile:/etc/caddy/Caddyfile 8 | mqtt: 9 | container_name: odin-mqtt 10 | image: eclipse-mosquitto 11 | network_mode: host 12 | volumes: 13 | - ./mosquitto.conf:/mosquitto/config/mosquitto.conf 14 | -------------------------------------------------------------------------------- /apps/services/mosquitto.conf: -------------------------------------------------------------------------------- 1 | listener 1883 2 | listener 9001 3 | protocol websockets 4 | allow_anonymous true 5 | -------------------------------------------------------------------------------- /apps/services/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "services", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "./start.sh" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /apps/services/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BLUE="\e[34m" 4 | GREEN="\e[32m" 5 | NC="\e[0m" 6 | 7 | docker compose down && docker compose up -d 8 | 9 | echo -e " 10 | 11 | ================================================= 12 | ${GREEN}AiO Server running on:${NC} http://localhost:6060 13 | ================================================= 14 | 15 | ${BLUE} ➜ Frontend:${NC} http://localhost:6060/ 16 | ${BLUE} ➜ Backend:${NC} http://localhost:6060/api/ 17 | ${BLUE} ➜ PocketBase:${NC} http://localhost:6060/_/ 18 | ${BLUE} ➜ MQTT:${NC} http://localhost:6060/socket/ 19 | 20 | " 21 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/bun.lockb -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | node /odin-frontend/server/index.mjs & 4 | mosquitto -c /etc/mosquitto/mosquitto.conf & 5 | caddy run --config /etc/caddy/Caddyfile & 6 | /odin-server serve --http=0.0.0.0:8090 7 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.23.2 2 | 3 | toolchain go1.23.2 4 | 5 | use ./apps/backend 6 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | cel.dev/expr v0.16.1/go.mod h1:AsGA5zb3WruAEQeQng1RZdGEXmBj0jvMWh6l5SnNuC8= 2 | cloud.google.com/go/firestore v1.14.0 h1:8aLcKnMPoldYU3YHgu4t2exrKhLQkqaXAGqT0ljrFVw= 3 | cloud.google.com/go/firestore v1.14.0/go.mod h1:96MVaHLsEhbvkBEdZgfN+AS/GIkco1LRpH9Xp9YZfzQ= 4 | cloud.google.com/go/firestore v1.16.0/go.mod h1:+22v/7p+WNBSQwdSwP57vz47aZiY+HrDkrOsJNhk7rg= 5 | cloud.google.com/go/kms v1.15.5 h1:pj1sRfut2eRbD9pFRjNnPNg/CzJPuQAzUujMIM1vVeM= 6 | cloud.google.com/go/kms v1.15.5/go.mod h1:cU2H5jnp6G2TDpUGZyqTCoy1n16fbubHZjmVXSMtwDI= 7 | cloud.google.com/go/kms v1.18.5/go.mod h1:yXunGUGzabH8rjUPImp2ndHiGolHeWJJ0LODLedicIY= 8 | cloud.google.com/go/longrunning v0.5.4 h1:w8xEcbZodnA2BbW6sVirkkoC+1gP8wS57EUUgGS0GVg= 9 | cloud.google.com/go/longrunning v0.5.4/go.mod h1:zqNVncI0BOP8ST6XQD1+VcvuShMmq7+xFSzOL++V0dI= 10 | cloud.google.com/go/longrunning v0.6.0/go.mod h1:uHzSZqW89h7/pasCWNYdUpwGz3PcVWhrWupreVPYLts= 11 | cloud.google.com/go/monitoring v1.16.3 h1:mf2SN9qSoBtIgiMA4R/y4VADPWZA7VCNJA079qLaZQ8= 12 | cloud.google.com/go/monitoring v1.16.3/go.mod h1:KwSsX5+8PnXv5NJnICZzW2R8pWTis8ypC4zmdRD63Tw= 13 | cloud.google.com/go/monitoring v1.20.4/go.mod h1:v7F/UcLRw15EX7xq565N7Ae5tnYEE28+Cl717aTXG4c= 14 | cloud.google.com/go/pubsub v1.33.0 h1:6SPCPvWav64tj0sVX/+npCBKhUi/UjJehy9op/V3p2g= 15 | cloud.google.com/go/pubsub v1.33.0/go.mod h1:f+w71I33OMyxf9VpMVcZbnG5KSUkCOUHYpFd5U1GdRc= 16 | cloud.google.com/go/pubsub v1.41.0/go.mod h1:g+YzC6w/3N91tzG66e2BZtp7WrpBBMXVa3Y9zVoOGpk= 17 | cloud.google.com/go/secretmanager v1.11.4 h1:krnX9qpG2kR2fJ+u+uNyNo+ACVhplIAS4Pu7u+4gd+k= 18 | cloud.google.com/go/secretmanager v1.11.4/go.mod h1:wreJlbS9Zdq21lMzWmJ0XhWW2ZxgPeahsqeV/vZoJ3w= 19 | cloud.google.com/go/secretmanager v1.13.6/go.mod h1:x2ySyOrqv3WGFRFn2Xk10iHmNmvmcEVSSqc30eb1bhw= 20 | cloud.google.com/go/trace v1.10.4 h1:2qOAuAzNezwW3QN+t41BtkDJOG42HywL73q8x/f6fnM= 21 | cloud.google.com/go/trace v1.10.4/go.mod h1:Nso99EDIK8Mj5/zmB+iGr9dosS/bzWCJ8wGmE6TXNWY= 22 | cloud.google.com/go/trace v1.10.12/go.mod h1:tYkAIta/gxgbBZ/PIzFxSH5blajgX4D00RpQqCG/GZs= 23 | contrib.go.opencensus.io/exporter/aws v0.0.0-20230502192102-15967c811cec h1:CSNP8nIEQt4sZEo2sGUiWSmVJ9c5QdyIQvwzZAsn+8Y= 24 | contrib.go.opencensus.io/exporter/aws v0.0.0-20230502192102-15967c811cec/go.mod h1:uu1P0UCM/6RbsMrgPa98ll8ZcHM858i/AD06a9aLRCA= 25 | contrib.go.opencensus.io/exporter/stackdriver v0.13.14 h1:zBakwHardp9Jcb8sQHcHpXy/0+JIb1M8KjigCJzx7+4= 26 | contrib.go.opencensus.io/exporter/stackdriver v0.13.14/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc= 27 | contrib.go.opencensus.io/integrations/ocsql v0.1.7 h1:G3k7C0/W44zcqkpRSFyjU9f6HZkbwIrL//qqnlqWZ60= 28 | contrib.go.opencensus.io/integrations/ocsql v0.1.7/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= 29 | github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk= 30 | github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= 31 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0 h1:fb8kj/Dh4CSwgsOzHeZY4Xh68cFVbzXx+ONXGMY//4w= 32 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.0/go.mod h1:uReU2sSxZExRPBAg3qKzmAucSi51+SP1OhohieR821Q= 33 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= 34 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI= 35 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0/go.mod h1:1fXstnBMas5kzG+S3q8UoJcmyU6nUeunJcMDHcRYHhs= 36 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= 37 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.0 h1:d81/ng9rET2YqdVkVwkb6EXeRrLJIwyGnJcAlAWKwhs= 38 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.0/go.mod h1:s4kgfzA0covAXNicZHDMN58jExvcng2mC/DepXiF1EI= 39 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= 40 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= 41 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= 42 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= 43 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= 44 | github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.5.0 h1:HKHkea1fdm18LT8VAxTVZgJpPsLgv+0NZhmtus1UqJQ= 45 | github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.5.0/go.mod h1:4BbKA+mRmmTP8VaLfDPNF5nOdhRm5upG3AXVWfv1dxc= 46 | github.com/Azure/azure-sdk-for-go/sdk/messaging/azservicebus v1.7.1/go.mod h1:6QAMYBAbQeeKX+REFJMZ1nFWu9XLw/PPcjYpuc9RDFs= 47 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0 h1:gggzg0SUMs6SQbEw+3LoSsYf9YMjkupeAnHMX8O9mmY= 48 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.0/go.mod h1:+6KLcKIVgxoBDMqMO/Nvy7bZ9a0nbU3I1DtFQK3YvB4= 49 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2/go.mod h1:dmXQgZuiSubAecswZE+Sm8jkvEa7kQgTPVRvwL/nd0E= 50 | github.com/Azure/go-amqp v1.0.2 h1:zHCHId+kKC7fO8IkwyZJnWMvtRXhYC0VJtD0GYkHc6M= 51 | github.com/Azure/go-amqp v1.0.2/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= 52 | github.com/Azure/go-amqp v1.0.5/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= 53 | github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= 54 | github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= 55 | github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk= 56 | github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE= 57 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 h1:hVeq+yCyUi+MsoO/CU95yqCIcdzra5ovzk8Q2BBpV2M= 58 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 59 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 60 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 61 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 62 | github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= 63 | github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= 64 | github.com/GoogleCloudPlatform/cloudsql-proxy v1.33.14 h1:9bRF9/edlX1kycHVm/icATaIWfVyHcUB9c68iWWeNok= 65 | github.com/GoogleCloudPlatform/cloudsql-proxy v1.33.14/go.mod h1:vroGijye9h4A6kMWeCtk9/zIh5ebseV/JmbKJ0VL3w8= 66 | github.com/GoogleCloudPlatform/cloudsql-proxy v1.36.0/go.mod h1:VRKXU8C7Y/aUKjRBTGfw0Ndv4YqNxlB8zAPJJDxbASE= 67 | github.com/Joker/jade v1.1.3/go.mod h1:T+2WLyt7VH6Lp0TRxQrUYEs64nRc83wkMQrfeIQKduM= 68 | github.com/Shopify/goreferrer v0.0.0-20220729165902-8cddb4f5de06/go.mod h1:7erjKLwalezA0k99cWs5L11HWOAPNjdUZ6RxH1BXbbM= 69 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 70 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 71 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 72 | github.com/aws/aws-sdk-go-v2/service/kms v1.27.5 h1:7lKTr8zJ2nVaVgyII+7hUayTi7xWedMuANiNVXiD2S8= 73 | github.com/aws/aws-sdk-go-v2/service/kms v1.27.5/go.mod h1:D9FVDkZjkZnnFHymJ3fPVz0zOUlNSd0xcIIVmmrAac8= 74 | github.com/aws/aws-sdk-go-v2/service/kms v1.35.3/go.mod h1:gjDP16zn+WWalyaUqwCCioQ8gU8lzttCCc9jYsiQI/8= 75 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.25.5 h1:qYi/BfDrWXZxlmRjlKCyFmtI4HKJwW8OKDKhKRAOZQI= 76 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.25.5/go.mod h1:4Ae1NCLK6ghmjzd45Tc33GgCKhUWD2ORAlULtMO1Cbs= 77 | github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.32.4/go.mod h1:TKKN7IQoM7uTnyuFm9bm9cw5P//ZYTl4m3htBWQ1G/c= 78 | github.com/aws/aws-sdk-go-v2/service/sns v1.26.5 h1:umyC9zH/A1w8AXrrG7iMxT4Rfgj80FjfvLannWt5vuE= 79 | github.com/aws/aws-sdk-go-v2/service/sns v1.26.5/go.mod h1:IrcbquqMupzndZ20BXxDxjM7XenTRhbwBOetk4+Z5oc= 80 | github.com/aws/aws-sdk-go-v2/service/sns v1.31.3/go.mod h1:1dn0delSO3J69THuty5iwP0US2Glt0mx2qBBlI13pvw= 81 | github.com/aws/aws-sdk-go-v2/service/sqs v1.29.5 h1:cJb4I498c1mrOVrRqYTcnLD65AFqUuseHfzHdNZHL9U= 82 | github.com/aws/aws-sdk-go-v2/service/sqs v1.29.5/go.mod h1:mCUv04gd/7g+/HNzDB4X6dzJuygji0ckvB3Lg/TdG5Y= 83 | github.com/aws/aws-sdk-go-v2/service/sqs v1.34.3/go.mod h1:L0enV3GCRd5iG9B64W35C4/hwsCB00Ib+DKVGTadKHI= 84 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.5 h1:5SI5O2tMp/7E/FqhYnaKdxbWjlCi2yujjNI/UO725iU= 85 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.5/go.mod h1:uXndCJoDO9gpuK24rNWVCnrGNUydKFEAYAZ7UU9S0rQ= 86 | github.com/aws/aws-sdk-go-v2/service/ssm v1.52.4/go.mod h1:v7NIzEFIHBiicOMaMTuEmbnzGnqW0d+6ulNALul6fYE= 87 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 88 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 89 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 90 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 91 | github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= 92 | github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= 93 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 94 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 95 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 96 | github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 97 | github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= 98 | github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= 99 | github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= 100 | github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY= 101 | github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= 102 | github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= 103 | github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= 104 | github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0qnXZOBM= 105 | github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 106 | github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 107 | github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 108 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw= 109 | github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4= 110 | github.com/dop251/goja v0.0.0-20241009100908-5f46f2705ca3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= 111 | github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c h1:hLoodLRD4KLWIH8eyAQCLcH8EqIrjac7fCkp/fHnvuQ= 112 | github.com/dop251/goja_nodejs v0.0.0-20231122114759-e84d9a924c5c/go.mod h1:bhGPmCgCCTSRfiMYWjpS46IDo9EUZXlsuUaPXSWGbv0= 113 | github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc/go.mod h1:VULptt4Q/fNzQUJlqY/GP3qHyU7ZH46mFkBZe0ZTokU= 114 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= 115 | github.com/envoyproxy/go-control-plane v0.11.1 h1:wSUXTlLfiAQRWs2F+p+EKOY9rUyis1MyGqJ2DIk5HpM= 116 | github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= 117 | github.com/envoyproxy/go-control-plane v0.13.0/go.mod h1:GRaKG3dwvFoTg4nj7aXdZnvMg4d7nvT/wl9WgVXn3Q8= 118 | github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= 119 | github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= 120 | github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= 121 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 122 | github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= 123 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 124 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 125 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 126 | github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= 127 | github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= 128 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= 129 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= 130 | github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU= 131 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= 132 | github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 133 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 134 | github.com/gofiber/fiber/v2 v2.52.2 h1:b0rYH6b06Df+4NyrbdptQL8ifuxw/Tf2DgfkZkDaxEo= 135 | github.com/gofiber/fiber/v2 v2.52.2/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= 136 | github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU= 137 | github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 138 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 139 | github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= 140 | github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= 141 | github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= 142 | github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= 143 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 144 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 145 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 146 | github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo= 147 | github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= 148 | github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= 149 | github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= 150 | github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= 151 | github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= 152 | github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k= 153 | github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 154 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 155 | github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= 156 | github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= 157 | github.com/iris-contrib/httpexpect/v2 v2.12.1/go.mod h1:7+RB6W5oNClX7PTwJgJnsQP3ZuUUYB3u61KCqeSgZ88= 158 | github.com/iris-contrib/schema v0.0.6/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= 159 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 160 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 161 | github.com/kataras/blocks v0.0.7/go.mod h1:UJIU97CluDo0f+zEjbnbkeMRlvYORtmc1304EeyXf4I= 162 | github.com/kataras/golog v0.1.8/go.mod h1:rGPAin4hYROfk1qT9wZP6VY2rsb4zzc37QpdPjdkqVw= 163 | github.com/kataras/iris/v12 v12.2.0/go.mod h1:BLzBpEunc41GbE68OUaQlqX4jzi791mx5HU04uPb90Y= 164 | github.com/kataras/pio v0.0.11/go.mod h1:38hH6SWH6m4DKSYmRhlrCJ5WItwWgCVrTNU62XZyUvI= 165 | github.com/kataras/sitemap v0.0.6/go.mod h1:dW4dOCNs896OR1HmG+dMLdT7JjDk7mYBzoIRwuj5jA4= 166 | github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= 167 | github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 168 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= 169 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 170 | github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= 171 | github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 172 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 173 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 174 | github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ= 175 | github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= 176 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= 177 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 178 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 179 | github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= 180 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 181 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 182 | github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= 183 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 184 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 185 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 186 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 187 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 188 | github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= 189 | github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= 190 | github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= 191 | github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= 192 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= 193 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= 194 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 195 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 196 | github.com/pocketbase/tygoja v0.0.0-20240113091827-17918475d342 h1:OcAwewen3hs/zY8i0syt8CcMTGBJhQwQRVDLcoQVXVk= 197 | github.com/pocketbase/tygoja v0.0.0-20240113091827-17918475d342/go.mod h1:dOJ+pCyqm/jRn5kO/TX598J0e5xGDcJAZerK5atCrKI= 198 | github.com/pocketbase/tygoja v0.0.0-20241015175937-d6ff411a0f75/go.mod h1:hKJWPGFqavk3cdTa47Qvs8g37lnfI57OYdVVbIqW5aE= 199 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= 200 | github.com/prometheus/prometheus v0.48.0 h1:yrBloImGQ7je4h8M10ujGh4R6oxYQJQKlMuETwNskGk= 201 | github.com/prometheus/prometheus v0.48.0/go.mod h1:SRw624aMAxTfryAcP8rOjg4S/sHHaetx2lyJJ2nM83g= 202 | github.com/prometheus/prometheus v0.54.0/go.mod h1:xlLByHhk2g3ycakQGrMaU8K7OySZx98BzeCR99991NY= 203 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 204 | github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= 205 | github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= 206 | github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 207 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 208 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 209 | github.com/tdewolff/minify/v2 v2.12.4/go.mod h1:h+SRvSIX3kwgwTFOpSckvSxgax3uy8kZTSF1Ojrr3bk= 210 | github.com/tdewolff/parse/v2 v2.6.4/go.mod h1:woz0cgbLwFdtbjJu8PIKxhW05KplTFQkOdX78o+Jgrs= 211 | github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= 212 | github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= 213 | github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= 214 | github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 215 | github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= 216 | github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= 217 | github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 218 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 219 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 220 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 221 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 222 | github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= 223 | github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= 224 | github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= 225 | github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= 226 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 227 | go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= 228 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 229 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 230 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 231 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 232 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 233 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 234 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0= 235 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 236 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 237 | golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 238 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 239 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 240 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 241 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 242 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 243 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 244 | golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 245 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 246 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 247 | golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 248 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= 249 | google.golang.org/genproto/googleapis/bytestream v0.0.0-20240116215550-a9fa1716bcac h1:QXtV4qU5zS94SeHJhPqxJQF0XyxssnVrEZOUgp1+NuY= 250 | google.golang.org/genproto/googleapis/bytestream v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:ZSvZ8l+AWJwXw91DoTjWjaVLpWU6o0eZ4YLYpH8aLeQ= 251 | google.golang.org/genproto/googleapis/bytestream v0.0.0-20241021214115-324edc3d5d38/go.mod h1:T8O3fECQbif8cez15vxAcjbwXxvL2xbnvbQ7ZfiMAMs= 252 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 253 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 254 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= 255 | lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 256 | modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= 257 | modernc.org/cc/v4 v4.2.1 h1:xwwaXFwiPaVZpGRMd19NPLsaiNyNBO8oChey4501g1M= 258 | modernc.org/cc/v4 v4.2.1/go.mod h1:0O8vuqhQfwBy+piyfEjzWIUGV4I3TPsXSf0W05+lgN8= 259 | modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I= 260 | modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d h1:3yB/pQNL5kVPDifGFqoZjeRxf8m0+Us15rB7ertNASQ= 261 | modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d/go.mod h1:austqj6cmEDRfewsUvmGmyIgsI/Nq87oTXlfTgY85Fc= 262 | modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083 h1:rGoLVwiOxdeVkGYMOF/8Pw7xpDd3OqScJU/tqHgvY1c= 263 | modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083/go.mod h1:Zt5HLUW0j+l02wj99UsPs+1DOFwwsGnqfcw+BGyyP/A= 264 | moul.io/http2curl/v2 v2.3.0/go.mod h1:RW4hyBjTWSYDOxapodpNEtX0g5Eb16sxklBqmd2RHcE= 265 | -------------------------------------------------------------------------------- /odin-turbo.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | }, 6 | { 7 | "path": "apps/scraper" 8 | }, 9 | { 10 | "path": "apps/backend" 11 | }, 12 | { 13 | "path": "apps/frontend" 14 | } 15 | ], 16 | "settings": {} 17 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odin-movieshow", 3 | "private": true, 4 | "version": "1.2.42", 5 | "scripts": { 6 | "build": "turbo build", 7 | "dev": "turbo dev", 8 | "lint": "turbo lint", 9 | "format": "prettier --write \"**/*.{ts,tsx,md}\"" 10 | }, 11 | "devDependencies": { 12 | "@repo/eslint-config": "*", 13 | "@repo/typescript-config": "*", 14 | "prettier": "^3.1.1", 15 | "turbo": "latest" 16 | }, 17 | "engines": { 18 | "node": ">=18" 19 | }, 20 | "packageManager": "bun@1.0.28", 21 | "workspaces": [ 22 | "apps/*", 23 | "packages/*" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 8 | plugins: ["only-warn"], 9 | globals: { 10 | React: true, 11 | JSX: true, 12 | }, 13 | env: { 14 | node: true, 15 | }, 16 | settings: { 17 | "import/resolver": { 18 | typescript: { 19 | project, 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | ".*.js", 26 | "node_modules/", 27 | "dist/", 28 | ], 29 | overrides: [ 30 | { 31 | files: ["*.js?(x)", "*.ts?(x)"], 32 | }, 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | extends: [ 8 | "eslint:recommended", 9 | "prettier", 10 | require.resolve("@vercel/style-guide/eslint/next"), 11 | "eslint-config-turbo", 12 | ], 13 | globals: { 14 | React: true, 15 | JSX: true, 16 | }, 17 | env: { 18 | node: true, 19 | browser: true, 20 | }, 21 | plugins: ["only-warn"], 22 | settings: { 23 | "import/resolver": { 24 | typescript: { 25 | project, 26 | }, 27 | }, 28 | }, 29 | ignorePatterns: [ 30 | // Ignore dotfiles 31 | ".*.js", 32 | "node_modules/", 33 | ], 34 | overrides: [{ files: ["*.js?(x)", "*.ts?(x)"] }], 35 | }; 36 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "library.js", 7 | "next.js", 8 | "react-internal.js" 9 | ], 10 | "devDependencies": { 11 | "@vercel/style-guide": "^5.1.0", 12 | "eslint-config-turbo": "^1.11.3", 13 | "eslint-config-prettier": "^9.1.0", 14 | "eslint-plugin-only-warn": "^1.1.0", 15 | "@typescript-eslint/parser": "^6.17.0", 16 | "@typescript-eslint/eslint-plugin": "^6.17.0", 17 | "typescript": "^5.3.3" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/eslint-config/react-internal.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * internal (bundled by their consumer) libraries 8 | * that utilize React. 9 | * 10 | * This config extends the Vercel Engineering Style Guide. 11 | * For more information, see https://github.com/vercel/style-guide 12 | * 13 | */ 14 | 15 | /** @type {import("eslint").Linter.Config} */ 16 | module.exports = { 17 | extends: ["eslint:recommended", "prettier", "eslint-config-turbo"], 18 | plugins: ["only-warn"], 19 | globals: { 20 | React: true, 21 | JSX: true, 22 | }, 23 | env: { 24 | browser: true, 25 | }, 26 | settings: { 27 | "import/resolver": { 28 | typescript: { 29 | project, 30 | }, 31 | }, 32 | }, 33 | ignorePatterns: [ 34 | // Ignore dotfiles 35 | ".*.js", 36 | "node_modules/", 37 | "dist/", 38 | ], 39 | overrides: [ 40 | // Force ESLint to detect .tsx files 41 | { files: ["*.js?(x)", "*.ts?(x)"] }, 42 | ], 43 | }; 44 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "esModuleInterop": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "lib": ["es2022", "DOM", "DOM.Iterable"], 11 | "module": "NodeNext", 12 | "moduleDetection": "force", 13 | "moduleResolution": "NodeNext", 14 | "noUncheckedIndexedAccess": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "ES2022" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/typescript-config/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "allowJs": true, 10 | "jsx": "preserve", 11 | "noEmit": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ["@repo/eslint-config/react-internal.js"], 5 | parser: "@typescript-eslint/parser", 6 | parserOptions: { 7 | project: "./tsconfig.lint.json", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/ui", 3 | "version": "0.0.0", 4 | "private": true, 5 | "exports": { 6 | "./button": "./src/button.tsx", 7 | "./card": "./src/card.tsx", 8 | "./code": "./src/code.tsx" 9 | }, 10 | "scripts": { 11 | "lint": "eslint . --max-warnings 0", 12 | "generate:component": "turbo gen react-component" 13 | }, 14 | "devDependencies": { 15 | "@repo/eslint-config": "*", 16 | "@repo/typescript-config": "*", 17 | "@turbo/gen": "^1.11.3", 18 | "@types/node": "^20.10.6", 19 | "@types/eslint": "^8.56.1", 20 | "@types/react": "^18.2.46", 21 | "@types/react-dom": "^18.2.18", 22 | "eslint": "^8.56.0", 23 | "react": "^18.2.0", 24 | "typescript": "^5.3.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/ui/src/button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | 5 | interface ButtonProps { 6 | children: ReactNode; 7 | className?: string; 8 | appName: string; 9 | } 10 | 11 | export const Button = ({ children, className, appName }: ButtonProps) => { 12 | return ( 13 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/ui/src/card.tsx: -------------------------------------------------------------------------------- 1 | export function Card({ 2 | className, 3 | title, 4 | children, 5 | href, 6 | }: { 7 | className?: string; 8 | title: string; 9 | children: React.ReactNode; 10 | href: string; 11 | }): JSX.Element { 12 | return ( 13 | 19 |

20 | {title} -> 21 |

22 |

{children}

23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/ui/src/code.tsx: -------------------------------------------------------------------------------- 1 | export function Code({ 2 | children, 3 | className, 4 | }: { 5 | children: React.ReactNode; 6 | className?: string; 7 | }): JSX.Element { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src", "turbo"], 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/ui/turbo/generators/config.ts: -------------------------------------------------------------------------------- 1 | import type { PlopTypes } from "@turbo/gen"; 2 | 3 | // Learn more about Turborepo Generators at https://turbo.build/repo/docs/core-concepts/monorepos/code-generation 4 | 5 | export default function generator(plop: PlopTypes.NodePlopAPI): void { 6 | // A simple generator to add a new React component to the internal UI library 7 | plop.setGenerator("react-component", { 8 | description: "Adds a new react component", 9 | prompts: [ 10 | { 11 | type: "input", 12 | name: "name", 13 | message: "What is the name of the component?", 14 | }, 15 | ], 16 | actions: [ 17 | { 18 | type: "add", 19 | path: "src/{{kebabCase name}}.tsx", 20 | templateFile: "templates/component.hbs", 21 | }, 22 | { 23 | type: "append", 24 | path: "package.json", 25 | pattern: /"exports": {(?)/g, 26 | template: '"./{{kebabCase name}}": "./src/{{kebabCase name}}.tsx",', 27 | }, 28 | ], 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /packages/ui/turbo/generators/templates/component.hbs: -------------------------------------------------------------------------------- 1 | export const {{ pascalCase name }} = ({ children }: { children: React.ReactNode }) => { 2 | return ( 3 |
4 |

{{ pascalCase name }} Component

5 | {children} 6 |
7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /screenshots/btc_donation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/screenshots/btc_donation.png -------------------------------------------------------------------------------- /screenshots/connect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/screenshots/connect.png -------------------------------------------------------------------------------- /screenshots/odin-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/screenshots/odin-screenshot.png -------------------------------------------------------------------------------- /screenshots/odin-screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ad-on-is/odin-server/f188b743bac4664515f1c48dd8f232bf9c153037/screenshots/odin-screenshot2.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json" 3 | } 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "globalDependencies": [ 4 | "**/.env.*local" 5 | ], 6 | "pipeline": { 7 | "build": { 8 | "dependsOn": [ 9 | "^build" 10 | ], 11 | "outputs": [ 12 | ".next/**", 13 | "!.next/cache/**" 14 | ] 15 | }, 16 | "lint": { 17 | "dependsOn": [ 18 | "^lint" 19 | ] 20 | }, 21 | "dev": { 22 | "cache": false, 23 | "persistent": true 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | v1.2.42 2 | --------------------------------------------------------------------------------