├── library ├── movies │ └── .gitkeep ├── downloads │ └── .gitkeep └── tvshows │ └── .gitkeep ├── packages ├── vpn │ └── .gitkeep ├── jackett │ ├── config │ │ └── .gitkeep │ └── downloads │ │ └── .gitkeep ├── transmission │ ├── config │ │ ├── .gitkeep │ │ └── settings.json │ └── watch │ │ └── .gitkeep ├── api │ ├── .dockerignore │ ├── module.d.ts │ ├── nest-cli.json │ ├── tsconfig.build.json │ ├── src │ │ ├── entities │ │ │ ├── dao │ │ │ │ ├── tag.dao.ts │ │ │ │ ├── file.dao.ts │ │ │ │ ├── movie.dao.ts │ │ │ │ ├── quality.dao.ts │ │ │ │ ├── torrent.dao.ts │ │ │ │ ├── media-view.dao.ts │ │ │ │ ├── parameter.dao.ts │ │ │ │ ├── tvshow.dao.ts │ │ │ │ ├── tvseason.dao.ts │ │ │ │ └── tvepisode.dao.ts │ │ │ ├── parameter.entity.ts │ │ │ ├── tag.entity.ts │ │ │ ├── torrent.entity.ts │ │ │ ├── quality.entity.ts │ │ │ ├── tvshow.entity.ts │ │ │ ├── file.entity.ts │ │ │ ├── movie.entity.ts │ │ │ ├── media-view.entity.ts │ │ │ ├── tvseason.entity.ts │ │ │ └── tvepisode.entity.ts │ │ ├── utils │ │ │ ├── format-number.ts │ │ │ ├── sanitize.ts │ │ │ ├── allowed-file-extensions.json │ │ │ ├── promise-resolve.ts │ │ │ ├── recursive-camel-case.ts │ │ │ └── winston-options.ts │ │ ├── modules │ │ │ ├── image-cache │ │ │ │ ├── image-cache.module.ts │ │ │ │ └── image-cache.controller.ts │ │ │ ├── redis │ │ │ │ ├── redis.module.ts │ │ │ │ ├── cache.dto.ts │ │ │ │ ├── invalidate-cache.interceptor.ts │ │ │ │ ├── redis.service.ts │ │ │ │ └── cache.interceptor.ts │ │ │ ├── omdb │ │ │ │ ├── omdb.module.ts │ │ │ │ ├── omdb.resolver.ts │ │ │ │ ├── omdb.dto.ts │ │ │ │ └── omdb.service.ts │ │ │ ├── transmission │ │ │ │ ├── transmission.module.ts │ │ │ │ ├── transmission.dto.ts │ │ │ │ ├── transmission.resolver.ts │ │ │ │ └── transmission.service.ts │ │ │ ├── health │ │ │ │ └── health.controller.ts │ │ │ ├── params │ │ │ │ ├── params.module.ts │ │ │ │ ├── params.dto.ts │ │ │ │ └── params.resolver.ts │ │ │ ├── jackett │ │ │ │ ├── jackett.resolver.ts │ │ │ │ ├── jackett.module.ts │ │ │ │ └── jackett.dto.ts │ │ │ ├── tmdb │ │ │ │ ├── tmdb.module.ts │ │ │ │ └── tmdb.resolver.ts │ │ │ ├── jobs │ │ │ │ ├── jobs.resolver.ts │ │ │ │ ├── jobs.module.ts │ │ │ │ └── jobs.service.ts │ │ │ └── library │ │ │ │ ├── library.module.ts │ │ │ │ └── library.dto.ts │ │ ├── main.ts │ │ ├── config.ts │ │ ├── app.module.ts │ │ └── app.dto.ts │ ├── Dockerfile │ ├── tsconfig.json │ ├── .gitignore │ └── package.json └── web │ ├── .dockerignore │ ├── public │ ├── favicon.ico │ ├── assets │ │ └── rating │ │ │ ├── IMDB.png │ │ │ ├── TMDB.png │ │ │ ├── metaCritic.png │ │ │ └── rottenTomatoes.png │ └── zeit.svg │ ├── .babelrc │ ├── queries │ ├── get-languages.query.graphql │ ├── get-tags.query.graphql │ ├── get-genres.query.graphql │ ├── get-movie-file-details.query.graphql │ ├── omdb-search.query.graphql │ ├── get-quality.query.graphql │ ├── get-params.query.graphql │ ├── get-library-tvshows.query.graphql │ ├── get-tv-show-seasons.query.graphql │ ├── get-library-movies.query.graphql │ ├── get-downloading.query.graphql │ ├── get-torrent-status.query.graphql │ ├── search-torrent.query.graphql │ ├── get-calendar.query.graphql │ ├── get-recommended.query.graphql │ ├── get-popular.query.graphql │ ├── search.query.graphql │ ├── get-missing.query.graphql │ ├── get-tv-season-details.query.graphql │ └── get-discovery.query.graphql │ ├── mutations │ ├── clear-cache.mutation.graphql │ ├── save-tags.mutation.graphql │ ├── remove-movie.mutation.graphql │ ├── remove-tv-show.mutation.graphql │ ├── track-movie.mutation.graphql │ ├── update-params.mutation.graphql │ ├── save-quality.mutation.graphql │ ├── track-tvshow.mutation.graphql │ ├── reset-library.mutation.graphql │ ├── download-own-torrent.mutation.graphql │ ├── jobs.mutation.graphql │ └── manual-download.mutation.graphql │ ├── components │ ├── home │ │ └── home.component.tsx │ ├── layout │ │ ├── layout.styles.tsx │ │ └── layout.component.tsx │ ├── discover │ │ ├── discover-filter-section.styles.tsx │ │ ├── discover-filter-section.component.tsx │ │ └── discover.styles.tsx │ ├── calandar │ │ ├── calendar.styles.tsx │ │ └── calendar.component.tsx │ ├── theme.ts │ ├── rating │ │ ├── rating.component.tsx │ │ └── rating.styles.tsx │ ├── settings │ │ ├── settings.helpers.ts │ │ ├── settings.component.tsx │ │ ├── settings.styles.tsx │ │ ├── settings-form.component.tsx │ │ ├── actions.component.tsx │ │ └── quality-params.component.tsx │ ├── manual-search │ │ ├── manual-search.styles.tsx │ │ └── manual-search.helpers.ts │ ├── fonts.ts │ ├── movie-details │ │ ├── rating-details.styles.ts │ │ ├── movie-file-details.component.tsx │ │ ├── rating-details.component.tsx │ │ ├── use-remove-library.hook.tsx │ │ ├── use-add-library.hook.tsx │ │ └── movie-details.styles.tsx │ ├── missing │ │ └── missing.styles.tsx │ ├── library-header │ │ └── library-header.component.tsx │ ├── movies │ │ ├── movies.styles.tsx │ │ └── movies.component.tsx │ ├── downloading │ │ ├── downloading.styles.tsx │ │ ├── downloading.component.tsx │ │ ├── searching-rows.component.tsx │ │ └── downloading-rows.component.tsx │ ├── navbar │ │ ├── navbar.component.tsx │ │ └── navbar.styles.tsx │ ├── tmdb-card │ │ ├── tmdb-card.styles.tsx │ │ └── tmdb-card.component.tsx │ ├── with-apollo.tsx │ ├── tvshow-details │ │ ├── use-get-seasons.hook.tsx │ │ └── tvshow-details.styles.tsx │ ├── tvshows │ │ └── tvshows.component.tsx │ ├── suggestions │ │ └── suggestions.component.tsx │ ├── search │ │ ├── search.styles.tsx │ │ └── carousel.component.tsx │ └── sortable │ │ └── sortable.component.tsx │ ├── next-env.d.ts │ ├── utils │ ├── api-url.ts │ ├── format-number.ts │ ├── redirect.ts │ ├── get-cached-image-url.ts │ ├── to-base64.ts │ ├── create-search-function.ts │ └── available-in.ts │ ├── Dockerfile │ ├── styled-components.d.ts │ ├── .gitignore │ ├── pages │ ├── search.tsx │ ├── calendar.tsx │ ├── discover.tsx │ ├── library │ │ ├── movies.tsx │ │ └── tvshows.tsx │ ├── settings.tsx │ ├── suggestions.tsx │ ├── _document.tsx │ └── _app.tsx │ ├── codegen.yml │ ├── next.config.js │ ├── tsconfig.json │ └── package.json ├── screenshot.png ├── .eslintignore ├── apollo.config.js ├── .eslintrc.js ├── docker-compose.dev.yml ├── .gitignore ├── .env ├── .github ├── stale.yml └── workflows │ ├── lint-and-build.yml │ ├── build-and-publish-api.yml │ └── build-and-puslish-web.yml ├── docker-compose.wireguard.yml ├── docker-compose.vpn.yml ├── LICENSE ├── package.json ├── scripts ├── bobarr.sh └── install.sh ├── docker-compose.yml ├── CHANGELOG.md └── CODE_OF_CONDUCT.md /library/movies/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/vpn/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /library/downloads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /library/tvshows/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/jackett/config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/jackett/downloads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/transmission/config/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/transmission/watch/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/api/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /packages/web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/screenshot.png -------------------------------------------------------------------------------- /packages/api/module.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'xml2json-light'; 2 | declare module 'lib-get-redirects'; 3 | -------------------------------------------------------------------------------- /packages/api/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [["styled-components", { "ssr": true }]] 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/public/assets/rating/IMDB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/packages/web/public/assets/rating/IMDB.png -------------------------------------------------------------------------------- /packages/web/public/assets/rating/TMDB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/packages/web/public/assets/rating/TMDB.png -------------------------------------------------------------------------------- /packages/api/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/public/assets/rating/metaCritic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/packages/web/public/assets/rating/metaCritic.png -------------------------------------------------------------------------------- /packages/web/public/assets/rating/rottenTomatoes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iam4x/bobarr/HEAD/packages/web/public/assets/rating/rottenTomatoes.png -------------------------------------------------------------------------------- /packages/web/queries/get-languages.query.graphql: -------------------------------------------------------------------------------- 1 | query getLanguages { 2 | languages: getLanguages { 3 | code, 4 | language 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/mutations/clear-cache.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation clearCache { 2 | result: clearRedisCache { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/components/home/home.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function HomeComponent() { 4 | return

Hello world

; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/build 4 | **/out 5 | **/.next 6 | 7 | packages/web/utils/graphql.tsx 8 | packages/web/utils/introspection-result.ts 9 | -------------------------------------------------------------------------------- /packages/web/mutations/save-tags.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation saveTags($tags: [TagInput!]!) { 2 | result: saveTags(tags: $tags) { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint spaced-comment:off */ 2 | /// 3 | /// 4 | 5 | declare module 'prettysize'; 6 | -------------------------------------------------------------------------------- /packages/web/queries/get-tags.query.graphql: -------------------------------------------------------------------------------- 1 | query getTags { 2 | tags: getTags { 3 | id 4 | name 5 | score 6 | createdAt 7 | updatedAt 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/web/mutations/remove-movie.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation removeMovie($tmdbId: Int!) { 2 | result: removeMovie(tmdbId: $tmdbId) { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/mutations/remove-tv-show.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation removeTVShow($tmdbId: Int!) { 2 | result: removeTVShow(tmdbId: $tmdbId) { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/mutations/track-movie.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation trackMovie($title: String!, $tmdbId: Int!) { 2 | movie: trackMovie(title: $title, tmdbId: $tmdbId) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/web/utils/api-url.ts: -------------------------------------------------------------------------------- 1 | const host = typeof window === 'undefined' ? 'api' : window.location.hostname; 2 | export const apiURL = process.env.WEB_UI_API_URL || `http://${host}:4000`; 3 | -------------------------------------------------------------------------------- /packages/web/components/layout/layout.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const LayoutStyles = styled.div` 4 | padding-top: ${({ theme }) => theme.navbarHeight}px; 5 | `; 6 | -------------------------------------------------------------------------------- /packages/web/mutations/update-params.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation updateParams($params: [UpdateParamsInput!]!) { 2 | result: updateParams(params: $params) { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/mutations/save-quality.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation saveQuality($qualities: [QualityInput!]!) { 2 | result: saveQualityParams(qualities: $qualities) { 3 | success 4 | message 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/web/mutations/track-tvshow.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation trackTVShow($tmdbId: Int!, $seasonNumbers: [Int!]!) { 2 | tvShow: trackTVShow(tmdbId: $tmdbId, seasonNumbers: $seasonNumbers) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/tag.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Tag } from '../tag.entity'; 3 | 4 | @EntityRepository(Tag) 5 | export class TagDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/file.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { File } from '../file.entity'; 3 | 4 | @EntityRepository(File) 5 | export class FileDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/movie.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Movie } from '../movie.entity'; 3 | 4 | @EntityRepository(Movie) 5 | export class MovieDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/quality.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Quality } from '../quality.entity'; 3 | 4 | @EntityRepository(Quality) 5 | export class QualityDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/torrent.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Torrent } from '../torrent.entity'; 3 | 4 | @EntityRepository(Torrent) 5 | export class TorrentDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/web/queries/get-genres.query.graphql: -------------------------------------------------------------------------------- 1 | query getGenres { 2 | genres: getGenres { 3 | movieGenres { 4 | id, 5 | name 6 | } 7 | 8 | tvShowGenres { 9 | id, 10 | name 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/queries/get-movie-file-details.query.graphql: -------------------------------------------------------------------------------- 1 | query getMovieFileDetails($tmdbId: Int!) { 2 | details: getMovieFileDetails(tmdbId: $tmdbId) { 3 | id 4 | libraryPath 5 | libraryFileSize 6 | torrentFileName 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/utils/format-number.ts: -------------------------------------------------------------------------------- 1 | import { padStart } from 'lodash'; 2 | 3 | export function formatNumber(value: number) { 4 | return value.toString().length >= 2 5 | ? value.toString() 6 | : padStart(value.toString(), 2, '0'); 7 | } 8 | -------------------------------------------------------------------------------- /packages/api/src/utils/format-number.ts: -------------------------------------------------------------------------------- 1 | import { padStart } from 'lodash'; 2 | 3 | export function formatNumber(value: number) { 4 | return value.toString().length >= 2 5 | ? value.toString() 6 | : padStart(value.toString(), 2, '0'); 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/utils/redirect.ts: -------------------------------------------------------------------------------- 1 | import { NextPageContext } from 'next'; 2 | 3 | export function redirect(ctx: NextPageContext, path: string) { 4 | if (ctx.res) { 5 | ctx.res.writeHead(301, { Location: path }); 6 | ctx.res.end(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/media-view.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { MediaView } from '../media-view.entity'; 3 | 4 | @EntityRepository(MediaView) 5 | export class MediaViewDAO extends Repository {} 6 | -------------------------------------------------------------------------------- /packages/web/queries/omdb-search.query.graphql: -------------------------------------------------------------------------------- 1 | query omdbSearch( 2 | $title: String! 3 | ) { 4 | result: omdbSearch( 5 | title: $title 6 | ) { 7 | ratings { 8 | IMDB 9 | rottenTomatoes 10 | metaCritic 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/queries/get-quality.query.graphql: -------------------------------------------------------------------------------- 1 | query getQuality($type: Entertainment!) { 2 | qualities: getQualityParams(type: $type) { 3 | id 4 | name 5 | match 6 | score 7 | updatedAt 8 | createdAt 9 | type 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /apollo.config.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-commonjs: off */ 2 | 3 | module.exports = { 4 | client: { 5 | service: { 6 | name: 'bobarr', 7 | url: 'http://localhost:4000/graphql', 8 | }, 9 | excludes: ['**/*.{ts,tsx,js,jsx}'], 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /packages/api/src/modules/image-cache/image-cache.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ImageCacheController } from './image-cache.controller'; 3 | 4 | @Module({ imports: [], controllers: [ImageCacheController] }) 5 | export class ImageCacheModule {} 6 | -------------------------------------------------------------------------------- /packages/web/components/discover/discover-filter-section.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DiscoverFilterSectionStyles = styled.div` 4 | width: 100%; 5 | 6 | h6 { 7 | font-size: 14px; 8 | font-weight: 400; 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /packages/web/mutations/reset-library.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation resetLibrary($deleteFiles: Boolean!, $resetSettings: Boolean!) { 2 | result: resetLibrary( 3 | deleteFiles: $deleteFiles 4 | resetSettings: $resetSettings 5 | ) { 6 | success 7 | message 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/api/src/modules/redis/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { RedisService } from './redis.service'; 4 | 5 | @Module({ 6 | imports: [], 7 | providers: [RedisService], 8 | exports: [RedisService], 9 | }) 10 | export class RedisModule {} 11 | -------------------------------------------------------------------------------- /packages/web/queries/get-params.query.graphql: -------------------------------------------------------------------------------- 1 | query getParams { 2 | params: getParams { 3 | region 4 | language 5 | tmdb_api_key 6 | jackett_api_key 7 | max_movie_download_size 8 | max_tvshow_episode_download_size 9 | organize_library_strategy 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/utils/get-cached-image-url.ts: -------------------------------------------------------------------------------- 1 | import { apiURL } from './api-url'; 2 | 3 | export function getImageURL(url: string | null) { 4 | return url 5 | ? `${apiURL}/image-cache?i=${url}` 6 | : 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; 7 | } 8 | -------------------------------------------------------------------------------- /packages/web/components/calandar/calendar.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CalendarStyles = styled.div` 4 | .wrapper { 5 | padding-top: 60px; 6 | max-width: 1200px; 7 | margin: 0 auto; 8 | } 9 | 10 | .cell-title { 11 | font-weight: 600; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /packages/web/queries/get-library-tvshows.query.graphql: -------------------------------------------------------------------------------- 1 | query getLibraryTVShows { 2 | tvShows: getTVShows { 3 | id 4 | tmdbId 5 | title 6 | originalTitle 7 | posterPath 8 | runtime 9 | overview 10 | voteAverage 11 | releaseDate 12 | createdAt 13 | updatedAt 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/queries/get-tv-show-seasons.query.graphql: -------------------------------------------------------------------------------- 1 | query getTVShowSeasons($tvShowTMDBId: Int!) { 2 | seasons: getTVShowSeasons(tvShowTMDBId: $tvShowTMDBId) { 3 | id 4 | name 5 | seasonNumber 6 | episodeCount 7 | overview 8 | posterPath 9 | airDate 10 | inLibrary 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/api/src/modules/omdb/omdb.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { OMDBService } from './omdb.service'; 3 | import { OMDBResolver } from './omdb.resolver'; 4 | 5 | @Module({ 6 | providers: [OMDBResolver, OMDBService], 7 | exports: [OMDBService], 8 | }) 9 | export class OMDBModule {} 10 | -------------------------------------------------------------------------------- /packages/web/queries/get-library-movies.query.graphql: -------------------------------------------------------------------------------- 1 | query getLibraryMovies { 2 | movies: getMovies { 3 | id 4 | tmdbId 5 | title 6 | originalTitle 7 | state 8 | posterPath 9 | overview 10 | runtime 11 | voteAverage 12 | releaseDate 13 | createdAt 14 | updatedAt 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/api/src/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | export function sanitize(str: string) { 2 | return str 3 | .toLowerCase() 4 | .replace(/,/g, ' ') 5 | .replace(/\./g, ' ') 6 | .replace(/-/g, ' ') 7 | .replace(/\(|\)/g, '') 8 | .replace(/\[|\]/g, '') 9 | .replace(/[az]'/g, ' ') 10 | .replace(/:/g, ' '); 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/mutations/download-own-torrent.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation downloadOwnTorrent( 2 | $mediaId: Int! 3 | $mediaType: FileType! 4 | $torrent: String! 5 | ) { 6 | downloadOwnTorrent( 7 | mediaId: $mediaId 8 | mediaType: $mediaType 9 | torrent: $torrent 10 | ) { 11 | success 12 | message 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/utils/to-base64.ts: -------------------------------------------------------------------------------- 1 | export function toBase64(file: File): Promise { 2 | return new Promise((resolve, reject) => { 3 | const reader = new FileReader(); 4 | reader.readAsDataURL(file); 5 | reader.onload = () => resolve((reader.result as string).split(',')[1]); 6 | reader.onerror = (error) => reject(error); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/components/theme.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/named 2 | import { DefaultTheme } from 'styled-components'; 3 | 4 | export const theme: DefaultTheme = { 5 | navbarHeight: 64, 6 | tmdbCardHeight: 450, 7 | 8 | colors: { 9 | navbarBackground: '#2A363B', 10 | coral: '#ff847c', 11 | blue: '#1890ff', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/web/queries/get-downloading.query.graphql: -------------------------------------------------------------------------------- 1 | query getDownloading { 2 | searching: getSearchingMedias { 3 | id 4 | title 5 | resourceId 6 | resourceType 7 | } 8 | 9 | downloading: getDownloadingMedias { 10 | id 11 | title 12 | tag 13 | quality 14 | torrent 15 | resourceId 16 | resourceType 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/queries/get-torrent-status.query.graphql: -------------------------------------------------------------------------------- 1 | query getTorrentStatus($torrents: [GetTorrentStatusInput!]!) { 2 | torrents: getTorrentStatus(torrents: $torrents) { 3 | id 4 | resourceId 5 | resourceType 6 | percentDone 7 | rateDownload 8 | rateUpload 9 | uploadRatio 10 | uploadedEver 11 | totalSize 12 | status 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | RUN apk add curl 4 | 5 | ENV PORT 3000 6 | EXPOSE 3000 7 | 8 | WORKDIR /usr/src/app 9 | COPY package*.json yarn.lock ./ 10 | 11 | RUN yarn --network-timeout 1000000 12 | COPY . . 13 | 14 | RUN yarn build 15 | 16 | HEALTHCHECK --start-period=30s \ 17 | CMD curl -s -f http://localhost:3000 || exit 1 18 | 19 | CMD yarn start 20 | -------------------------------------------------------------------------------- /packages/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | RUN apk add curl 4 | 5 | ENV PORT 4000 6 | EXPOSE 4000 7 | 8 | WORKDIR /usr/src/app 9 | COPY package*.json yarn.lock ./ 10 | 11 | RUN yarn --network-timeout 1000000 12 | COPY . . 13 | 14 | RUN yarn build 15 | 16 | HEALTHCHECK --start-period=30s \ 17 | CMD curl -s -f http://localhost:4000/health || exit 1 18 | 19 | CMD yarn start:prod 20 | -------------------------------------------------------------------------------- /packages/web/queries/search-torrent.query.graphql: -------------------------------------------------------------------------------- 1 | query searchTorrent($query: String!) { 2 | results: searchJackett(query: $query) { 3 | id 4 | title 5 | quality 6 | qualityScore 7 | seeders 8 | peers 9 | link 10 | downloadLink 11 | tag 12 | tagScore 13 | normalizedTitle 14 | normalizedTitleParts 15 | size 16 | publishDate 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/components/rating/rating.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RatingStyles } from './rating.styles'; 3 | 4 | export function RatingComponent({ rating }: { rating: number }) { 5 | return ( 6 | 7 |
8 |
{rating}%
9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /packages/web/styled-components.d.ts: -------------------------------------------------------------------------------- 1 | // import original module declarations 2 | import 'styled-components'; 3 | 4 | // and extend them! 5 | declare module 'styled-components' { 6 | export interface DefaultTheme { 7 | navbarHeight: number; 8 | tmdbCardHeight: number; 9 | colors: { 10 | navbarBackground: string; 11 | coral: string; 12 | blue: string; 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web/mutations/jobs.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation startScanLibrary { 2 | result: startScanLibraryJob { 3 | success 4 | message 5 | } 6 | } 7 | 8 | mutation startFindNewEpisodes { 9 | result: startFindNewEpisodesJob { 10 | success 11 | message 12 | } 13 | } 14 | 15 | mutation startDownloadMissing { 16 | result: startDownloadMissingJob { 17 | success 18 | message 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/queries/get-calendar.query.graphql: -------------------------------------------------------------------------------- 1 | query getCalendar { 2 | calendar: getCalendar { 3 | movies { 4 | id 5 | title 6 | state 7 | releaseDate 8 | } 9 | 10 | tvEpisodes { 11 | id 12 | tvShow { 13 | id 14 | title 15 | } 16 | episodeNumber 17 | seasonNumber 18 | state 19 | releaseDate 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/web/components/layout/layout.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { NavbarComponent } from '../navbar/navbar.component'; 4 | import { LayoutStyles } from './layout.styles'; 5 | 6 | export function LayoutComponent({ children }: { children?: React.ReactNode }) { 7 | return ( 8 | 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/web/components/settings/settings.helpers.ts: -------------------------------------------------------------------------------- 1 | export function reorder({ 2 | list, 3 | startIndex, 4 | endIndex, 5 | }: { 6 | list: TList[]; 7 | startIndex: number; 8 | endIndex: number; 9 | }) { 10 | const result = Array.from(list); 11 | const [removed] = result.splice(startIndex, 1); 12 | result.splice(endIndex, 0, removed); 13 | return result.map((row, index) => ({ ...row, score: list.length - index })); 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/queries/get-recommended.query.graphql: -------------------------------------------------------------------------------- 1 | query getRecommended { 2 | tvShows: getRecommendedTVShows { 3 | id 4 | tmdbId 5 | title 6 | releaseDate 7 | posterPath 8 | overview 9 | runtime 10 | voteAverage 11 | } 12 | 13 | movies: getRecommendedMovies { 14 | id 15 | tmdbId 16 | title 17 | releaseDate 18 | posterPath 19 | overview 20 | runtime 21 | voteAverage 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/api/src/modules/redis/cache.dto.ts: -------------------------------------------------------------------------------- 1 | export enum CacheKeys { 2 | CALENDAR = 'calendar', 3 | RECOMMENDED_TV_SHOWS = 'recommended_tv_shows', 4 | RECOMMENDED_MOVIES = 'recommended_movies', 5 | TMDB_GET_MOVIE = 'tmdb_get_movie', 6 | TMDB_GET_TV_SHOW = 'tmdb_get_tv_show', 7 | TMDB_GET_ENGLISH_TV_SHOW_NAME = 'tmdb_get_english_tv_show_name', 8 | TMDB_GET_TV_EPISODE = 'tmdb_get_tv_episode', 9 | TMDB_GET_RECOMMENDATIONS = 'tmdb_get_recommendation', 10 | } 11 | -------------------------------------------------------------------------------- /packages/web/queries/get-popular.query.graphql: -------------------------------------------------------------------------------- 1 | query getPopular { 2 | results: getPopular { 3 | movies { 4 | id 5 | tmdbId 6 | title 7 | releaseDate 8 | posterPath 9 | overview 10 | runtime 11 | voteAverage 12 | } 13 | 14 | tvShows { 15 | id 16 | tmdbId 17 | title 18 | releaseDate 19 | posterPath 20 | overview 21 | runtime 22 | voteAverage 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/components/manual-search/manual-search.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ManualSearchStyles = styled.div` 4 | .search-title { 5 | font-size: 1.2em; 6 | font-weight: bold; 7 | } 8 | 9 | .search-input { 10 | display: flex; 11 | margin-top: 12px; 12 | margin-bottom: 12px; 13 | 14 | .action-btn { 15 | margin-left: 12px; 16 | } 17 | } 18 | 19 | .ant-table { 20 | font-size: 0.8em; 21 | } 22 | `; 23 | -------------------------------------------------------------------------------- /packages/web/queries/search.query.graphql: -------------------------------------------------------------------------------- 1 | query search($query: String!) { 2 | results: search(query: $query) { 3 | movies { 4 | id 5 | tmdbId 6 | title 7 | releaseDate 8 | posterPath 9 | overview 10 | runtime 11 | voteAverage 12 | } 13 | 14 | tvShows { 15 | id 16 | tmdbId 17 | title 18 | releaseDate 19 | posterPath 20 | overview 21 | runtime 22 | voteAverage 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/web/components/fonts.ts: -------------------------------------------------------------------------------- 1 | import FontFaceObserver from 'fontfaceobserver'; 2 | 3 | export function loadFonts() { 4 | const link = document.createElement('link'); 5 | link.href = 6 | 'https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,500,700,900'; 7 | link.rel = 'stylesheet'; 8 | 9 | document.head.appendChild(link); 10 | 11 | new FontFaceObserver('Source Sans Pro').load().then(() => { 12 | document.documentElement.classList.add('source-sans-pro'); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /packages/web/queries/get-missing.query.graphql: -------------------------------------------------------------------------------- 1 | fragment MissingTVEpisodes on EnrichedTVEpisode { 2 | id 3 | seasonNumber 4 | episodeNumber 5 | releaseDate 6 | tvShow { 7 | id 8 | title 9 | } 10 | } 11 | 12 | fragment MissingMovies on EnrichedMovie { 13 | id 14 | title 15 | releaseDate 16 | } 17 | 18 | query getMissing { 19 | tvEpisodes: getMissingTVEpisodes { 20 | ...MissingTVEpisodes 21 | } 22 | 23 | movies: getMissingMovies { 24 | ...MissingMovies 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/api/src/modules/omdb/omdb.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Args, Query } from '@nestjs/graphql'; 2 | 3 | import { OMDBService } from './omdb.service'; 4 | import { GetOMDBSearchQueries, OMDBInfo } from './omdb.dto'; 5 | 6 | @Resolver() 7 | export class OMDBResolver { 8 | public constructor(private readonly omdbService: OMDBService) {} 9 | 10 | @Query((_returns) => OMDBInfo) 11 | public omdbSearch(@Args() args: GetOMDBSearchQueries) { 12 | return this.omdbService.search(args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "resolveJsonModule": true, 15 | "strict": true 16 | }, 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/parameter.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | import { Parameter } from '../parameter.entity'; 3 | 4 | @EntityRepository(Parameter) 5 | export class ParameterDAO extends Repository { 6 | public async findOrCreate({ 7 | key, 8 | value, 9 | }: { 10 | key: Parameter['key']; 11 | value: Parameter['value']; 12 | }) { 13 | const param = await this.findOne({ key }); 14 | return param || this.save({ key, value }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/queries/get-tv-season-details.query.graphql: -------------------------------------------------------------------------------- 1 | query getTVSeasonDetails($tvShowTMDBId: Int!, $seasonNumber: Int!) { 2 | episodes: getTVSeasonDetails( 3 | tvShowTMDBId: $tvShowTMDBId 4 | seasonNumber: $seasonNumber 5 | ) { 6 | id 7 | episodeNumber 8 | seasonNumber 9 | state 10 | updatedAt 11 | voteAverage 12 | releaseDate 13 | createdAt 14 | tvShow { 15 | id 16 | title 17 | tmdbId 18 | updatedAt 19 | createdAt 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/api/src/utils/allowed-file-extensions.json: -------------------------------------------------------------------------------- 1 | [ 2 | "3g2", 3 | "3gp", 4 | "aaf", 5 | "asf", 6 | "avchd", 7 | "avi", 8 | "drc", 9 | "flv", 10 | "m2v", 11 | "m4p", 12 | "m4v", 13 | "mkv", 14 | "mng", 15 | "mov", 16 | "mp2", 17 | "mp4", 18 | "mpe", 19 | "mpeg", 20 | "mpg", 21 | "mpv", 22 | "mxf", 23 | "nsv", 24 | "ogg", 25 | "ogv", 26 | "qt", 27 | "rm", 28 | "rmvb", 29 | "roq", 30 | "srt", 31 | "svi", 32 | "vob", 33 | "webm", 34 | "wmv", 35 | "yuv" 36 | ] 37 | -------------------------------------------------------------------------------- /packages/web/utils/create-search-function.ts: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash'; 2 | 3 | const sanatize = (value: string | number) => 4 | typeof value === 'string' 5 | ? value 6 | .normalize('NFD') 7 | .replace(/[\u0300-\u036f]/g, '') 8 | .toLowerCase() 9 | : value?.toString(); 10 | 11 | export function createSearchFunction(fields: string[], searchQuery: string) { 12 | return (obj: any) => 13 | fields.some((field) => 14 | sanatize(get(obj, field) || '').includes(sanatize(searchQuery)) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /packages/api/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-commonjs 2 | module.exports = { 3 | extends: ['algolia', 'algolia/react', 'algolia/typescript'], 4 | rules: { 5 | '@typescript-eslint/interface-name-prefix': 'off', 6 | '@typescript-eslint/explicit-function-return-type': 'off', 7 | '@typescript-eslint/no-explicit-any': 'off', 8 | 9 | 'import/extensions': 'off', 10 | 'import/no-unresolved': 'off', 11 | 12 | 'no-shadow': 'off', 13 | 'no-redeclare': 'off', 14 | 'new-cap': 'off', 15 | 'no-console': 'warn', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /packages/web/components/movie-details/rating-details.styles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const RatingDetailsStyles = styled.div` 4 | display: flex; 5 | 6 | li { 7 | display: flex; 8 | align-items: center; 9 | padding-right: 20px; 10 | } 11 | 12 | img { 13 | width: 30px; 14 | height: 30px; 15 | margin-right: 6px; 16 | } 17 | 18 | .rating-details--rate { 19 | font-size: 20px; 20 | } 21 | 22 | .rating-details--rate-suffix { 23 | font-size: 15px; 24 | opacity: 0.6; 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /packages/api/src/modules/transmission/transmission.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { TorrentDAO } from 'src/entities/dao/torrent.dao'; 5 | 6 | import { TransmissionService } from './transmission.service'; 7 | import { TransmissionResolver } from './transmission.resolver'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([TorrentDAO])], 11 | providers: [TransmissionService, TransmissionResolver], 12 | exports: [TransmissionService], 13 | }) 14 | export class TransmissionModule {} 15 | -------------------------------------------------------------------------------- /packages/api/src/modules/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | import { 4 | HealthCheckService, 5 | DNSHealthIndicator, 6 | HealthCheck, 7 | } from '@nestjs/terminus'; 8 | 9 | @Controller('health') 10 | export class HealthController { 11 | public constructor( 12 | private health: HealthCheckService, 13 | private dns: DNSHealthIndicator 14 | ) {} 15 | 16 | @Get() 17 | @HealthCheck() 18 | public check() { 19 | return this.health.check([ 20 | () => this.dns.pingCheck('tmdb', 'https://www.themoviedb.org/'), 21 | ]); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/web/components/discover/discover-filter-section.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | import { DiscoverFilterSectionStyles } from './discover-filter-section.styles'; 3 | 4 | interface DiscoverFilterSectionComponentProps { 5 | title?: string; 6 | children: ReactNode; 7 | } 8 | export function DiscoverFilterSectionComponent( 9 | props: DiscoverFilterSectionComponentProps 10 | ) { 11 | const { title, children } = props; 12 | return ( 13 | 14 |
{title}
15 | {children} 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/web/components/missing/missing.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MissingComponentStyles = styled.div` 4 | .wrapper { 5 | max-width: 1200px; 6 | margin: 0 auto; 7 | } 8 | 9 | .row { 10 | background: #fff; 11 | border-radius: 4px; 12 | align-items: center; 13 | padding: 5px 8px; 14 | font-size: 0.8em; 15 | margin-bottom: 8px; 16 | display: flex; 17 | width: 100%; 18 | } 19 | 20 | .title { 21 | font-weight: bold; 22 | margin-right: 4px; 23 | } 24 | 25 | .ant-tag { 26 | margin-left: auto; 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /packages/web/pages/search.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { LayoutComponent } from '../components/layout/layout.component'; 5 | import { SearchComponent } from '../components/search/search.component'; 6 | import { withApollo } from '../components/with-apollo'; 7 | 8 | function SearchPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Search 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(SearchPage); 22 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/tvshow.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository, DeepPartial } from 'typeorm'; 2 | import { TVShow } from '../tvshow.entity'; 3 | 4 | @EntityRepository(TVShow) 5 | export class TVShowDAO extends Repository { 6 | public async findOrCreate(tvShowAttributes: DeepPartial) { 7 | if (!tvShowAttributes.tmdbId) { 8 | throw new Error('findOrCreate TVShow needs [tmdbId]'); 9 | } 10 | 11 | const tvShow = await this.findOne({ 12 | where: { tmdbId: tvShowAttributes.tmdbId }, 13 | }); 14 | 15 | return tvShow || this.save(tvShowAttributes); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "http://api:4000/graphql" 3 | documents: "{queries,mutations}/**/*.graphql" 4 | generates: 5 | utils/graphql.tsx: 6 | config: 7 | withHooks: true 8 | withComponent: false 9 | withHOC: false 10 | withMutationFn: false 11 | addDocBlocks: false 12 | reactApolloVersion: 3 13 | plugins: 14 | - add: 15 | content: '/* eslint-disable */' 16 | - add: 17 | content: '/* this is a generated file, do not edit */' 18 | - typescript 19 | - typescript-operations 20 | - typescript-react-apollo 21 | -------------------------------------------------------------------------------- /packages/web/pages/calendar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { LayoutComponent } from '../components/layout/layout.component'; 5 | import { withApollo } from '../components/with-apollo'; 6 | import { CalendarComponent } from '../components/calandar/calendar.component'; 7 | 8 | function CalendarPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Calendar 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(CalendarPage); 22 | -------------------------------------------------------------------------------- /packages/web/pages/discover.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { LayoutComponent } from '../components/layout/layout.component'; 5 | import { withApollo } from '../components/with-apollo'; 6 | import { DiscoverComponent } from '../components/discover/discover.component'; 7 | 8 | function DiscoverPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Discover 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(DiscoverPage); 22 | -------------------------------------------------------------------------------- /packages/web/pages/library/movies.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { withApollo } from '../../components/with-apollo'; 5 | import { LayoutComponent } from '../../components/layout/layout.component'; 6 | import { MoviesComponent } from '../../components/movies/movies.component'; 7 | 8 | function MoviesPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Movies 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(MoviesPage); 22 | -------------------------------------------------------------------------------- /packages/web/pages/settings.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | 4 | import { withApollo } from '../components/with-apollo'; 5 | import { LayoutComponent } from '../components/layout/layout.component'; 6 | import { SettingsComponent } from '../components/settings/settings.component'; 7 | 8 | function SettingsPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Settings 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(SettingsPage); 22 | -------------------------------------------------------------------------------- /packages/web/pages/library/tvshows.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { withApollo } from '../../components/with-apollo'; 5 | import { LayoutComponent } from '../../components/layout/layout.component'; 6 | import { TVShowsComponent } from '../../components/tvshows/tvshows.component'; 7 | 8 | function TVShowsPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - TV Shows 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(TVShowsPage); 22 | -------------------------------------------------------------------------------- /packages/web/utils/available-in.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export function availableIn(date: dayjs.Dayjs) { 4 | const days = date.diff(new Date(), 'day'); 5 | 6 | let label = `Available in ${days} days`; 7 | if (days === 0) label = `On air today`; 8 | if (days > 14) label = `Avaible on ${date.format('DD/MM')}`; 9 | 10 | // next year release 11 | if (date.format('YYYY') !== dayjs(new Date()).format('YYYY')) { 12 | label = `Available in ${date.format('YYYY')}`; 13 | } 14 | 15 | if (date.isBefore(new Date())) { 16 | label = `Released on ${date.format('DD/MM/YYYY')}`; 17 | } 18 | 19 | return label; 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/pages/suggestions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | 4 | import { LayoutComponent } from '../components/layout/layout.component'; 5 | import { withApollo } from '../components/with-apollo'; 6 | import { SuggestionsComponent } from '../components/suggestions/suggestions.component'; 7 | 8 | function SuggestionsPage() { 9 | return ( 10 | <> 11 | 12 | Bobarr - Suggestions 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | export default withApollo(SuggestionsPage); 22 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | api: 5 | build: 6 | context: ./packages/api 7 | command: yarn dev 8 | volumes: 9 | - ./packages/api:/usr/src/app 10 | - api_node_modules:/usr/src/app/node_modules 11 | - api_build:/usr/src/app/dist 12 | 13 | web: 14 | build: 15 | context: ./packages/web 16 | command: yarn dev 17 | volumes: 18 | - ./packages/web:/usr/src/app 19 | - web_node_modules:/usr/src/app/node_modules 20 | - web_build:/usr/src/app/.next 21 | 22 | postgres: 23 | ports: 24 | - 5432:5432 25 | 26 | redis: 27 | ports: 28 | - 6379:6379 29 | -------------------------------------------------------------------------------- /packages/api/src/entities/parameter.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | Index, 8 | } from 'typeorm'; 9 | 10 | import { ParameterKey } from 'src/app.dto'; 11 | 12 | @Entity() 13 | export class Parameter { 14 | @PrimaryGeneratedColumn() 15 | public id!: number; 16 | 17 | @Index({ unique: true }) 18 | @Column('varchar', { unique: true }) 19 | public key!: ParameterKey; 20 | 21 | @Column('varchar') 22 | public value!: string; 23 | 24 | @CreateDateColumn() 25 | public createdAt!: Date; 26 | 27 | @UpdateDateColumn() 28 | public updatedAt!: Date; 29 | } 30 | -------------------------------------------------------------------------------- /packages/web/components/library-header/library-header.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { MissingComponent } from '../missing/missing.component'; 5 | import { DownloadingComponent } from '../downloading/downloading.component'; 6 | 7 | const LibraryHeaderComponentStyles = styled.div` 8 | background: #a4bcc2; 9 | padding: 24px 0; 10 | `; 11 | 12 | export function LibraryHeaderComponent({ types }: { types: string[] }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/web/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: off */ 2 | /* eslint require-await: off */ 3 | /* eslint import/no-commonjs: off */ 4 | 5 | module.exports = { 6 | env: { WEB_UI_API_URL: process.env.WEB_UI_API_URL }, 7 | webpackDevMiddleware: (config) => { 8 | // Solve compiling problem via vagrant 9 | config.watchOptions = { 10 | poll: 1000, // Check for changes every second 11 | aggregateTimeout: 300, // delay before rebuilding 12 | }; 13 | return config; 14 | }, 15 | async redirects() { 16 | return [ 17 | { 18 | source: '/', 19 | destination: '/search', 20 | permanent: false, 21 | }, 22 | ]; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # misc 4 | .DS_Store 5 | .idea 6 | .vscode 7 | 8 | # debug 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # jackett 14 | packages/jackett/config/* 15 | packages/jackett/downloads/* 16 | !packages/jackett/**/.gitkeep 17 | 18 | # transmission 19 | packages/transmission/config/* 20 | packages/transmission/watch/* 21 | !packages/transmission/**/.gitkeep 22 | !packages/transmission/config/settings.json 23 | 24 | # vpn 25 | packages/vpn/* 26 | !packages/vpn/.gitkeep 27 | 28 | # library 29 | library/downloads/* 30 | !library/downloads/.gitkeep 31 | library/movies/* 32 | !library/movies/.gitkeep 33 | library/tvshows/* 34 | !library/tvshows/.gitkeep 35 | -------------------------------------------------------------------------------- /packages/api/src/entities/tag.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | UpdateDateColumn, 8 | CreateDateColumn, 9 | } from 'typeorm'; 10 | 11 | @Entity() 12 | @ObjectType() 13 | export class Tag { 14 | @Field() 15 | @PrimaryGeneratedColumn() 16 | public id!: number; 17 | 18 | @Field() 19 | @Column('varchar', { unique: true }) 20 | public name!: string; 21 | 22 | @Field() 23 | @Column('int') 24 | public score!: number; 25 | 26 | @Field() 27 | @CreateDateColumn() 28 | public createdAt!: Date; 29 | 30 | @Field() 31 | @UpdateDateColumn() 32 | public updatedAt!: Date; 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/queries/get-discovery.query.graphql: -------------------------------------------------------------------------------- 1 | query getDiscover( 2 | $entertainment: Entertainment 3 | $originLanguage: String 4 | $primaryReleaseYear: String 5 | $score: Float 6 | $genres: [Float!] 7 | $page: Float 8 | ) { 9 | TMDBResults: discover( 10 | entertainment: $entertainment 11 | originLanguage: $originLanguage 12 | primaryReleaseYear: $primaryReleaseYear 13 | score: $score 14 | genres: $genres 15 | page: $page 16 | ) { 17 | page 18 | totalResults 19 | totalPages 20 | results { 21 | id 22 | tmdbId 23 | title 24 | posterPath 25 | overview 26 | runtime 27 | voteAverage 28 | releaseDate 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ENV=production 2 | 3 | TZ=Europe/Paris 4 | UMASK_SET=0002 5 | 6 | # set to your own user id and group 7 | # this is required for filesytem management 8 | # use: `$ id $(whoami)` in terminal to find out 9 | # (defaults are usually already fine on macOS) 10 | PUID=510 11 | PGID=20 12 | 13 | POSTGRES_DB=bobarr 14 | POSTGRES_USER=bobarr 15 | POSTGRES_PASSWORD=bobarr 16 | 17 | REDIS_PASSWORD=bobarr 18 | 19 | JACKETT_AUTOMATIC_SEARCH_TIMEOUT=120000 20 | JACKETT_MANUAL_SEARCH_TIMEOUT=15000 21 | 22 | LIBRARY_MOVIES_FOLDER_NAME=movies 23 | LIBRARY_TV_SHOWS_FOLDER_NAME=tvshows 24 | 25 | DEBUG_REDIS=false 26 | 27 | # see https://github.com/iam4x/bobarr/issues/128 28 | # WEB_UI_API_URL=http://yourdomain.com/api 29 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "include": [ 25 | "styled-components.d.ts", 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /packages/api/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { WinstonModule } from 'nest-winston'; 3 | import { router as bullBoardMiddleware } from 'bull-board'; 4 | import * as bodyParser from 'body-parser'; 5 | 6 | import { AppModule } from './app.module'; 7 | import { winstonOptions } from './utils/winston-options'; 8 | 9 | async function bootstrap() { 10 | const logger = WinstonModule.createLogger(winstonOptions); 11 | const app = await NestFactory.create(AppModule, { logger }); 12 | app.use(bodyParser.json({ limit: '10mb' })); 13 | app.use(bodyParser.urlencoded({ limit: '10mb', extended: true })); 14 | app.use('/jobs', bullBoardMiddleware); 15 | await app.listen(4000); 16 | } 17 | bootstrap(); 18 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - bug 8 | - feature 9 | - enhancement 10 | # Label to use when marking an issue as stale 11 | staleLabel: wontfix 12 | # Comment to post when marking an issue as stale. Set to `false` to disable 13 | markComment: > 14 | This issue has been automatically marked as stale because it has not had 15 | recent activity. It will be closed if no further activity occurs. Thank you 16 | for your contributions. 17 | # Comment to post when closing a stale issue. Set to `false` to disable 18 | closeComment: true 19 | -------------------------------------------------------------------------------- /packages/api/src/modules/params/params.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RedisModule } from 'src/modules/redis/redis.module'; 5 | 6 | import { ParameterDAO } from 'src/entities/dao/parameter.dao'; 7 | import { QualityDAO } from 'src/entities/dao/quality.dao'; 8 | import { TagDAO } from 'src/entities/dao/tag.dao'; 9 | 10 | import { ParamsResolver } from './params.resolver'; 11 | import { ParamsService } from './params.service'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([ParameterDAO, QualityDAO, TagDAO]), 16 | RedisModule, 17 | ], 18 | providers: [ParamsResolver, ParamsService], 19 | exports: [ParamsService], 20 | }) 21 | export class ParamsModule {} 22 | -------------------------------------------------------------------------------- /packages/web/components/movies/movies.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MoviesComponentStyles = styled.div` 4 | padding-top: 32px; 5 | 6 | .wrapper { 7 | max-width: 1200px; 8 | margin: 0 auto; 9 | } 10 | 11 | .flex { 12 | display: flex; 13 | flex-wrap: wrap; 14 | margin-left: -12px; 15 | margin-right: -12px; 16 | } 17 | 18 | .movie-card, 19 | .tvshow-card { 20 | margin-left: 12px; 21 | margin-right: 12px; 22 | height: ${({ theme }) => theme.tmdbCardHeight}px; 23 | } 24 | 25 | .sortable { 26 | display: flex; 27 | margin-bottom: 24px; 28 | 29 | .sort-buttons button { 30 | margin-right: 8px; 31 | } 32 | 33 | .search-input { 34 | margin-left: auto; 35 | width: 300px; 36 | } 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /packages/web/mutations/manual-download.mutation.graphql: -------------------------------------------------------------------------------- 1 | mutation downloadMovie($movieId: Int!, $jackettResult: JackettInput!) { 2 | result: downloadMovie(movieId: $movieId, jackettResult: $jackettResult) { 3 | success 4 | message 5 | } 6 | } 7 | 8 | mutation downloadTVEpisode($episodeId: Int!, $jackettResult: JackettInput!) { 9 | result: downloadTVEpisode( 10 | episodeId: $episodeId 11 | jackettResult: $jackettResult 12 | ) { 13 | success 14 | message 15 | } 16 | } 17 | 18 | mutation downloadSeason( 19 | $tvShowTMDBId: Int! 20 | $seasonNumber: Int! 21 | $jackettResult: JackettInput! 22 | ) { 23 | result: downloadSeason( 24 | tvShowTMDBId: $tvShowTMDBId 25 | seasonNumber: $seasonNumber 26 | jackettResult: $jackettResult 27 | ) { 28 | success 29 | message 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/api/src/modules/jackett/jackett.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Args, Query } from '@nestjs/graphql'; 2 | 3 | import { JackettService } from './jackett.service'; 4 | import { JackettFormattedResult } from './jackett.dto'; 5 | 6 | @Resolver() 7 | export class JackettResolver { 8 | public constructor(private readonly jacketService: JackettService) {} 9 | 10 | @Query((_returns) => [JackettFormattedResult]) 11 | public async searchJackett(@Args('query') query: string) { 12 | const results = await this.jacketService.search([query], { 13 | withoutFilter: true, 14 | }); 15 | return results.map((result) => ({ 16 | ...result, 17 | tag: result.tag.label, 18 | tagScore: result.tag.score, 19 | quality: result.quality.label, 20 | qualityScore: result.quality.score, 21 | })); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/api/src/modules/tmdb/tmdb.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RedisModule } from 'src/modules/redis/redis.module'; 5 | import { ParamsModule } from 'src/modules/params/params.module'; 6 | import { TVSeasonDAO } from 'src/entities/dao/tvseason.dao'; 7 | import { TVShowDAO } from 'src/entities/dao/tvshow.dao'; 8 | import { MovieDAO } from 'src/entities/dao/movie.dao'; 9 | 10 | import { TMDBResolver } from './tmdb.resolver'; 11 | import { TMDBService } from './tmdb.service'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([TVSeasonDAO, TVShowDAO, MovieDAO]), 16 | ParamsModule, 17 | RedisModule, 18 | ], 19 | providers: [TMDBResolver, TMDBService], 20 | exports: [TMDBService], 21 | }) 22 | export class TMDBModule {} 23 | -------------------------------------------------------------------------------- /packages/web/public/zeit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/api/src/modules/jackett/jackett.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { TVSeasonDAO } from 'src/entities/dao/tvseason.dao'; 5 | import { TVEpisodeDAO } from 'src/entities/dao/tvepisode.dao'; 6 | 7 | import { ParamsModule } from 'src/modules/params/params.module'; 8 | import { LibraryModule } from 'src/modules/library/library.module'; 9 | 10 | import { JackettService } from './jackett.service'; 11 | import { JackettResolver } from './jackett.resolver'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([TVSeasonDAO, TVEpisodeDAO]), 16 | ParamsModule, 17 | forwardRef(() => LibraryModule), 18 | ], 19 | providers: [JackettService, JackettResolver], 20 | exports: [JackettService], 21 | }) 22 | export class JackettModule {} 23 | -------------------------------------------------------------------------------- /packages/api/src/utils/promise-resolve.ts: -------------------------------------------------------------------------------- 1 | function invert( 2 | promise: Promise 3 | ): Promise { 4 | return new Promise((res, rej) => promise.then(rej, res)); 5 | } 6 | 7 | export function firstOf( 8 | promises: Array> 9 | ): Promise { 10 | return invert(Promise.all(promises.map((p) => invert(p)))); 11 | } 12 | 13 | function PromiseDelay(t: number): Promise { 14 | return new Promise((resolve) => { 15 | setTimeout(resolve.bind(null), t); 16 | }); 17 | } 18 | 19 | export const PromiseRaceAll = function ( 20 | promises: Array>, 21 | timeoutTime: number 22 | ) { 23 | return Promise.all( 24 | promises.map( 25 | (p) => Promise.race([p, PromiseDelay(timeoutTime)]) as Promise 26 | ) 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: lint and build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [14.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: yarn 28 | - run: yarn lint 29 | - run: (cd packages/api && yarn build) 30 | - run: (cd packages/web && yarn build) 31 | -------------------------------------------------------------------------------- /packages/api/src/modules/params/params.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, InputType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class ParamsHash { 5 | @Field() public region!: string; 6 | @Field() public language!: string; 7 | @Field() public tmdb_api_key!: string; 8 | @Field() public jackett_api_key!: string; 9 | @Field() public max_movie_download_size!: string; 10 | @Field() public max_tvshow_episode_download_size!: string; 11 | @Field() public organize_library_strategy!: string; 12 | } 13 | 14 | @InputType() 15 | export class UpdateParamsInput { 16 | @Field() public key!: string; 17 | @Field() public value!: string; 18 | } 19 | 20 | @InputType() 21 | export class QualityInput { 22 | @Field() public id!: number; 23 | @Field() public score!: number; 24 | } 25 | 26 | @InputType() 27 | export class TagInput { 28 | @Field() public name!: string; 29 | @Field() public score!: number; 30 | } 31 | -------------------------------------------------------------------------------- /docker-compose.wireguard.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | vpn: 5 | image: linuxserver/wireguard 6 | container_name: bobarr-vpn 7 | env_file: .env 8 | cap_add: 9 | - NET_ADMIN 10 | - SYS_MODULE 11 | security_opt: 12 | - label:disable 13 | networks: 14 | - default 15 | dns: 16 | - 1.1.1.1 17 | - 1.0.0.1 18 | sysctls: 19 | - net.ipv4.conf.all.src_valid_mark=1 20 | - net.ipv6.conf.all.disable_ipv6=0 21 | volumes: 22 | - ./packages/vpn/wg0.conf:/config/wg0.conf 23 | - /lib/modules:/lib/modules 24 | restart: always 25 | 26 | api: 27 | links: 28 | - vpn:transmission 29 | networks: 30 | - default 31 | 32 | transmission: 33 | network_mode: "service:vpn" 34 | depends_on: 35 | - vpn 36 | 37 | transmission-web: 38 | links: 39 | - vpn:transmission 40 | networks: 41 | - default 42 | -------------------------------------------------------------------------------- /packages/api/src/config.ts: -------------------------------------------------------------------------------- 1 | export const DB_CONFIG = { 2 | type: 'postgres' as const, 3 | host: 'postgres', 4 | port: 5432, 5 | username: process.env.POSTGRES_USER, 6 | password: process.env.POSTGRES_PASSWORD, 7 | database: process.env.POSTGRES_DB, 8 | entities: [`${__dirname}/entities/*.entity{.ts,.js}`], 9 | synchronize: true, 10 | }; 11 | 12 | export const DEBUG_REDIS = process.env.DEBUG_REDIS === 'true' || false; 13 | export const REDIS_CONFIG = { 14 | host: 'redis', 15 | port: 6379, 16 | password: process.env.REDIS_PASSWORD, 17 | }; 18 | 19 | export const JACKETT_RESPONSE_TIMEOUT = { 20 | automatic: Number(process.env.JACKETT_AUTOMATIC_SEARCH_TIMEOUT), 21 | manual: Number(process.env.JACKETT_MANUAL_SEARCH_TIMEOUT), 22 | }; 23 | 24 | export const LIBRARY_CONFIG = { 25 | moviesFolderName: process.env.LIBRARY_MOVIES_FOLDER_NAME, 26 | tvShowsFolderName: process.env.LIBRARY_TV_SHOWS_FOLDER_NAME, 27 | }; 28 | -------------------------------------------------------------------------------- /packages/web/components/settings/settings.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { SettingsComponentStyles } from './settings.styles'; 4 | import { SettingsFormComponent } from './settings-form.component'; 5 | import { ActionsComponents } from './actions.component'; 6 | import { QualityParamsComponent } from './quality-params.component'; 7 | import { TagsComponent } from './tags.component'; 8 | 9 | export function SettingsComponent() { 10 | return ( 11 | 12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/web/components/settings/settings.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SettingsComponentStyles = styled.div` 4 | padding-top: 48px; 5 | 6 | .wrapper { 7 | max-width: 1200px; 8 | margin: 0 auto; 9 | } 10 | 11 | .flex { 12 | display: flex; 13 | justify-content: space-evenly; 14 | } 15 | 16 | .row { 17 | width: 500px; 18 | } 19 | 20 | .actions { 21 | .ant-btn { 22 | display: block; 23 | margin-bottom: 8px; 24 | width: 100%; 25 | } 26 | } 27 | 28 | .quality-preference { 29 | margin-top: 24px; 30 | 31 | .ant-card-head-title { 32 | display: flex; 33 | 34 | .help { 35 | cursor: pointer; 36 | margin-left: auto; 37 | } 38 | } 39 | 40 | .ant-btn { 41 | margin-bottom: 4px; 42 | width: 100%; 43 | } 44 | 45 | .save-btn { 46 | margin-top: 12px; 47 | } 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /packages/api/src/entities/torrent.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | Index, 8 | } from 'typeorm'; 9 | 10 | import { FileType } from 'src/app.dto'; 11 | 12 | @Entity() 13 | export class Torrent { 14 | @PrimaryGeneratedColumn() 15 | public id!: number; 16 | 17 | @Index({ unique: true }) 18 | @Column('varchar', { unique: true }) 19 | public torrentHash!: string; 20 | 21 | @Index() 22 | @Column('varchar') 23 | public resourceType!: FileType; 24 | 25 | @Column('varchar', { default: 'unknown' }) 26 | public quality!: string; 27 | 28 | @Column('varchar', { default: 'unknown' }) 29 | public tag!: string; 30 | 31 | @Index() 32 | @Column('int') 33 | public resourceId!: number; 34 | 35 | @Column('boolean', { default: false }) 36 | public completed: boolean = false; 37 | 38 | @CreateDateColumn() 39 | public createdAt!: Date; 40 | 41 | @UpdateDateColumn() 42 | public updatedAt!: Date; 43 | } 44 | -------------------------------------------------------------------------------- /packages/api/src/entities/quality.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | UpdateDateColumn, 8 | CreateDateColumn, 9 | } from 'typeorm'; 10 | import { Entertainment } from 'src/modules/tmdb/tmdb.dto'; 11 | 12 | @Entity() 13 | @ObjectType() 14 | export class Quality { 15 | @Field() 16 | @PrimaryGeneratedColumn() 17 | public id!: number; 18 | 19 | @Field() 20 | @Column('varchar') 21 | public name!: string; 22 | 23 | @Field((_type) => [String]) 24 | @Column('simple-array') 25 | public match!: string[]; 26 | 27 | @Field() 28 | @Column('int') 29 | public score!: number; 30 | 31 | @Field() 32 | @CreateDateColumn() 33 | public createdAt!: Date; 34 | 35 | @Field() 36 | @UpdateDateColumn() 37 | public updatedAt!: Date; 38 | 39 | @Field((_type) => Entertainment) 40 | @Column({ type: 'enum', enum: Entertainment, default: Entertainment.Movie }) 41 | public type!: Entertainment; 42 | } 43 | -------------------------------------------------------------------------------- /packages/web/components/movie-details/movie-file-details.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import prettySize from 'prettysize'; 3 | 4 | import { useGetMovieFileDetailsQuery } from '../../utils/graphql'; 5 | 6 | export function MovieFileDetailsComponent({ tmdbId }: { tmdbId: number }) { 7 | const { data } = useGetMovieFileDetailsQuery({ 8 | pollInterval: 5000, 9 | variables: { tmdbId }, 10 | }); 11 | 12 | return ( 13 |
    14 |
  • 15 | Library path: 16 | {data?.details?.libraryPath} 17 |
  • 18 | {data?.details?.torrentFileName && ( 19 | <> 20 |
  • 21 | Torrent name: 22 | {data?.details?.torrentFileName} 23 |
  • 24 |
  • 25 | Torrent size: 26 | {prettySize(data?.details?.libraryFileSize)} 27 |
  • 28 | 29 | )} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /docker-compose.vpn.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | vpn: 5 | image: dperson/openvpn-client 6 | container_name: bobarr-vpn 7 | cap_add: 8 | - net_admin 9 | security_opt: 10 | - label:disable 11 | networks: 12 | - default 13 | dns: 14 | - 1.1.1.1 15 | - 1.0.0.1 16 | sysctls: 17 | - net.ipv4.conf.all.src_valid_mark=1 18 | - net.ipv6.conf.all.disable_ipv6=1 19 | volumes: 20 | - /dev/net:/dev/net:z 21 | - ./packages/vpn:/vpn 22 | entrypoint: "bash -c" 23 | command: "exit 0" 24 | restart: always 25 | entrypoint: ["/sbin/tini", "--", "/usr/bin/openvpn.sh"] 26 | command: '-p "51413" -p "51413;udp" -f ""' 27 | tty: true 28 | 29 | api: 30 | links: 31 | - vpn:transmission 32 | networks: 33 | - default 34 | 35 | transmission: 36 | network_mode: "service:vpn" 37 | depends_on: 38 | - vpn 39 | 40 | transmission-web: 41 | links: 42 | - vpn:transmission 43 | networks: 44 | - default 45 | -------------------------------------------------------------------------------- /packages/api/src/modules/transmission/transmission.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int, Float, InputType } from '@nestjs/graphql'; 2 | import BigInt from 'graphql-bigint'; 3 | import { FileType } from 'src/app.dto'; 4 | 5 | @ObjectType() 6 | export class TorrentStatus { 7 | @Field((_type) => Int) public id!: number; 8 | @Field((_type) => Int) public resourceId!: number; 9 | @Field((_type) => FileType) public resourceType!: FileType; 10 | @Field((_type) => Float) public percentDone!: number; 11 | @Field((_type) => Int) public rateDownload!: number; 12 | @Field((_type) => Int) public rateUpload!: number; 13 | @Field((_type) => Float) public uploadRatio!: number; 14 | @Field((_type) => BigInt) public uploadedEver!: number; 15 | @Field((_type) => BigInt) public totalSize!: number; 16 | @Field((_type) => Int) public status!: number; 17 | } 18 | 19 | @InputType() 20 | export class GetTorrentStatusInput { 21 | @Field((_type) => Int) public resourceId!: number; 22 | @Field((_type) => FileType) public resourceType!: FileType; 23 | } 24 | -------------------------------------------------------------------------------- /packages/api/src/modules/jobs/jobs.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Mutation } from '@nestjs/graphql'; 2 | import { GraphQLCommonResponse } from 'src/app.dto'; 3 | 4 | import { JobsService } from './jobs.service'; 5 | 6 | @Resolver() 7 | export class JobsResolver { 8 | public constructor(private readonly jobsService: JobsService) {} 9 | 10 | @Mutation((_returns) => GraphQLCommonResponse) 11 | public async startScanLibraryJob() { 12 | await this.jobsService.startScanLibrary(); 13 | return { success: true, message: 'SCAN_LIBRARY_FOLDER_STARTED' }; 14 | } 15 | 16 | @Mutation((_returns) => GraphQLCommonResponse) 17 | public async startFindNewEpisodesJob() { 18 | await this.jobsService.startFindNewEpisodes(); 19 | return { success: true, message: 'FIND_NEW_EPISODES_STARTED' }; 20 | } 21 | 22 | @Mutation((_returns) => GraphQLCommonResponse) 23 | public async startDownloadMissingJob() { 24 | await this.jobsService.startDownloadMissing(); 25 | return { success: true, message: 'DOWNLOAD_MISSING_STARTED' }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/api/src/entities/tvshow.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | UpdateDateColumn, 9 | OneToMany, 10 | Index, 11 | } from 'typeorm'; 12 | 13 | import { TVSeason } from './tvseason.entity'; 14 | import { TVEpisode } from './tvepisode.entity'; 15 | 16 | @Entity() 17 | @ObjectType() 18 | export class TVShow { 19 | @Field() 20 | @PrimaryGeneratedColumn() 21 | public id!: number; 22 | 23 | @Field() 24 | @Index() 25 | @Column('int', { unique: true }) 26 | public tmdbId!: number; 27 | 28 | @Field() 29 | @Column('varchar') 30 | public title!: string; 31 | 32 | @OneToMany((_type) => TVSeason, (season) => season.tvShow) 33 | public seasons!: TVSeason[]; 34 | 35 | @OneToMany((_type) => TVEpisode, (episode) => episode.tvShow) 36 | public episodes!: TVEpisode[]; 37 | 38 | @Field() 39 | @CreateDateColumn() 40 | public createdAt!: Date; 41 | 42 | @Field() 43 | @UpdateDateColumn() 44 | public updatedAt!: Date; 45 | } 46 | -------------------------------------------------------------------------------- /packages/api/src/modules/redis/invalidate-cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | mixin, 7 | } from '@nestjs/common'; 8 | 9 | import { tap } from 'rxjs/operators'; 10 | 11 | import { RedisService } from './redis.service'; 12 | import { CacheKeys } from './cache.dto'; 13 | 14 | @Injectable() 15 | export abstract class CacheInterceptor implements NestInterceptor { 16 | protected abstract readonly keys: CacheKeys[]; 17 | 18 | public constructor(private readonly redisService: RedisService) {} 19 | 20 | public intercept(_context: ExecutionContext, next: CallHandler) { 21 | return next 22 | .handle() 23 | .pipe( 24 | tap(() => 25 | Promise.all( 26 | this.keys.map((key) => this.redisService.deleteKeysPattern(key)) 27 | ) 28 | ) 29 | ); 30 | } 31 | } 32 | 33 | export const makeInvalidateCacheInterceptor = (keys: CacheKeys[]) => 34 | mixin( 35 | class extends CacheInterceptor { 36 | protected readonly keys = keys; 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /packages/web/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { DocumentContext } from 'next/document'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | import { resetServerContext } from 'react-beautiful-dnd'; 5 | 6 | export default class MyDocument extends Document { 7 | public static async getInitialProps(ctx: DocumentContext) { 8 | resetServerContext(); 9 | 10 | const sheet = new ServerStyleSheet(); 11 | const originalRenderPage = ctx.renderPage; 12 | 13 | try { 14 | // eslint-disable-next-line no-param-reassign 15 | ctx.renderPage = () => 16 | originalRenderPage({ 17 | enhanceApp: (App) => (props) => 18 | sheet.collectStyles(), 19 | }); 20 | 21 | const initialProps = await Document.getInitialProps(ctx); 22 | return { 23 | ...initialProps, 24 | styles: ( 25 | <> 26 | {initialProps.styles} 27 | {sheet.getStyleElement()} 28 | 29 | ), 30 | }; 31 | } finally { 32 | sheet.seal(); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/tvseason.dao.ts: -------------------------------------------------------------------------------- 1 | import { DownloadableMediaState } from 'src/app.dto'; 2 | import { EntityRepository, Repository } from 'typeorm'; 3 | import { TVSeason } from '../tvseason.entity'; 4 | 5 | @EntityRepository(TVSeason) 6 | export class TVSeasonDAO extends Repository { 7 | public async inLibrary(tvShowTMDBId: number, seasonNumber: number) { 8 | const match = await this.createQueryBuilder('tvSeason') 9 | .innerJoinAndSelect( 10 | 'tvSeason.tvShow', 11 | 'tvShow', 12 | 'tvShow.tmdbId = :tvShowTMDBId', 13 | { tvShowTMDBId } 14 | ) 15 | .where('tvSeason.seasonNumber = :seasonNumber', { seasonNumber }) 16 | .getOne(); 17 | return match !== undefined; 18 | } 19 | 20 | public async findOrCreate( 21 | seasonAttributes: { 22 | tvShowId: number; 23 | seasonNumber: number; 24 | }, 25 | defaultState?: DownloadableMediaState 26 | ) { 27 | const match = await this.findOne(seasonAttributes); 28 | return ( 29 | match || (await this.save({ ...seasonAttributes, state: defaultState })) 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 iam4x 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/api/src/entities/file.entity.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | 12 | import { Movie } from './movie.entity'; 13 | import { TVEpisode } from './tvepisode.entity'; 14 | 15 | @Entity() 16 | @ObjectType() 17 | export class File { 18 | @Field() 19 | @PrimaryGeneratedColumn() 20 | public id!: number; 21 | 22 | @Field() 23 | @Column('varchar', { unique: true }) 24 | public path!: string; 25 | 26 | @Field() 27 | @Column('int', { nullable: true }) 28 | public tvEpisodeId!: number; 29 | 30 | @Field() 31 | @Column('int', { nullable: true }) 32 | public movieId!: number; 33 | 34 | @ManyToOne((_type) => Movie, (movie) => movie.files) 35 | public movie!: Movie; 36 | 37 | @ManyToOne((_type) => TVEpisode, (tvEpisode) => tvEpisode.files) 38 | public tvEpisode!: TVEpisode; 39 | 40 | @Field() 41 | @CreateDateColumn() 42 | public createdAt!: Date; 43 | 44 | @Field() 45 | @UpdateDateColumn() 46 | public updatedAt!: Date; 47 | } 48 | -------------------------------------------------------------------------------- /packages/api/src/entities/movie.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | UpdateDateColumn, 9 | Index, 10 | OneToMany, 11 | } from 'typeorm'; 12 | 13 | import { DownloadableMediaState } from 'src/app.dto'; 14 | import { File } from './file.entity'; 15 | 16 | @Entity() 17 | @ObjectType() 18 | export class Movie { 19 | @Field() 20 | @PrimaryGeneratedColumn() 21 | public id!: number; 22 | 23 | @Field() 24 | @Index() 25 | @Column('int', { unique: true }) 26 | public tmdbId!: number; 27 | 28 | @Field() 29 | @Column('varchar') 30 | public title!: string; 31 | 32 | @Field((_type) => DownloadableMediaState) 33 | @Index() 34 | @Column('varchar', { default: DownloadableMediaState.SEARCHING }) 35 | public state: DownloadableMediaState = DownloadableMediaState.SEARCHING; 36 | 37 | @OneToMany((_type) => File, (file) => file.movie) 38 | public files!: File[]; 39 | 40 | @Field() 41 | @CreateDateColumn() 42 | public createdAt!: Date; 43 | 44 | @Field() 45 | @UpdateDateColumn() 46 | public updatedAt!: Date; 47 | } 48 | -------------------------------------------------------------------------------- /packages/api/src/utils/recursive-camel-case.ts: -------------------------------------------------------------------------------- 1 | import { 2 | cloneDeep, 3 | isArray, 4 | map, 5 | isString, 6 | isNumber, 7 | mapKeys, 8 | isPlainObject, 9 | mapValues, 10 | camelCase, 11 | } from 'lodash'; 12 | 13 | export function recursiveCamelCase( 14 | object: Record | Array> 15 | ) { 16 | return transform(object) as TOutput; 17 | } 18 | 19 | function transform(object: any): any { 20 | let camelCaseObject = cloneDeep(object); 21 | 22 | if (isArray(camelCaseObject)) { 23 | return map(camelCaseObject, recursiveCamelCase); 24 | } 25 | 26 | if (isString(camelCaseObject)) { 27 | return camelCaseObject; 28 | } 29 | 30 | if (isNumber(camelCaseObject)) { 31 | return camelCaseObject; 32 | } 33 | 34 | camelCaseObject = mapKeys(camelCaseObject, (_, key) => camelCase(key)); 35 | 36 | // Recursively apply throughout object 37 | return mapValues(camelCaseObject, (value) => { 38 | if (isPlainObject(value)) { 39 | return recursiveCamelCase(value); 40 | } else if (isArray(value)) { 41 | return map(value, recursiveCamelCase); 42 | } 43 | return value; 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /packages/web/components/downloading/downloading.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DownloadingComponentStyles = styled.div` 4 | .wrapper { 5 | margin: 0 auto; 6 | max-width: 1200px; 7 | } 8 | 9 | .download-row { 10 | background: #fff; 11 | border-radius: 4px; 12 | align-items: center; 13 | padding: 5px 8px; 14 | font-size: 0.8em; 15 | margin-bottom: 8px; 16 | display: flex; 17 | width: 100%; 18 | 19 | .speed { 20 | flex-shrink: 0; 21 | font-size: 0.7em; 22 | margin-left: auto; 23 | margin-right: 12px; 24 | } 25 | 26 | .progress { 27 | flex-shrink: 0; 28 | width: 250px; 29 | } 30 | 31 | .name { 32 | text-transform: uppercase; 33 | font-weight: 600; 34 | text-overflow: ellipsis; 35 | white-space: nowrap; 36 | overflow: hidden; 37 | } 38 | 39 | .torrent-name { 40 | font-size: 0.7em; 41 | margin-left: 4px; 42 | margin-right: 12px; 43 | text-transform: uppercase; 44 | text-overflow: ellipsis; 45 | white-space: nowrap; 46 | overflow: hidden; 47 | } 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /packages/web/components/rating/rating.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | interface Props { 4 | rating: number; 5 | } 6 | 7 | export const RatingStyles = styled.div` 8 | color: #fff; 9 | font-size: 11px; 10 | position: relative; 11 | width: 38px; 12 | height: 38px; 13 | 14 | .vote { 15 | border: 2px solid ${({ theme }) => theme.colors.navbarBackground}; 16 | background: ${(props) => (props.rating >= 70 ? '#21d07a' : '#d2d531')}; 17 | border-radius: 50%; 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | position: relative; 22 | height: 100%; 23 | width: 100%; 24 | 25 | &::before { 26 | content: ' '; 27 | background: ${({ theme }) => theme.colors.navbarBackground}; 28 | border-radius: 50%; 29 | top: 0; 30 | left: 0; 31 | height: calc(100% - 4px); 32 | width: calc(100% - 4px); 33 | } 34 | } 35 | 36 | .percent { 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | display: flex; 41 | width: 100%; 42 | height: 100%; 43 | align-items: center; 44 | justify-content: center; 45 | } 46 | `; 47 | -------------------------------------------------------------------------------- /packages/api/src/modules/transmission/transmission.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Query, Args } from '@nestjs/graphql'; 2 | import { map } from 'p-iteration'; 3 | 4 | import { TorrentDAO } from 'src/entities/dao/torrent.dao'; 5 | 6 | import { TorrentStatus, GetTorrentStatusInput } from './transmission.dto'; 7 | import { TransmissionService } from './transmission.service'; 8 | 9 | @Resolver() 10 | export class TransmissionResolver { 11 | public constructor( 12 | private readonly torrentDAO: TorrentDAO, 13 | private readonly transmissionService: TransmissionService 14 | ) {} 15 | 16 | @Query((_returns) => [TorrentStatus]) 17 | public getTorrentStatus( 18 | @Args('torrents', { type: () => [GetTorrentStatusInput] }) 19 | torrents: GetTorrentStatusInput[] 20 | ) { 21 | return map(torrents, async ({ resourceId, resourceType }) => { 22 | const torrent = await this.torrentDAO.findOneOrFail({ 23 | where: { resourceId, resourceType }, 24 | }); 25 | 26 | const torrentStatus = await this.transmissionService.getTorrent( 27 | torrent.torrentHash 28 | ); 29 | 30 | return { ...torrentStatus, resourceId, resourceType }; 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/components/discover/discover.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DiscoverStyles = styled.div` 4 | .wrapper { 5 | max-width: 1200px; 6 | margin: 0 auto; 7 | padding-right: 0 32px; 8 | } 9 | 10 | .flex { 11 | display: flex; 12 | justify-content: space-evenly; 13 | } 14 | 15 | .discover { 16 | &--filter { 17 | flex: 2; 18 | margin-right: 12px; 19 | } 20 | 21 | &--filter-genres { 22 | display: flex; 23 | flex-wrap: wrap; 24 | 25 | > label { 26 | width: 100%; 27 | } 28 | } 29 | 30 | &--filter-entertainment { 31 | label:first-of-type { 32 | margin-right: 32px; 33 | } 34 | } 35 | 36 | &--result { 37 | flex: 8; 38 | } 39 | 40 | &--pagination { 41 | text-align: center; 42 | margin: 20px 0; 43 | } 44 | 45 | &--result-cards-container { 46 | width: 100%; 47 | display: flex; 48 | flex-wrap: wrap; 49 | align-items: start; 50 | justify-content: space-around; 51 | 52 | > div { 53 | display: inline-block; 54 | padding-bottom: 20px; 55 | } 56 | } 57 | } 58 | `; 59 | -------------------------------------------------------------------------------- /packages/api/src/entities/dao/tvepisode.dao.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | 3 | import { DownloadableMediaState } from 'src/app.dto'; 4 | 5 | import { TVEpisode } from '../tvepisode.entity'; 6 | 7 | @EntityRepository(TVEpisode) 8 | export class TVEpisodeDAO extends Repository { 9 | public async findOrCreate(episodeAttributes: { 10 | seasonId: number; 11 | tvShowId: number; 12 | episodeNumber: number; 13 | seasonNumber: number; 14 | }) { 15 | const match = await this.findOne(episodeAttributes); 16 | return match || (await this.save(episodeAttributes)); 17 | } 18 | 19 | public findMissingFromLibrary() { 20 | return this.createQueryBuilder('episode') 21 | .leftJoinAndSelect('episode.tvShow', 'tvShow') 22 | .innerJoin('episode.season', 'season', 'season.state != :seasonState', { 23 | seasonState: DownloadableMediaState.DOWNLOADING, 24 | }) 25 | .where('episode.state = :episodeState', { 26 | episodeState: DownloadableMediaState.MISSING, 27 | }) 28 | .orderBy('episode.tvShow', 'DESC') 29 | .addOrderBy('episode.season', 'DESC') 30 | .addOrderBy('episode.episodeNumber', 'DESC') 31 | .getMany(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/components/navbar/navbar.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from 'classnames'; 3 | import Link from 'next/link'; 4 | import { useRouter } from 'next/router'; 5 | 6 | import { useGetParamsQuery } from '../../utils/graphql'; 7 | import { NavbarStyles } from './navbar.styles'; 8 | 9 | const links = [ 10 | ['Movies', '/library/movies'], 11 | ['TV Shows', '/library/tvshows'], 12 | ['Search', '/search'], 13 | ['Discover', '/discover'], 14 | ['Suggestions', '/suggestions'], 15 | ['Calendar', '/calendar'], 16 | ['Settings', '/settings'], 17 | ]; 18 | 19 | export function NavbarComponent() { 20 | const router = useRouter(); 21 | const { data } = useGetParamsQuery(); 22 | 23 | return ( 24 | 25 |
26 |
bobarr
27 |
28 | {links.map(([name, url]) => ( 29 | 30 | {name} 31 | 32 | ))} 33 |
34 |
{data?.params?.region || 'US'}
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/web/components/manual-search/manual-search.helpers.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | import { formatNumber } from '../../utils/format-number'; 4 | 5 | import { 6 | MissingTvEpisodesFragment, 7 | MissingMoviesFragment, 8 | EnrichedMovie, 9 | EnrichedTvEpisode, 10 | TmdbFormattedTvSeason, 11 | } from '../../utils/graphql'; 12 | 13 | export type Media = 14 | | MissingTvEpisodesFragment 15 | | MissingMoviesFragment 16 | | EnrichedMovie 17 | | EnrichedTvEpisode 18 | | (TmdbFormattedTvSeason & { tvShowTitle: string; tvShowTMDBId: number }); 19 | 20 | export function getDefaultSearchQuery(media: Media) { 21 | if (media.__typename === 'EnrichedTVEpisode') { 22 | const seasonNb = formatNumber(media.seasonNumber!); 23 | const episodeNb = formatNumber(media.episodeNumber!); 24 | return `${media.tvShow?.title} S${seasonNb}E${episodeNb}`; 25 | } 26 | 27 | if (media.__typename === 'EnrichedMovie') { 28 | const year = dayjs(media.releaseDate).format('YYYY'); 29 | return `${media.title} ${year}`; 30 | } 31 | 32 | if (media.__typename === 'TMDBFormattedTVSeason') { 33 | const seasonNb = formatNumber(media.seasonNumber!); 34 | return `${media.tvShowTitle} S${seasonNb}`; 35 | } 36 | 37 | return ''; 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-api.yml: -------------------------------------------------------------------------------- 1 | name: docker build and publish api 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v1 18 | 19 | - 20 | name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | 23 | - 24 | name: Login to DockerHub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | 30 | - 31 | name: Build and push api 32 | uses: docker/build-push-action@v2 33 | with: 34 | context: ./packages/api 35 | file: ./packages/api/Dockerfile 36 | push: true 37 | tags: iam4x/bobarr-api:latest 38 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 39 | cache-from: type=registry,ref=iam4x/bobarr-api:latest 40 | cache-to: type=inline 41 | 42 | - 43 | name: Image digest 44 | run: echo ${{ steps.docker_build.outputs.digest }} 45 | -------------------------------------------------------------------------------- /.github/workflows/build-and-puslish-web.yml: -------------------------------------------------------------------------------- 1 | name: docker build and publish web 2 | 3 | on: 4 | push: 5 | branches: master 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - 12 | name: Checkout 13 | uses: actions/checkout@v2 14 | 15 | - 16 | name: Set up QEMU 17 | uses: docker/setup-qemu-action@v1 18 | 19 | - 20 | name: Set up Docker Buildx 21 | uses: docker/setup-buildx-action@v1 22 | 23 | - 24 | name: Login to DockerHub 25 | uses: docker/login-action@v1 26 | with: 27 | username: ${{ secrets.DOCKERHUB_USERNAME }} 28 | password: ${{ secrets.DOCKERHUB_TOKEN }} 29 | 30 | - 31 | name: Build and push web 32 | uses: docker/build-push-action@v2 33 | with: 34 | context: ./packages/web 35 | file: ./packages/web/Dockerfile 36 | push: true 37 | tags: iam4x/bobarr-web:latest 38 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 39 | cache-from: type=registry,ref=iam4x/bobarr-web:latest 40 | cache-to: type=inline 41 | 42 | - 43 | name: Image digest 44 | run: echo ${{ steps.docker_build.outputs.digest }} 45 | -------------------------------------------------------------------------------- /packages/api/src/modules/omdb/omdb.dto.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | export interface OMDBSearchParams { 4 | t?: string; 5 | } 6 | 7 | export interface OMDBSearchResult { 8 | Title: string; 9 | Year: string; 10 | Rated: string; 11 | Released: string; 12 | Runtime: string; 13 | Genre: string; 14 | Director: string; 15 | Writer: string; 16 | Actors: string; 17 | Plot: string; 18 | Language: string; 19 | Country: string; 20 | Awards: string; 21 | Poster: string; 22 | Ratings: Array<{ 23 | Source: string; 24 | Value: string; 25 | }>; 26 | Metascore: string; 27 | imdbRating: string; 28 | imdbVotes: string; 29 | imdbID: string; 30 | Type: string; 31 | DVD: string; 32 | BoxOffice: string; 33 | Production: string; 34 | Website: string; 35 | Response: string; 36 | } 37 | 38 | @ArgsType() 39 | export class GetOMDBSearchQueries { 40 | @Field() public title!: string; 41 | } 42 | 43 | @ObjectType() 44 | export class Ratings { 45 | @Field({ nullable: true }) public IMDB?: string; 46 | @Field({ nullable: true }) public rottenTomatoes?: string; 47 | @Field({ nullable: true }) public metaCritic?: string; 48 | } 49 | 50 | @ObjectType() 51 | export class OMDBInfo { 52 | @Field() public ratings!: Ratings; 53 | } 54 | -------------------------------------------------------------------------------- /packages/web/components/downloading/downloading.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useGetDownloadingQuery } from '../../utils/graphql'; 4 | 5 | import { DownloadingComponentStyles } from './downloading.styles'; 6 | import { SearchingRowsComponent } from './searching-rows.component'; 7 | import { DownloadingRowsComponent } from './downloading-rows.component'; 8 | 9 | export function DownloadingComponent({ types }: { types: string[] }) { 10 | const { data } = useGetDownloadingQuery({ 11 | fetchPolicy: 'cache-and-network', 12 | pollInterval: 2500, 13 | }); 14 | 15 | const searching = data?.searching?.filter((row) => 16 | types.includes(row.resourceType.toLowerCase()) 17 | ); 18 | 19 | const downloading = data?.downloading?.filter((row) => 20 | types.includes(row.resourceType.toLowerCase()) 21 | ); 22 | 23 | return ( 24 | 25 |
26 | 27 | {/* dont mount downloading rows when it's not needed */} 28 | {/* this component does request polling */} 29 | {downloading && downloading.length > 0 && ( 30 | 31 | )} 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/api/src/utils/winston-options.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | function formatLog(log: Record) { 4 | const { timestamp, level, context, message, ...rest } = log; 5 | const baseLine = `${timestamp} [${level}] ${context} - ${message}`; 6 | return Object.keys(rest).length > 0 7 | ? `${baseLine} - ${JSON.stringify(rest)}` 8 | : baseLine; 9 | } 10 | 11 | const transports: any[] = [ 12 | new winston.transports.Console({ 13 | format: winston.format.combine( 14 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), 15 | winston.format.colorize(), 16 | winston.format.printf(formatLog) 17 | ), 18 | }), 19 | ]; 20 | 21 | if (process.env.ENV === 'production') { 22 | transports.push( 23 | new winston.transports.File({ 24 | filename: 'out.log', 25 | format: winston.format.combine( 26 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), 27 | winston.format.json() 28 | ), 29 | }) 30 | ); 31 | transports.push( 32 | new winston.transports.File({ 33 | level: 'error', 34 | filename: 'error.log', 35 | format: winston.format.combine( 36 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), 37 | winston.format.json() 38 | ), 39 | }) 40 | ); 41 | } 42 | 43 | export const winstonOptions = { transports }; 44 | -------------------------------------------------------------------------------- /packages/api/src/modules/image-cache/image-cache.controller.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { Response } from 'express'; 3 | 4 | import os from 'os'; 5 | import path from 'path'; 6 | import axios from 'axios'; 7 | import { promises as fs } from 'fs'; 8 | 9 | import { 10 | Controller, 11 | Get, 12 | HttpException, 13 | HttpStatus, 14 | Query, 15 | Res, 16 | } from '@nestjs/common'; 17 | 18 | @Controller('image-cache') 19 | export class ImageCacheController { 20 | @Get() 21 | public async getFromCache( 22 | @Query('i') imageUrl: string, 23 | @Res() res: Response 24 | ) { 25 | if (!imageUrl) { 26 | throw new HttpException( 27 | 'INVALID_IMAGE_URL', 28 | HttpStatus.UNPROCESSABLE_ENTITY 29 | ); 30 | } 31 | 32 | const filePath = path.join(os.tmpdir(), '/bobarr-image-cache', imageUrl); 33 | 34 | try { 35 | await fs.stat(filePath); 36 | await fs.readFile(filePath); 37 | } catch (error) { 38 | const { data: buffer } = await axios.get( 39 | `https://image.tmdb.org/t/p/${imageUrl}`, 40 | { 41 | responseType: 'arraybuffer', 42 | } 43 | ); 44 | 45 | await fs.mkdir(path.dirname(filePath), { recursive: true }); 46 | await fs.writeFile(filePath, buffer); 47 | } 48 | 49 | return res.sendFile(filePath); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/web/components/movie-details/rating-details.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | useOmdbSearchQuery, 4 | TmdbSearchResult, 5 | EnrichedMovie, 6 | } from '../../utils/graphql'; 7 | import { RatingDetailsStyles } from './rating-details.styles'; 8 | export const RatingDetailComponent = ({ 9 | entertainment, 10 | }: { 11 | entertainment: TmdbSearchResult | EnrichedMovie; 12 | }) => { 13 | const { data } = useOmdbSearchQuery({ 14 | variables: { title: entertainment.title }, 15 | }); 16 | 17 | const ratings = data?.result.ratings; 18 | 19 | const allRatings = { 20 | TMDB: `${entertainment.voteAverage * 10}%`, 21 | IMDB: ratings?.IMDB, 22 | rottenTomatoes: ratings?.rottenTomatoes, 23 | metaCritic: ratings?.metaCritic, 24 | }; 25 | 26 | return ( 27 | 28 | {Object.entries(allRatings)?.map(([key, value], index) => { 29 | const rate = value?.split(/(?=[%, /])/); 30 | 31 | if (!rate) return null; 32 | 33 | return ( 34 |
  • 35 | 36 | {rate?.[0]} 37 | {rate?.[1]} 38 |
  • 39 | ); 40 | })} 41 |
    42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /packages/api/src/modules/omdb/omdb.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import Axios from 'axios'; 3 | import { 4 | OMDBSearchResult, 5 | OMDBSearchParams, 6 | GetOMDBSearchQueries, 7 | } from './omdb.dto'; 8 | 9 | @Injectable() 10 | export class OMDBService { 11 | private async request(params: OMDBSearchParams = {}) { 12 | const client = Axios.create({ 13 | params: { apikey: '9cffdb0d' }, 14 | baseURL: 'http://www.omdbapi.com/', 15 | }); 16 | 17 | const { data } = await client.get('', { params }); 18 | 19 | return data; 20 | } 21 | 22 | public async search(args: GetOMDBSearchQueries) { 23 | const result = await this.request({ 24 | t: args.title, 25 | }); 26 | 27 | return this.mapResult(result); 28 | } 29 | 30 | private mapResult(omdbSearchResult: OMDBSearchResult) { 31 | const ratings = omdbSearchResult.Ratings?.reduce( 32 | (prev, { Source, Value }) => ({ 33 | ...prev, 34 | ...(Source === 'Metacritic' && { 35 | metaCritic: Value, 36 | }), 37 | ...(Source === 'Internet Movie Database' && { 38 | IMDB: Value, 39 | }), 40 | ...(Source === 'Rotten Tomatoes' && { 41 | rottenTomatoes: Value, 42 | }), 43 | }), 44 | {} 45 | ); 46 | 47 | return { 48 | ratings, 49 | }; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/api/src/entities/media-view.entity.ts: -------------------------------------------------------------------------------- 1 | import { ViewEntity, ViewColumn } from 'typeorm'; 2 | import { FileType, DownloadableMediaState } from 'src/app.dto'; 3 | 4 | @ViewEntity({ 5 | expression: ` 6 | SELECT 7 | 'movie-' || id::text as id, 8 | id as "resourceId", 9 | title, 10 | 'movie' as "resourceType", 11 | state 12 | FROM 13 | movie 14 | UNION ALL 15 | SELECT 16 | 'season-' || tv_season.id::text as id, 17 | tv_season.id as "resourceId", 18 | tv_show.title || ' - Season ' || "seasonNumber"::text as title, 19 | 'season' as "resourceType", 20 | tv_season.state 21 | FROM 22 | tv_season 23 | LEFT JOIN tv_show ON tv_season."tvShowId" = tv_show.id 24 | UNION ALL 25 | SELECT 26 | 'episode-' || tv_episode.id::text as id, 27 | tv_episode.id as "resourceId", 28 | tv_show.title || ' - Season ' || "seasonNumber"::text || ' - Episode ' || "episodeNumber"::text as title, 29 | 'episode' as "resourceType", 30 | tv_episode.state 31 | FROM 32 | tv_episode 33 | LEFT JOIN tv_show ON tv_episode."tvShowId" = tv_show.id 34 | `, 35 | }) 36 | export class MediaView { 37 | @ViewColumn() public id!: string; 38 | @ViewColumn() public title!: string; 39 | @ViewColumn() public resourceId!: number; 40 | @ViewColumn() public resourceType!: FileType; 41 | @ViewColumn() public state!: DownloadableMediaState; 42 | } 43 | -------------------------------------------------------------------------------- /packages/api/src/modules/library/library.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { MovieDAO } from 'src/entities/dao/movie.dao'; 5 | import { TVShowDAO } from 'src/entities/dao/tvshow.dao'; 6 | import { TVSeasonDAO } from 'src/entities/dao/tvseason.dao'; 7 | import { TVEpisodeDAO } from 'src/entities/dao/tvepisode.dao'; 8 | import { TorrentDAO } from 'src/entities/dao/torrent.dao'; 9 | import { MediaViewDAO } from 'src/entities/dao/media-view.dao'; 10 | 11 | import { TMDBModule } from 'src/modules/tmdb/tmdb.module'; 12 | import { JobsModule } from 'src/modules/jobs/jobs.module'; 13 | import { TransmissionModule } from 'src/modules/transmission/transmission.module'; 14 | import { RedisModule } from 'src/modules/redis/redis.module'; 15 | import { ParamsModule } from 'src/modules/params/params.module'; 16 | 17 | import { LibraryResolver } from './library.resolver'; 18 | import { LibraryService } from './library.service'; 19 | 20 | @Module({ 21 | imports: [ 22 | TypeOrmModule.forFeature([ 23 | MovieDAO, 24 | TVShowDAO, 25 | TVSeasonDAO, 26 | TVEpisodeDAO, 27 | TorrentDAO, 28 | MediaViewDAO, 29 | ]), 30 | TMDBModule, 31 | TransmissionModule, 32 | RedisModule, 33 | ParamsModule, 34 | forwardRef(() => JobsModule), 35 | ], 36 | providers: [LibraryResolver, LibraryService], 37 | exports: [LibraryService], 38 | }) 39 | export class LibraryModule {} 40 | -------------------------------------------------------------------------------- /packages/web/components/downloading/searching-rows.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tag } from 'antd'; 3 | import { LoadingOutlined } from '@ant-design/icons'; 4 | 5 | import { SearchingMedia, FileType } from '../../utils/graphql'; 6 | 7 | export function SearchingRowsComponent({ rows }: { rows: SearchingMedia[] }) { 8 | const searching = rows.reduce((merged: SearchingMedia[], curr) => { 9 | if (curr.resourceType === FileType.Episode) { 10 | const match = merged.find((row) => 11 | row.title 12 | .toUpperCase() 13 | .includes(curr.title.toUpperCase().replace(/ - EPISODE.+/, '')) 14 | ); 15 | 16 | if (match) { 17 | const [, episode] = 18 | /EPISODE (\d+)/.exec(curr.title.toUpperCase()) || []; 19 | 20 | return merged.map((row) => 21 | row.id === match.id 22 | ? { ...row, title: `${match.title}, ${episode}` } 23 | : row 24 | ); 25 | } 26 | } 27 | return [...merged, curr]; 28 | }, []); 29 | 30 | return ( 31 |
    32 | {searching.map((row) => ( 33 |
    34 |
    35 | 36 | Searching 37 | 38 |
    39 |
    {row.title}
    40 |
    41 | ))} 42 |
    43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /packages/web/components/movie-details/use-remove-library.hook.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, notification } from 'antd'; 3 | 4 | import { 5 | TmdbSearchResult, 6 | GetLibraryMoviesDocument, 7 | useRemoveMovieMutation, 8 | GetDownloadingDocument, 9 | GetMissingDocument, 10 | EnrichedMovie, 11 | } from '../../utils/graphql'; 12 | 13 | export function useRemoveLibrary({ 14 | result, 15 | }: { 16 | result: TmdbSearchResult | EnrichedMovie; 17 | }) { 18 | const [removeMovie] = useRemoveMovieMutation({ 19 | awaitRefetchQueries: true, 20 | refetchQueries: [ 21 | { query: GetLibraryMoviesDocument }, 22 | { query: GetDownloadingDocument }, 23 | { query: GetMissingDocument }, 24 | ], 25 | onError: ({ message }) => 26 | notification.error({ 27 | message: message.replace('GraphQL error: ', ''), 28 | placement: 'bottomRight', 29 | }), 30 | onCompleted: () => 31 | notification.success({ 32 | message: 'Movie removed from library', 33 | placement: 'bottomRight', 34 | }), 35 | }); 36 | 37 | const handleClick = () => 38 | Modal.confirm({ 39 | title: {result.title}, 40 | content: `Remove from library and delete files?`, 41 | centered: true, 42 | okText: 'Yes', 43 | cancelText: 'No', 44 | okType: 'danger', 45 | onOk: () => removeMovie({ variables: { tmdbId: result.tmdbId } }), 46 | }); 47 | 48 | return handleClick; 49 | } 50 | -------------------------------------------------------------------------------- /packages/web/components/movie-details/use-add-library.hook.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal, notification } from 'antd'; 3 | 4 | import { 5 | useTrackMovieMutation, 6 | TmdbSearchResult, 7 | GetLibraryMoviesDocument, 8 | GetDownloadingDocument, 9 | GetMissingDocument, 10 | EnrichedMovie, 11 | } from '../../utils/graphql'; 12 | 13 | export function useAddLibrary({ 14 | result, 15 | }: { 16 | result: TmdbSearchResult | EnrichedMovie; 17 | }) { 18 | const [trackMovie] = useTrackMovieMutation({ 19 | awaitRefetchQueries: true, 20 | refetchQueries: [ 21 | { query: GetLibraryMoviesDocument }, 22 | { query: GetDownloadingDocument }, 23 | { query: GetMissingDocument }, 24 | ], 25 | onError: ({ message }) => 26 | notification.error({ 27 | message: message.replace('GraphQL error: ', ''), 28 | placement: 'bottomRight', 29 | }), 30 | onCompleted: () => 31 | notification.success({ 32 | message: 'Movie sent to download', 33 | placement: 'bottomRight', 34 | }), 35 | }); 36 | 37 | const handleClick = () => 38 | Modal.confirm({ 39 | title: {result.title}, 40 | content: `Search torrent and start download ?`, 41 | centered: true, 42 | okText: 'Yes', 43 | cancelText: 'No', 44 | onOk: () => 45 | trackMovie({ 46 | variables: { title: result.title, tmdbId: result.tmdbId }, 47 | }), 48 | }); 49 | 50 | return handleClick; 51 | } 52 | -------------------------------------------------------------------------------- /packages/api/src/entities/tvseason.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | PrimaryGeneratedColumn, 4 | Column, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | ManyToOne, 8 | OneToMany, 9 | Unique, 10 | Index, 11 | } from 'typeorm'; 12 | 13 | import { DownloadableMediaState } from 'src/app.dto'; 14 | 15 | import { TVShow } from './tvshow.entity'; 16 | import { TVEpisode } from './tvepisode.entity'; 17 | import { formatNumber } from 'src/utils/format-number'; 18 | 19 | @Entity() 20 | @Unique(['seasonNumber', 'tvShow']) 21 | export class TVSeason { 22 | @PrimaryGeneratedColumn() 23 | public id!: number; 24 | 25 | @Column('int') 26 | public seasonNumber!: number; 27 | 28 | @Index() 29 | @Column('varchar', { default: DownloadableMediaState.SEARCHING }) 30 | public state: DownloadableMediaState = DownloadableMediaState.SEARCHING; 31 | 32 | @Column('int') 33 | public tvShowId!: number; 34 | 35 | @ManyToOne((_type) => TVShow, (tvshow) => tvshow.seasons, { 36 | nullable: false, 37 | onDelete: 'CASCADE', 38 | }) 39 | public tvShow!: TVShow; 40 | 41 | @OneToMany((_type) => TVEpisode, (episode) => episode.season) 42 | public episodes!: TVEpisode[]; 43 | 44 | @CreateDateColumn() 45 | public createdAt!: Date; 46 | 47 | @UpdateDateColumn() 48 | public updatedAt!: Date; 49 | 50 | public get title() { 51 | const seasonNb = formatNumber(this.seasonNumber); 52 | return this.tvShow?.title 53 | ? `${this.tvShow.title} - Season ${seasonNb}` 54 | : `Season ${seasonNb}`; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bobarr", 3 | "version": "1.0.0-beta.3", 4 | "author": "iam4x ", 5 | "license": "MIT", 6 | "scripts": { 7 | "postinstall": "(cd packages/web && yarn) && (cd packages/api && yarn)", 8 | "start": "./scripts/bobarr.sh start", 9 | "start:vpn": "./scripts/bobarr.sh start:vpn", 10 | "start:wireguard": "./scripts/bobarr.sh start:wireguard", 11 | "stop": "./scripts/bobarr.sh stop", 12 | "dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --force-recreate -d && docker-compose -f docker-compose.yml -f docker-compose.dev.yml logs --tail 20 -f api web", 13 | "dev:vpn": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.vpn.yml up --force-recreate -d && docker-compose -f docker-compose.yml -f docker-compose.dev.yml logs --tail 20 -f api web", 14 | "lint": "yarn eslint --ext .ts,.tsx packages/api packages/web" 15 | }, 16 | "devDependencies": { 17 | "@typescript-eslint/eslint-plugin": "^4.10.0", 18 | "@typescript-eslint/parser": "^4.10.0", 19 | "babel-eslint": "^10.1.0", 20 | "concurrently": "^5.3.0", 21 | "eslint": "^7.7.0", 22 | "eslint-config-algolia": "^16.0.0", 23 | "eslint-config-prettier": "^7.0.0", 24 | "eslint-plugin-eslint-comments": "^3.2.0", 25 | "eslint-plugin-import": "^2.22.0", 26 | "eslint-plugin-prettier": "^3.1.4", 27 | "eslint-plugin-react": "^7.20.6", 28 | "eslint-plugin-react-hooks": "^4.1.0", 29 | "prettier": "^2.1.1", 30 | "typescript": "^4.0.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/api/src/modules/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import { Injectable, Inject } from '@nestjs/common'; 3 | import { forEach } from 'p-iteration'; 4 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 5 | import { Logger } from 'winston'; 6 | 7 | import { REDIS_CONFIG, DEBUG_REDIS } from 'src/config'; 8 | import { CacheKeys } from './cache.dto'; 9 | 10 | @Injectable() 11 | export class RedisService { 12 | private client: Redis.Redis; 13 | 14 | public constructor(@Inject(WINSTON_MODULE_PROVIDER) private logger: Logger) { 15 | this.logger = logger.child({ context: 'RedisService' }); 16 | this.client = new Redis({ 17 | db: 0, 18 | host: REDIS_CONFIG.host, 19 | port: REDIS_CONFIG.port, 20 | password: REDIS_CONFIG.password, 21 | }); 22 | } 23 | 24 | public clearCache() { 25 | if (DEBUG_REDIS) this.logger.info('clear cache'); 26 | return forEach(Object.entries(CacheKeys), ([, value]) => 27 | this.deleteKeysPattern(value) 28 | ); 29 | } 30 | 31 | public get(key: string) { 32 | if (DEBUG_REDIS) this.logger.info('get key', { key }); 33 | return this.client.get(key); 34 | } 35 | 36 | public set(key: string, data: string, ttl: number) { 37 | if (DEBUG_REDIS) this.logger.info('set key', { key }); 38 | return this.client.set(key, data, 'PX', ttl); 39 | } 40 | 41 | public async deleteKeysPattern(key: CacheKeys) { 42 | if (DEBUG_REDIS) this.logger.info('delete key', { key }); 43 | const keys = await this.client.keys(`${key}*`); 44 | await Promise.all(keys.map((_) => this.client.del(_))); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/web/components/tmdb-card/tmdb-card.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const TMDBCardStyles = styled.div` 4 | flex-shrink: 0; 5 | position: relative; 6 | width: 220px; 7 | 8 | .poster--container { 9 | border-radius: 12px; 10 | cursor: pointer; 11 | height: 330px; 12 | margin-bottom: 24px; 13 | position: relative; 14 | overflow: hidden; 15 | width: 220px; 16 | 17 | .poster, 18 | .overlay { 19 | height: 330px; 20 | width: 220px; 21 | left: 0; 22 | top: 0; 23 | position: absolute; 24 | } 25 | 26 | .poster { 27 | background: #fecea8; 28 | } 29 | 30 | .overlay { 31 | display: flex; 32 | background: rgba(0, 0, 0, 0.8); 33 | align-items: center; 34 | justify-content: center; 35 | flex-direction: column; 36 | transition: 0.1s linear; 37 | opacity: 0; 38 | 39 | &:hover { 40 | opacity: 1; 41 | } 42 | 43 | .anticon { 44 | color: #fff; 45 | font-size: 2em; 46 | } 47 | } 48 | 49 | .action-label { 50 | color: #fff; 51 | font-size: 1em; 52 | text-transform: uppercase; 53 | font-weight: 900; 54 | font-family: monospace; 55 | margin-top: 10px; 56 | } 57 | } 58 | 59 | .name { 60 | font-weight: 700; 61 | margin-bottom: 2px; 62 | } 63 | 64 | .date { 65 | text-transform: lowercase; 66 | font-size: 0.8em; 67 | font-weight: 300; 68 | color: rgba(0, 0, 0, 0.5); 69 | } 70 | 71 | .vote--container { 72 | position: absolute; 73 | top: 310px; 74 | left: 14px; 75 | } 76 | `; 77 | -------------------------------------------------------------------------------- /packages/web/components/navbar/navbar.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const NavbarStyles = styled.div` 4 | background: ${({ theme }) => theme.colors.navbarBackground}; 5 | color: #fff; 6 | height: ${({ theme }) => theme.navbarHeight}px; 7 | position: fixed; 8 | top: 0; 9 | left: 0; 10 | z-index: 1; 11 | width: 100vw; 12 | 13 | .wrapper { 14 | align-items: center; 15 | display: flex; 16 | height: 100%; 17 | margin-left: 48px; 18 | margin-right: 48px; 19 | } 20 | 21 | .logo { 22 | font-family: monospace; 23 | font-size: 2.8em; 24 | font-weight: bold; 25 | margin-right: 72px; 26 | text-shadow: -1px -1px 2px rgba(0, 0, 0, 0.8); 27 | } 28 | 29 | .links { 30 | display: flex; 31 | 32 | a { 33 | border: 1px solid transparent; 34 | border-radius: 2px; 35 | color: #fff; 36 | cursor: pointer; 37 | display: block; 38 | margin-right: 24px; 39 | padding: 3px 5px; 40 | text-shadow: -1px -1px 2px rgba(0, 0, 0, 0.8); 41 | text-decoration: none; 42 | transition: 0.1s linear; 43 | 44 | &.active, 45 | &:hover { 46 | border-color: #fff; 47 | } 48 | 49 | &:last-child { 50 | margin-right: 0; 51 | } 52 | } 53 | } 54 | 55 | .region-select { 56 | align-items: center; 57 | border-radius: 2px; 58 | border: 1px solid #fff; 59 | cursor: pointer; 60 | display: flex; 61 | font-size: 0.9em; 62 | justify-items: center; 63 | margin-left: auto; 64 | padding: 3px 5px; 65 | transition: 0.1s linear; 66 | 67 | &:hover { 68 | background: #fff; 69 | color: ${({ theme }) => theme.colors.navbarBackground}; 70 | } 71 | } 72 | `; 73 | -------------------------------------------------------------------------------- /packages/api/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { GraphQLModule } from '@nestjs/graphql'; 4 | import { WinstonModule } from 'nest-winston'; 5 | import { TerminusModule } from '@nestjs/terminus'; 6 | 7 | import { DB_CONFIG } from './config'; 8 | import { winstonOptions } from './utils/winston-options'; 9 | 10 | import { LibraryModule } from 'src/modules/library/library.module'; 11 | import { ParamsModule } from 'src/modules/params/params.module'; 12 | import { TMDBModule } from 'src/modules/tmdb/tmdb.module'; 13 | import { JackettModule } from 'src/modules/jackett/jackett.module'; 14 | import { JobsModule } from 'src/modules/jobs/jobs.module'; 15 | import { TransmissionModule } from 'src/modules/transmission/transmission.module'; 16 | import { RedisModule } from 'src/modules/redis/redis.module'; 17 | import { HealthController } from 'src/modules/health/health.controller'; 18 | import { ImageCacheModule } from 'src/modules/image-cache/image-cache.module'; 19 | import { OMDBModule } from './modules/omdb/omdb.module'; 20 | 21 | @Module({ 22 | imports: [ 23 | WinstonModule.forRoot(winstonOptions), 24 | TypeOrmModule.forRoot(DB_CONFIG), 25 | GraphQLModule.forRoot({ 26 | autoSchemaFile: 'schema.gql', 27 | introspection: true, 28 | playground: true, 29 | bodyParserConfig: { 30 | limit: '10mb', 31 | }, 32 | }), 33 | TerminusModule, 34 | ParamsModule, 35 | LibraryModule, 36 | TMDBModule, 37 | OMDBModule, 38 | JackettModule, 39 | JobsModule, 40 | TransmissionModule, 41 | RedisModule, 42 | ImageCacheModule, 43 | ], 44 | controllers: [HealthController], 45 | providers: [], 46 | }) 47 | export class AppModule {} 48 | -------------------------------------------------------------------------------- /packages/web/components/with-apollo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { NextPage } from 'next'; 3 | 4 | import { 5 | ApolloProvider, 6 | ApolloClient, 7 | InMemoryCache, 8 | NormalizedCacheObject, 9 | HttpLink, 10 | } from '@apollo/client'; 11 | 12 | import { apiURL } from '../utils/api-url'; 13 | 14 | const isServer = typeof window === 'undefined'; 15 | let apolloClient: ApolloClient; 16 | 17 | const apolloClientCache = new InMemoryCache(); 18 | 19 | function createApolloClient() { 20 | return new ApolloClient({ 21 | ssrMode: isServer, 22 | connectToDevTools: !isServer, 23 | link: new HttpLink({ uri: `${apiURL}/graphql` }), 24 | cache: apolloClientCache, 25 | queryDeduplication: true, 26 | }); 27 | } 28 | 29 | export function initializeApollo(initialState: Record) { 30 | const _apolloClient = apolloClient ?? createApolloClient(); 31 | 32 | // If your page has Next.js data fetching methods that use Apollo Client, the initial state 33 | // gets hydrated here 34 | if (initialState) { 35 | _apolloClient.cache.restore(initialState); 36 | } 37 | // For SSG and SSR always create a new Apollo Client 38 | if (typeof window === 'undefined') return _apolloClient; 39 | // Create the Apollo Client once in the client 40 | if (!apolloClient) apolloClient = _apolloClient; 41 | 42 | return _apolloClient; 43 | } 44 | 45 | export function withApollo(Component: NextPage) { 46 | return (props: any) => { 47 | const pageApolloClient = useMemo( 48 | () => initializeApollo(props.initialApolloState), 49 | [props.initialApolloState] 50 | ); 51 | return ( 52 | 53 | 54 | 55 | ); 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /packages/web/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'pure-react-carousel/dist/react-carousel.es.css'; 2 | import 'antd/dist/antd.css'; 3 | 4 | import App from 'next/app'; 5 | import React from 'react'; 6 | import { ThemeProvider, createGlobalStyle } from 'styled-components'; 7 | import { Reset } from 'styled-reset'; 8 | 9 | import { theme } from '../components/theme'; 10 | import { loadFonts } from '../components/fonts'; 11 | 12 | const GlobalStyles = createGlobalStyle` 13 | html { 14 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 15 | Helvetica, Arial, sans-serif, 'Apple Color Emoji', 16 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 17 | 18 | &.source-sans-pro { 19 | font-family: 'Source Sans Pro', sans-serif; 20 | } 21 | } 22 | 23 | *, 24 | *:before, 25 | *:after { 26 | box-sizing: border-box; 27 | } 28 | 29 | .icon-spin { 30 | -webkit-animation: icon-spin 2s infinite linear; 31 | animation: icon-spin 2s infinite linear; 32 | } 33 | 34 | @-webkit-keyframes icon-spin { 35 | 0% { 36 | -webkit-transform: rotate(0deg); 37 | transform: rotate(0deg); 38 | } 39 | 100% { 40 | -webkit-transform: rotate(359deg); 41 | transform: rotate(359deg); 42 | } 43 | } 44 | 45 | @keyframes icon-spin { 46 | 0% { 47 | -webkit-transform: rotate(0deg); 48 | transform: rotate(0deg); 49 | } 50 | 100% { 51 | -webkit-transform: rotate(359deg); 52 | transform: rotate(359deg); 53 | } 54 | } 55 | `; 56 | 57 | export default class MyApp extends App { 58 | public componentDidMount() { 59 | loadFonts(); 60 | } 61 | 62 | public render() { 63 | const { Component, pageProps } = this.props; 64 | return ( 65 | 66 | <> 67 | 68 | 69 | 70 | 71 | 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0-beta.3", 4 | "private": true, 5 | "scripts": { 6 | "dev": "yarn && yarn concurrently yarn:watch-*", 7 | "watch-next": "next dev", 8 | "watch-gql": "gql-gen --watch", 9 | "build": "next build", 10 | "start": "next start", 11 | "gql-gen": "graphql-codegen --config codegen.yml" 12 | }, 13 | "dependencies": { 14 | "@ant-design/icons": "^4.0.5", 15 | "@apollo/client": "^3.1.3", 16 | "antd": "^4.0.0", 17 | "classnames": "^2.2.6", 18 | "dayjs": "^1.8.23", 19 | "fontfaceobserver": "^2.1.0", 20 | "graphql": "^15.3.0", 21 | "graphql-tag": "^2.10.3", 22 | "isomorphic-unfetch": "^3.0.0", 23 | "lodash": "^4.17.19", 24 | "next": "^9.5.2", 25 | "prettysize": "^2.0.0", 26 | "pure-react-carousel": "^1.27.0", 27 | "react": "16.13.1", 28 | "react-beautiful-dnd": "^13.0.0", 29 | "react-dom": "16.13.1", 30 | "react-icons": "^3.9.0", 31 | "react-masonry-component": "^6.2.1", 32 | "react-modal": "^3.11.2", 33 | "styled-components": "^5.0.1", 34 | "styled-reset": "^4.1.2", 35 | "throttle-debounce": "^2.1.0", 36 | "yarn": "^1.22.4" 37 | }, 38 | "devDependencies": { 39 | "@graphql-codegen/add": "^2.0.2", 40 | "@graphql-codegen/cli": "^1.13.1", 41 | "@graphql-codegen/fragment-matcher": "2.0.1", 42 | "@graphql-codegen/introspection": "1.18.1", 43 | "@graphql-codegen/typescript": "1.19.0", 44 | "@graphql-codegen/typescript-operations": "1.17.12", 45 | "@graphql-codegen/typescript-react-apollo": "2.2.1", 46 | "@types/classnames": "^2.2.10", 47 | "@types/fontfaceobserver": "^0.0.6", 48 | "@types/lodash": "^4.14.149", 49 | "@types/node": "^14.14.14", 50 | "@types/react": "^17.0.0", 51 | "@types/react-beautiful-dnd": "^13.0.0", 52 | "@types/styled-components": "^5.1.7", 53 | "@types/throttle-debounce": "^2.1.0", 54 | "babel-plugin-styled-components": "^1.10.7", 55 | "concurrently": "^5.1.0", 56 | "typescript": "^4.0.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/web/components/movies/movies.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mansonry from 'react-masonry-component'; 3 | import { Skeleton, Empty } from 'antd'; 4 | 5 | import { useGetLibraryMoviesQuery, EnrichedMovie } from '../../utils/graphql'; 6 | 7 | import { LibraryHeaderComponent } from '../library-header/library-header.component'; 8 | import { TMDBCardComponent } from '../tmdb-card/tmdb-card.component'; 9 | 10 | import { MoviesComponentStyles } from './movies.styles'; 11 | import { useSortable } from '../sortable/sortable.component'; 12 | 13 | const sortAttributes = [ 14 | { label: 'Name', key: 'title' }, 15 | { label: 'Release date', key: 'releaseDate' }, 16 | { label: 'Score', key: 'voteAverage' }, 17 | { label: 'Added at', key: 'createdAt' }, 18 | ]; 19 | 20 | export function MoviesComponent() { 21 | const { data, loading } = useGetLibraryMoviesQuery(); 22 | const { renderSortable, results } = useSortable({ 23 | sortAttributes, 24 | searchableAttributes: ['title', 'originalTitle', 'releaseDate'], 25 | rows: data?.movies, 26 | }); 27 | 28 | return ( 29 | <> 30 | 31 | 32 |
    33 | 34 | {data?.movies.length === 0 ? ( 35 | 36 | ) : ( 37 | <> 38 | {renderSortable()} 39 | 40 | {results.map((movie) => ( 41 |
    42 | 47 |
    48 | ))} 49 |
    50 | 51 | )} 52 |
    53 |
    54 |
    55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /packages/api/src/entities/tvepisode.entity.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | 3 | import { 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | Column, 7 | CreateDateColumn, 8 | UpdateDateColumn, 9 | ManyToOne, 10 | Unique, 11 | Index, 12 | OneToMany, 13 | } from 'typeorm'; 14 | 15 | import { DownloadableMediaState } from 'src/app.dto'; 16 | import { formatNumber } from 'src/utils/format-number'; 17 | 18 | import { TVSeason } from './tvseason.entity'; 19 | import { TVShow } from './tvshow.entity'; 20 | import { File } from './file.entity'; 21 | 22 | @Entity() 23 | @Unique(['episodeNumber', 'seasonNumber', 'tvShow']) 24 | @ObjectType() 25 | export class TVEpisode { 26 | @Field() 27 | @PrimaryGeneratedColumn() 28 | public id!: number; 29 | 30 | @Field() 31 | @Column('int') 32 | public episodeNumber!: number; 33 | 34 | @Field() 35 | @Column('int') 36 | public seasonNumber!: number; 37 | 38 | @Field((_type) => DownloadableMediaState) 39 | @Index() 40 | @Column('varchar', { default: DownloadableMediaState.SEARCHING }) 41 | public state: DownloadableMediaState = DownloadableMediaState.SEARCHING; 42 | 43 | @Column('int') 44 | public seasonId!: number; 45 | 46 | @ManyToOne((_type) => TVSeason, (season) => season.episodes, { 47 | nullable: false, 48 | onDelete: 'CASCADE', 49 | }) 50 | public season!: TVSeason; 51 | 52 | @Column('int') 53 | public tvShowId!: number; 54 | 55 | @Field((_type) => TVShow) 56 | @ManyToOne((_type) => TVShow, (tvshow) => tvshow.episodes, { 57 | nullable: false, 58 | onDelete: 'CASCADE', 59 | }) 60 | public tvShow!: TVShow; 61 | 62 | @OneToMany((_type) => File, (file) => file.tvEpisode) 63 | public files!: File[]; 64 | 65 | @Field() 66 | @CreateDateColumn() 67 | public createdAt!: Date; 68 | 69 | @Field() 70 | @UpdateDateColumn() 71 | public updatedAt!: Date; 72 | 73 | public get title() { 74 | const episodeNb = formatNumber(this.episodeNumber); 75 | return this.season?.title 76 | ? `${this.season.title} - Episode ${episodeNb}` 77 | : `Episode ${episodeNb}`; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/web/components/tvshow-details/use-get-seasons.hook.tsx: -------------------------------------------------------------------------------- 1 | import { orderBy } from 'lodash'; 2 | import { notification } from 'antd'; 3 | 4 | import { 5 | useGetTvShowSeasonsQuery, 6 | useTrackTvShowMutation, 7 | useRemoveTvShowMutation, 8 | GetLibraryTvShowsDocument, 9 | GetTvShowSeasonsDocument, 10 | GetDownloadingDocument, 11 | } from '../../utils/graphql'; 12 | 13 | export function useGetSeasons({ tmdbId }: { tmdbId: number }) { 14 | const { data, loading } = useGetTvShowSeasonsQuery({ 15 | variables: { tvShowTMDBId: tmdbId }, 16 | }); 17 | 18 | const [trackTVShow, { loading: mutationLoading }] = useTrackTvShowMutation({ 19 | awaitRefetchQueries: true, 20 | refetchQueries: [ 21 | { query: GetDownloadingDocument }, 22 | { query: GetLibraryTvShowsDocument }, 23 | { 24 | query: GetTvShowSeasonsDocument, 25 | variables: { tvShowTMDBId: tmdbId }, 26 | }, 27 | ], 28 | onError: ({ message }) => 29 | notification.error({ 30 | message: message.replace('GraphQL error: ', ''), 31 | placement: 'bottomRight', 32 | }), 33 | onCompleted: () => 34 | notification.success({ 35 | message: 'Episodes sent to download', 36 | placement: 'bottomRight', 37 | }), 38 | }); 39 | 40 | const [removeTVShow] = useRemoveTvShowMutation({ 41 | awaitRefetchQueries: true, 42 | refetchQueries: [ 43 | { query: GetDownloadingDocument }, 44 | { query: GetLibraryTvShowsDocument }, 45 | { 46 | query: GetTvShowSeasonsDocument, 47 | variables: { tvShowTMDBId: tmdbId }, 48 | }, 49 | ], 50 | onError: ({ message }) => 51 | notification.error({ 52 | message: message.replace('GraphQL error: ', ''), 53 | placement: 'bottomRight', 54 | }), 55 | onCompleted: () => 56 | notification.success({ 57 | message: 'TVShow removed from library', 58 | placement: 'bottomRight', 59 | }), 60 | }); 61 | 62 | const seasons = orderBy(data?.seasons, ['seasonNumber'], ['desc']).filter( 63 | (season) => season.seasonNumber !== 0 64 | ); 65 | 66 | return { seasons, loading, trackTVShow, mutationLoading, removeTVShow }; 67 | } 68 | -------------------------------------------------------------------------------- /packages/web/components/tvshows/tvshows.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mansonry from 'react-masonry-component'; 3 | import styled from 'styled-components'; 4 | import { Skeleton, Empty } from 'antd'; 5 | 6 | import { useGetLibraryTvShowsQuery, EnrichedTvShow } from '../../utils/graphql'; 7 | 8 | import { LibraryHeaderComponent } from '../library-header/library-header.component'; 9 | import { TMDBCardComponent } from '../tmdb-card/tmdb-card.component'; 10 | 11 | import { MoviesComponentStyles } from '../movies/movies.styles'; 12 | import { useSortable } from '../sortable/sortable.component'; 13 | 14 | const TVShowsComponentStyles = styled(MoviesComponentStyles)``; 15 | 16 | const sortAttributes = [ 17 | { label: 'Name', key: 'title' }, 18 | { label: 'First aired', key: 'releaseDate' }, 19 | { label: 'Score', key: 'voteAverage' }, 20 | { label: 'Added at', key: 'createdAt' }, 21 | ]; 22 | 23 | export function TVShowsComponent() { 24 | const { data, loading } = useGetLibraryTvShowsQuery(); 25 | const { renderSortable, results } = useSortable({ 26 | sortAttributes, 27 | searchableAttributes: ['title', 'originalTitle', 'releaseDate'], 28 | rows: data?.tvShows, 29 | }); 30 | 31 | return ( 32 | <> 33 | 34 | 35 |
    36 | 37 | {data?.tvShows.length === 0 ? ( 38 | 39 | ) : ( 40 | <> 41 | {renderSortable()} 42 | 43 | {results.map((tvShow) => ( 44 |
    45 | 50 |
    51 | ))} 52 |
    53 | 54 | )} 55 |
    56 |
    57 |
    58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /scripts/bobarr.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # exit when error 3 | 4 | cat << "EOF" 5 | 6 | /$$ /$$ 7 | | $$ | $$ 8 | | $$$$$$$ /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ 9 | | $$__ $$ /$$__ $$| $$__ $$ |____ $$ /$$__ $$ /$$__ $$ 10 | | $$ \ $$| $$ \ $$| $$ \ $$ /$$$$$$$| $$ \__/| $$ \__/ 11 | | $$ | $$| $$ | $$| $$ | $$ /$$__ $$| $$ | $$ 12 | | $$$$$$$/| $$$$$$/| $$$$$$$/| $$$$$$$| $$ | $$ 13 | |_______/ \______/ |_______/ \_______/|__/ |__/ 14 | 15 | https://github.com/iam4x/bobarr 16 | 17 | EOF 18 | 19 | if docker compose > /dev/null 2>&1; then 20 | if docker compose version --short | grep "^2." > /dev/null 2>&1; then 21 | COMPOSE_VERSION='docker compose' 22 | fi 23 | elif docker-compose > /dev/null 2>&1; then 24 | if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then 25 | if docker-compose version --short | grep "^2." > /dev/null 2>&1; then 26 | COMPOSE_VERSION='docker-compose' 27 | fi 28 | fi 29 | fi 30 | 31 | args=$1 32 | 33 | stop_bobarr() { 34 | $COMPOSE_VERSION down --remove-orphans || true 35 | } 36 | 37 | after_start() { 38 | echo "" 39 | echo "bobarr started correctly, printing bobarr api logs" 40 | echo "you can close this and bobarr will continue to run in backgound" 41 | echo "" 42 | $COMPOSE_VERSION logs -f api 43 | } 44 | 45 | if [[ $args == 'start' ]]; then 46 | stop_bobarr 47 | $COMPOSE_VERSION up --force-recreate -d 48 | after_start 49 | elif [[ $args == 'start:vpn' ]]; then 50 | stop_bobarr 51 | $COMPOSE_VERSION -f docker-compose.yml -f docker-compose.vpn.yml up --force-recreate -d 52 | after_start 53 | elif [[ $args == 'start:wireguard' ]]; then 54 | stop_bobarr 55 | $COMPOSE_VERSION -f docker-compose.yml -f docker-compose.wireguard.yml up --force-recreate -d 56 | after_start 57 | elif [[ $args == 'stop' ]]; then 58 | stop_bobarr 59 | echo "" 60 | echo "bobarr correctly stopped" 61 | elif [[ $args == 'update' ]]; then 62 | $COMPOSE_VERSION pull 63 | echo "" 64 | echo "bobarr docker images correctly updated, you can now re-start bobarr" 65 | else 66 | echo "unknow command: $args" 67 | echo "use [start | start:vpn | start:wireguard | stop | update]" 68 | fi 69 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | volumes: 4 | web_build: 5 | api_build: 6 | api_node_modules: 7 | web_node_modules: 8 | db_data: 9 | redis_data: 10 | 11 | networks: 12 | default: 13 | 14 | services: 15 | api: 16 | container_name: bobarr-api 17 | env_file: .env 18 | image: iam4x/bobarr-api:latest 19 | command: yarn start:prod 20 | restart: unless-stopped 21 | volumes: 22 | - ./library:/usr/library 23 | ports: 24 | - 4000:4000 25 | 26 | web: 27 | container_name: bobarr-web 28 | env_file: .env 29 | image: iam4x/bobarr-web:latest 30 | command: yarn start 31 | restart: unless-stopped 32 | ports: 33 | - 3000:3000 34 | 35 | postgres: 36 | container_name: bobarr-postgresql 37 | image: postgres:12-alpine 38 | env_file: .env 39 | restart: unless-stopped 40 | volumes: 41 | - db_data:/var/lib/postgresql/data 42 | 43 | redis: 44 | container_name: bobarr-redis 45 | image: bitnami/redis:5.0.6 46 | env_file: .env 47 | restart: unless-stopped 48 | volumes: 49 | - redis_data:/bitnami/redis/data 50 | 51 | jackett: 52 | image: linuxserver/jackett 53 | container_name: bobarr-jacket 54 | env_file: .env 55 | restart: unless-stopped 56 | volumes: 57 | - ./packages/jackett/config:/config 58 | - ./packages/jackett/downloads:/downloads 59 | ports: 60 | - 9117:9117 61 | 62 | flaresolverr: 63 | image: ghcr.io/flaresolverr/flaresolverr:latest 64 | container_name: bobarr-flaresolverr 65 | environment: 66 | - LOG_LEVEL=info 67 | restart: unless-stopped 68 | ports: 69 | - 8191:8191 70 | 71 | transmission: 72 | image: linuxserver/transmission 73 | container_name: bobarr-transmission 74 | env_file: .env 75 | restart: unless-stopped 76 | volumes: 77 | - ./library/downloads:/downloads 78 | - ./packages/transmission/config:/config 79 | - ./packages/transmission/watch:/watch 80 | 81 | transmission-web: 82 | image: dperson/nginx 83 | container_name: bobarr-transmission-web 84 | depends_on: 85 | - transmission 86 | environment: 87 | - TZ=Europe/Paris 88 | ports: 89 | - "9091:80" 90 | command: -w "http://transmission:9091;/" 91 | restart: unless-stopped 92 | -------------------------------------------------------------------------------- /packages/api/src/modules/jobs/jobs.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { BullModule } from '@nestjs/bull'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { REDIS_CONFIG } from 'src/config'; 6 | import { JobsQueue } from 'src/app.dto'; 7 | 8 | import { MovieDAO } from 'src/entities/dao/movie.dao'; 9 | import { TorrentDAO } from 'src/entities/dao/torrent.dao'; 10 | import { TVSeasonDAO } from 'src/entities/dao/tvseason.dao'; 11 | import { TVEpisodeDAO } from 'src/entities/dao/tvepisode.dao'; 12 | import { FileDAO } from 'src/entities/dao/file.dao'; 13 | 14 | import { JackettModule } from 'src/modules/jackett/jackett.module'; 15 | import { LibraryModule } from 'src/modules/library/library.module'; 16 | import { TransmissionModule } from 'src/modules/transmission/transmission.module'; 17 | import { TMDBModule } from 'src/modules/tmdb/tmdb.module'; 18 | import { ParamsModule } from 'src/modules/params/params.module'; 19 | 20 | import { DownloadProcessor } from './processors/download.processor'; 21 | import { RefreshTorrentProcessor } from './processors/refresh-torrent.processor'; 22 | import { OrganizeProcessor } from './processors/organize.processor'; 23 | import { ScanLibraryProcessor } from './processors/scan-library.processor'; 24 | 25 | import { JobsService } from './jobs.service'; 26 | import { JobsResolver } from './jobs.resolver'; 27 | 28 | const queues = [ 29 | JobsQueue.REFRESH_TORRENT, 30 | JobsQueue.DOWNLOAD, 31 | JobsQueue.RENAME_AND_LINK, 32 | JobsQueue.SCAN_LIBRARY, 33 | ].map((name) => ({ 34 | name, 35 | redis: REDIS_CONFIG, 36 | defaultJobOptions: { removeOnFail: 100, removeOnComplete: 100 }, 37 | })); 38 | 39 | @Module({ 40 | imports: [ 41 | TypeOrmModule.forFeature([ 42 | MovieDAO, 43 | TorrentDAO, 44 | TVSeasonDAO, 45 | TVEpisodeDAO, 46 | FileDAO, 47 | ]), 48 | BullModule.registerQueue(...queues), 49 | JackettModule, 50 | TransmissionModule, 51 | TMDBModule, 52 | ParamsModule, 53 | forwardRef(() => LibraryModule), 54 | ], 55 | providers: [ 56 | DownloadProcessor, 57 | RefreshTorrentProcessor, 58 | OrganizeProcessor, 59 | ScanLibraryProcessor, 60 | JobsService, 61 | JobsResolver, 62 | ], 63 | exports: [JobsService], 64 | }) 65 | export class JobsModule {} 66 | -------------------------------------------------------------------------------- /packages/web/components/calandar/calendar.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Calendar, Tag, Skeleton, Alert } from 'antd'; 3 | 4 | import { useGetCalendarQuery } from '../../utils/graphql'; 5 | import { formatNumber } from '../../utils/format-number'; 6 | 7 | import { CalendarStyles } from './calendar.styles'; 8 | 9 | export function CalendarComponent() { 10 | const { data, loading, error } = useGetCalendarQuery(); 11 | 12 | return ( 13 | 14 |
    15 | {error && ( 16 | {JSON.stringify(error, null, 4)}} 19 | /> 20 | )} 21 | {loading && ( 22 | 26 | )} 27 | 28 | { 30 | const formattedDate = date.format('YYYY-MM-DD'); 31 | 32 | const movies = 33 | data?.calendar?.movies?.filter( 34 | (movie) => formattedDate === movie.releaseDate 35 | ) || []; 36 | 37 | const tvEpisodes = 38 | data?.calendar?.tvEpisodes.filter( 39 | (tvEpisode) => formattedDate === tvEpisode.releaseDate 40 | ) || []; 41 | 42 | return ( 43 |
    44 | {[...movies, ...tvEpisodes].map((media) => ( 45 | 49 | {media.__typename === 'EnrichedMovie' && media.title} 50 | {media.__typename === 'EnrichedTVEpisode' && 51 | `${media.tvShow.title} - S${formatNumber( 52 | media.seasonNumber 53 | )}E${formatNumber(media.episodeNumber)}`} 54 | 55 | ))} 56 |
    57 | ); 58 | }} 59 | /> 60 |
    61 |
    62 |
    63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /packages/api/src/modules/tmdb/tmdb.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Resolver, Args, Query, Int } from '@nestjs/graphql'; 2 | import { UseInterceptors } from '@nestjs/common'; 3 | 4 | import { makeCacheInterceptor } from 'src/modules/redis/cache.interceptor'; 5 | import { CacheKeys } from 'src/modules/redis/cache.dto'; 6 | 7 | import { TMDBService } from './tmdb.service'; 8 | 9 | import { 10 | TMDBSearchResults, 11 | TMDBFormattedTVSeason, 12 | TMDBSearchResult, 13 | TMDBLanguagesResult, 14 | TMDBGenresResults, 15 | GetDiscoverQueries, 16 | TMDBPaginatedResult, 17 | } from './tmdb.dto'; 18 | 19 | @Resolver() 20 | export class TMDBResolver { 21 | public constructor(private readonly tmdbService: TMDBService) {} 22 | 23 | @Query((_returns) => TMDBSearchResults) 24 | public search(@Args('query') query: string) { 25 | return this.tmdbService.search(query); 26 | } 27 | 28 | @Query((_returns) => TMDBSearchResults) 29 | public getPopular() { 30 | return this.tmdbService.getPopular(); 31 | } 32 | 33 | @Query((_returns) => [TMDBFormattedTVSeason]) 34 | public getTVShowSeasons( 35 | @Args('tvShowTMDBId', { type: () => Int }) tmdbId: number 36 | ) { 37 | return this.tmdbService.getTVShowSeasons(tmdbId); 38 | } 39 | 40 | @UseInterceptors( 41 | makeCacheInterceptor({ 42 | key: CacheKeys.RECOMMENDED_TV_SHOWS, 43 | ttl: 1000 * 60 * 60 * 6, // cache for 30 minutes 44 | }) 45 | ) 46 | @Query((_returns) => [TMDBSearchResult]) 47 | public getRecommendedTVShows() { 48 | return this.tmdbService.getRecommended('tvshow'); 49 | } 50 | 51 | @UseInterceptors( 52 | makeCacheInterceptor({ 53 | key: CacheKeys.RECOMMENDED_MOVIES, 54 | ttl: 1000 * 60 * 60 * 6, // cache for 30 minutes 55 | }) 56 | ) 57 | @Query((_returns) => [TMDBSearchResult]) 58 | public getRecommendedMovies() { 59 | return this.tmdbService.getRecommended('movie'); 60 | } 61 | 62 | @Query((_returns) => TMDBPaginatedResult) 63 | public discover(@Args() args: GetDiscoverQueries) { 64 | return this.tmdbService.discover(args); 65 | } 66 | 67 | @Query((_returns) => [TMDBLanguagesResult]) 68 | public getLanguages() { 69 | return this.tmdbService.getLanguages(); 70 | } 71 | 72 | @Query((_returns) => TMDBGenresResults) 73 | public getGenres() { 74 | return this.tmdbService.getGenres(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /packages/web/components/tvshow-details/tvshow-details.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { MovieDetailsStyles } from '../movie-details/movie-details.styles'; 3 | 4 | export const TVShowSeasonsModalComponentStyles = styled(MovieDetailsStyles)` 5 | .seasons { 6 | display: flex; 7 | flex-wrap: wrap; 8 | width: 100%; 9 | } 10 | 11 | .season-row { 12 | align-items: center; 13 | border: 1px solid rgba(255, 255, 255, 0.3); 14 | border-radius: 4px; 15 | cursor: pointer; 16 | display: flex; 17 | margin-bottom: 8px; 18 | margin-right: 4px; 19 | margin-left: 4px; 20 | padding: 8px 10px; 21 | transition: 0.1s linear; 22 | max-width: 145px; 23 | 24 | &.selected { 25 | border-color: #fff; 26 | } 27 | 28 | &.in-library { 29 | cursor: not-allowed; 30 | border-color: #fff; 31 | } 32 | } 33 | 34 | .season-number { 35 | font-size: 1.1em; 36 | font-weight: 600; 37 | } 38 | 39 | .season-episodes-count { 40 | font-size: 0.9em; 41 | } 42 | 43 | .seasons-details { 44 | padding-top: 12px; 45 | 46 | .season-top { 47 | margin-bottom: 4px; 48 | } 49 | 50 | .season-title, 51 | .season-top { 52 | display: flex; 53 | align-items: center; 54 | } 55 | 56 | .season-title, 57 | .season-replace { 58 | cursor: pointer; 59 | } 60 | 61 | .season-replace { 62 | font-weight: bold; 63 | display: flex; 64 | align-items: center; 65 | margin-left: 32px; 66 | border: 1px solid #fff5; 67 | border-radius: 5px; 68 | padding: 0 4px; 69 | } 70 | 71 | .season-number { 72 | font-size: 1.25em; 73 | font-weight: 600; 74 | margin-right: 8px; 75 | } 76 | 77 | .season-year { 78 | font-size: 1em; 79 | font-weight: 300; 80 | } 81 | 82 | .season-toggle { 83 | margin-right: 12px; 84 | margin-top: 4px; 85 | } 86 | 87 | .ant-table { 88 | color: #fff; 89 | background: rgba(0, 0, 0, 0.4); 90 | border-radius: 4px; 91 | 92 | tr :hover > td { 93 | background: inherit; 94 | } 95 | 96 | tr > td { 97 | border: none; 98 | } 99 | } 100 | } 101 | `; 102 | -------------------------------------------------------------------------------- /packages/web/components/suggestions/suggestions.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Skeleton, Empty } from 'antd'; 3 | 4 | import { CarouselComponent } from '../search/carousel.component'; 5 | import { SearchStyles, Wrapper } from '../search/search.styles'; 6 | 7 | import { useGetRecommendedQuery } from '../../utils/graphql'; 8 | 9 | export function SuggestionsComponent() { 10 | const { data, loading } = useGetRecommendedQuery({ 11 | fetchPolicy: 'cache-and-network', 12 | }); 13 | 14 | const hasRecommendations = Boolean( 15 | data?.movies?.length || data?.tvShows?.length 16 | ); 17 | 18 | const isLoading = !hasRecommendations && loading; 19 | 20 | return ( 21 | 22 |
    23 | 24 |
    What are we watching next?
    25 |
    26 | Recommendations based on your library... 27 |
    28 |
    29 |
    30 | 31 | 32 |
    33 | {isLoading && } 34 | {!hasRecommendations && !isLoading && } 35 | {hasRecommendations && !isLoading && ( 36 | <> 37 | {Boolean(data?.movies?.length) && ( 38 | <> 39 |
    40 | Recommended Movies 41 |
    42 | {data?.movies && data.movies.length === 0 && } 43 | 47 | 48 | )} 49 | {Boolean(data?.tvShows?.length) && ( 50 | <> 51 |
    52 | Recommended TV Shows 53 |
    54 | 58 | 59 | )} 60 | 61 | )} 62 |
    63 |
    64 |
    65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /packages/api/src/modules/jackett/jackett.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field } from '@nestjs/graphql'; 2 | import BigInt from 'graphql-bigint'; 3 | 4 | export interface JackettResult { 5 | FirstSeen: string; 6 | Tracker: string; 7 | TrackerId: string; 8 | CategoryDesc: string; 9 | Title: string; 10 | Guid: string; 11 | Link?: string; 12 | Comments: string; 13 | PublishDate: string; 14 | Category: number[]; 15 | Size: number; 16 | Grabs: number; 17 | Seeders: number; 18 | Peers: number; 19 | MagnetUri?: string; 20 | MinimumRatio: number; 21 | MinimumSeedTime: number; 22 | DownloadVolumeFactor: number; 23 | UploadVolumeFactor: number; 24 | } 25 | 26 | @ObjectType() 27 | export class JackettFormattedResult { 28 | @Field() public id!: string; 29 | @Field() public title!: string; 30 | @Field() public quality!: string; 31 | @Field() public qualityScore!: number; 32 | @Field() public seeders!: number; 33 | @Field() public peers!: number; 34 | @Field() public link!: string; 35 | @Field() public downloadLink!: string; 36 | @Field() public tag!: string; 37 | @Field() public tagScore!: number; 38 | @Field() public publishDate!: string; 39 | @Field() public normalizedTitle!: string; 40 | @Field((_type) => [String]) public normalizedTitleParts!: string[]; 41 | @Field((_type) => BigInt) public size!: BigInt; 42 | } 43 | 44 | export interface JackettIndexer { 45 | id: string; 46 | configured: boolean; 47 | title: string; 48 | description: string; 49 | link: string; 50 | language: string; 51 | type: string; 52 | caps: { 53 | server: { 54 | title: string; 55 | }; 56 | searching: { 57 | search: { 58 | available: 'yes' | 'no'; 59 | supportedParams: string; 60 | }; 61 | tv: { 62 | available: 'yes' | 'no'; 63 | supportedParams: string; 64 | }; 65 | movie: { 66 | available: 'yes' | 'no'; 67 | supportedParams: string; 68 | }; 69 | music: { 70 | available: 'yes' | 'no'; 71 | supportedParams: ''; 72 | }; 73 | audio: { 74 | available: 'yes' | 'no'; 75 | supportedParams: string; 76 | }; 77 | }; 78 | categories: { 79 | category: Array<{ 80 | id: string; 81 | name: string; 82 | subcat?: Array<{ id: string; name: string }>; 83 | }>; 84 | }; 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /packages/api/src/modules/redis/cache.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | mixin, 5 | NestInterceptor, 6 | CallHandler, 7 | } from '@nestjs/common'; 8 | 9 | import { of } from 'rxjs'; 10 | import { map } from 'rxjs/operators'; 11 | 12 | import { RedisService } from './redis.service'; 13 | import { CacheKeys } from './cache.dto'; 14 | 15 | export function CacheMethod({ key, ttl }: { key: CacheKeys; ttl: number }) { 16 | return function ( 17 | _target: Record, 18 | _propertyKey: string, 19 | descriptor: PropertyDescriptor 20 | ) { 21 | const method = descriptor.value; 22 | 23 | // eslint-disable-next-line no-param-reassign 24 | descriptor.value = async function (...args: any[]) { 25 | const computedCacheKey = `${key}_${ 26 | args.length ? args.map((arg) => JSON.stringify(arg)) : 'no_args' 27 | }`; 28 | 29 | const cachedResult = await (this as any).redisService.get( 30 | computedCacheKey 31 | ); 32 | 33 | if (cachedResult) return JSON.parse(cachedResult); 34 | 35 | const result = await method.apply(this, args); 36 | await (this as any).redisService.set( 37 | computedCacheKey, 38 | JSON.stringify(result), 39 | ttl 40 | ); 41 | 42 | return result; 43 | }; 44 | }; 45 | } 46 | 47 | @Injectable() 48 | export abstract class CacheInterceptor implements NestInterceptor { 49 | protected abstract readonly ttl: number; 50 | protected abstract readonly key: CacheKeys; 51 | 52 | public constructor(private readonly redisService: RedisService) {} 53 | 54 | public async intercept(context: ExecutionContext, next: CallHandler) { 55 | const [, params] = context.getArgs(); 56 | const key = `${this.key}_${JSON.stringify(params)}`; 57 | 58 | const cachedResult = await this.redisService.get(key); 59 | if (cachedResult) return of(JSON.parse(cachedResult)); 60 | 61 | return next.handle().pipe( 62 | map((data) => { 63 | this.redisService.set(key, JSON.stringify(data), this.ttl); 64 | return data; 65 | }) 66 | ); 67 | } 68 | } 69 | 70 | export const makeCacheInterceptor = ({ 71 | key, 72 | ttl, 73 | }: { 74 | key: CacheKeys; 75 | ttl: number; 76 | }) => 77 | mixin( 78 | class extends CacheInterceptor { 79 | protected readonly ttl = ttl; 80 | protected readonly key = key; 81 | } 82 | ); 83 | -------------------------------------------------------------------------------- /packages/api/src/app.dto.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType, Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | export enum FileType { 4 | EPISODE = 'episode', 5 | SEASON = 'season', 6 | MOVIE = 'movie', 7 | } 8 | 9 | export enum JobName { 10 | DOWNLOAD_MOVIE = 'download_movie', 11 | DOWNLOAD_EPISODE = 'download_episode', 12 | DOWNLOAD_SEASON = 'download_season', 13 | } 14 | 15 | export enum DownloadableMediaState { 16 | SEARCHING = 'searching', 17 | MISSING = 'missing', 18 | DOWNLOADING = 'downloading', 19 | DOWNLOADED = 'downloaded', 20 | PROCESSED = 'processed', 21 | } 22 | 23 | export enum ParameterKey { 24 | REGION = 'region', 25 | LANGUAGE = 'language', 26 | TMDB_API_KEY = 'tmdb_api_key', 27 | JACKETT_API_KEY = 'jackett_api_key', 28 | MAX_MOVIE_DOWNLOAD_SIZE = 'max_movie_download_size', 29 | MAX_TVSHOW_EPISODE_DOWNLOAD_SIZE = 'max_tvshow_episode_download_size', 30 | ORGANIZE_LIBRARY_STRATEGY = 'organize_library_strategy', 31 | } 32 | 33 | export enum OrganizeLibraryStrategy { 34 | LINK = 'link', 35 | COPY = 'copy', 36 | MOVE = 'move', 37 | } 38 | 39 | export enum JobsQueue { 40 | DOWNLOAD = 'download', 41 | REFRESH_TORRENT = 'refresh_torrent', 42 | RENAME_AND_LINK = 'rename_and_link', 43 | SCAN_LIBRARY = 'scan_library', 44 | } 45 | 46 | export enum DownloadQueueProcessors { 47 | DOWNLOAD_MOVIE = 'download_movie', 48 | DOWNLOAD_SEASON = 'download_season', 49 | DOWNLOAD_EPISODE = 'download_episode', 50 | DOWNLOAD_MISSING = 'download_missing', 51 | } 52 | 53 | export enum OrganizeQueueProcessors { 54 | HANDLE_MOVIE = 'handle_movie', 55 | HANDLE_SEASON = 'handle_season', 56 | HANDLE_EPISODE = 'handle_episode', 57 | } 58 | 59 | export enum ScanLibraryQueueProcessors { 60 | SCAN_LIBRARY_FOLDER = 'scan_library_folder', 61 | FIND_NEW_EPISODES = 'find_new_episodes', 62 | SCAN_TV_SHOWS_FOLDER = 'scan_tv_shows_folder', 63 | SCAN_MOVIES_FOLDER = 'scan_movies_folder', 64 | PROCESS_MOVIE_FOLDER = 'process_movie_folder', 65 | PROCESS_TV_SHOW_FOLDER = 'process_tv_show_folder', 66 | } 67 | 68 | @ObjectType() 69 | export class GraphQLCommonResponse { 70 | @Field() public success!: boolean; 71 | @Field({ nullable: true }) public message?: string; 72 | } 73 | 74 | registerEnumType(FileType, { name: 'FileType' }); 75 | registerEnumType(JobName, { name: 'JobName' }); 76 | registerEnumType(DownloadableMediaState, { name: 'DownloadableMediaState' }); 77 | registerEnumType(ParameterKey, { name: 'ParameterKey' }); 78 | -------------------------------------------------------------------------------- /packages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bobarr/api", 3 | "version": "1.0.0-beta.3", 4 | "description": "bobarr api server", 5 | "author": "iam4x ", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "start": "nest start", 12 | "dev": "yarn && yarn ts-node-dev --interval 1000 --respawn --transpile-only -r tsconfig-paths/register --ignore-watch node_modules src/main.ts", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "ENV=production node dist/main" 15 | }, 16 | "dependencies": { 17 | "@nestjs/bull": "^0.3.1", 18 | "@nestjs/common": "^7.0.0", 19 | "@nestjs/core": "^7.0.0", 20 | "@nestjs/graphql": "^7.1.5", 21 | "@nestjs/platform-express": "^7.0.0", 22 | "@nestjs/terminus": "^7.0.1", 23 | "@nestjs/typeorm": "^7.0.0", 24 | "add": "^2.0.6", 25 | "apollo-server-express": "^2.11.0", 26 | "axios": "~0.21.1", 27 | "body-parser": "^1.19.0", 28 | "bull": "^3.13.0", 29 | "bull-board": "^1.1.2", 30 | "child-command": "^1.0.3", 31 | "common-tags": "^1.8.0", 32 | "dayjs": "^1.8.23", 33 | "dotenv": "^8.2.0", 34 | "graphql": "^15.3.0", 35 | "graphql-bigint": "^1.0.0", 36 | "graphql-tools": "^6.1.0", 37 | "ioredis": "^4.16.2", 38 | "leven": "^3.1.0", 39 | "lib-get-redirects": "^1.0.0", 40 | "lodash": "^4.17.19", 41 | "nest-winston": "^1.3.3", 42 | "p-iteration": "^1.1.8", 43 | "pg": "^8.0.0", 44 | "reflect-metadata": "^0.1.13", 45 | "request": "^2.88.2", 46 | "rimraf": "^3.0.2", 47 | "rxjs": "^6.5.4", 48 | "transmission-client": "^1.0.0", 49 | "typeorm": "^0.2.24", 50 | "winston": "^3.2.1", 51 | "xml2json-light": "^1.0.6" 52 | }, 53 | "devDependencies": { 54 | "@nestjs/cli": "^7.0.0", 55 | "@nestjs/schematics": "^7.0.0", 56 | "@nestjs/testing": "^7.0.0", 57 | "@types/bull": "^3.12.1", 58 | "@types/bull-board": "^0.6.0", 59 | "@types/common-tags": "^1.8.0", 60 | "@types/dotenv": "^8.2.0", 61 | "@types/express": "^4.17.3", 62 | "@types/graphql-bigint": "^1.0.0", 63 | "@types/lodash": "^4.14.149", 64 | "@types/node": "^14.6.0", 65 | "bullmq": "^1.9.0", 66 | "supertest": "^4.0.2", 67 | "ts-loader": "^8.0.3", 68 | "ts-node": "^9.0.0", 69 | "ts-node-dev": "^1.0.0-pre.44", 70 | "tsconfig-paths": "^3.9.0", 71 | "typescript": "^4.0.2" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e # exit when error 3 | 4 | cat << "EOF" 5 | 6 | /$$ /$$ 7 | | $$ | $$ 8 | | $$$$$$$ /$$$$$$ | $$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ 9 | | $$__ $$ /$$__ $$| $$__ $$ |____ $$ /$$__ $$ /$$__ $$ 10 | | $$ \ $$| $$ \ $$| $$ \ $$ /$$$$$$$| $$ \__/| $$ \__/ 11 | | $$ | $$| $$ | $$| $$ | $$ /$$__ $$| $$ | $$ 12 | | $$$$$$$/| $$$$$$/| $$$$$$$/| $$$$$$$| $$ | $$ 13 | |_______/ \______/ |_______/ \_______/|__/ |__/ 14 | 15 | https://github.com/iam4x/bobarr 16 | 17 | EOF 18 | 19 | if [ "$(ls -A $PWD)" ] 20 | then 21 | echo "$(pwd) is not empty" 22 | echo "please re-run this install script in an empty and new directory" 23 | exit 2 24 | fi 25 | 26 | echo "downloading bobarr into directory" 27 | 28 | mkdir -p library/downloads 29 | mkdir -p library/movies 30 | mkdir -p library/tvshows 31 | 32 | mkdir -p packages/jackett/config 33 | mkdir -p packages/jackett/downloads 34 | mkdir -p packages/transmission/config 35 | mkdir -p packages/vpn 36 | 37 | ( 38 | cd packages/transmission/config 39 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/packages/transmission/config/settings.json -o settings.json 40 | ) 41 | 42 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/.env -o .env 43 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/docker-compose.yml -o docker-compose.yml 44 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/docker-compose.vpn.yml -o docker-compose.vpn.yml 45 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/docker-compose.wireguard.yml -o docker-compose.wireguard.yml 46 | 47 | curl -s https://raw.githubusercontent.com/iam4x/bobarr/master/scripts/bobarr.sh -o bobarr.sh 48 | chmod +x ./bobarr.sh 49 | 50 | echo "downloading docker images" 51 | 52 | docker-compose pull 53 | 54 | echo "" 55 | echo "bobarr installation is now complete!" 56 | echo "update your configuration into [.env] and [docker-compose.yml] to link your library" 57 | 58 | echo "" 59 | echo "when done run you can start bobarr with [./bobarr.sh start]" 60 | 61 | echo "" 62 | echo "if you want to setup a vpn or wireguard, drop your vpn configuration into [./packages/vpn]" 63 | echo "and then start with [./bobarr.sh start:vpn] or [./bobarr.sh start:wireguard]" 64 | 65 | echo "" 66 | echo "you can find the documentation here => https://github.com/iam4x/bobarr#-bobarr" 67 | echo "and if you need help join our discord => https://discord.gg/PFwM4zk" 68 | 69 | echo "" 70 | echo "enjoy" 71 | -------------------------------------------------------------------------------- /packages/transmission/config/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "alt-speed-down": 50, 3 | "alt-speed-enabled": false, 4 | "alt-speed-time-begin": 540, 5 | "alt-speed-time-day": 127, 6 | "alt-speed-time-enabled": false, 7 | "alt-speed-time-end": 1020, 8 | "alt-speed-up": 50, 9 | "bind-address-ipv4": "0.0.0.0", 10 | "bind-address-ipv6": "::", 11 | "blocklist-enabled": false, 12 | "blocklist-url": "http://www.example.com/blocklist", 13 | "cache-size-mb": 4, 14 | "dht-enabled": true, 15 | "download-dir": "/downloads/complete", 16 | "download-queue-enabled": true, 17 | "download-queue-size": 5, 18 | "encryption": 1, 19 | "idle-seeding-limit": 30, 20 | "idle-seeding-limit-enabled": false, 21 | "incomplete-dir": "/downloads/incomplete", 22 | "incomplete-dir-enabled": true, 23 | "lpd-enabled": false, 24 | "message-level": 2, 25 | "peer-congestion-algorithm": "", 26 | "peer-id-ttl-hours": 6, 27 | "peer-limit-global": 200, 28 | "peer-limit-per-torrent": 50, 29 | "peer-port": 51413, 30 | "peer-port-random-high": 65535, 31 | "peer-port-random-low": 49152, 32 | "peer-port-random-on-start": false, 33 | "peer-socket-tos": "default", 34 | "pex-enabled": true, 35 | "port-forwarding-enabled": true, 36 | "preallocation": 1, 37 | "prefetch-enabled": true, 38 | "queue-stalled-enabled": true, 39 | "queue-stalled-minutes": 30, 40 | "ratio-limit": 2, 41 | "ratio-limit-enabled": false, 42 | "rename-partial-files": true, 43 | "rpc-authentication-required": false, 44 | "rpc-bind-address": "0.0.0.0", 45 | "rpc-enabled": true, 46 | "rpc-host-whitelist": "", 47 | "rpc-host-whitelist-enabled": false, 48 | "rpc-password": "{1ddd3f1f6a71d655cde7767242a23a575b44c909n5YuRT.f", 49 | "rpc-port": 9091, 50 | "rpc-url": "/transmission/", 51 | "rpc-username": "", 52 | "rpc-whitelist": "127.0.0.1", 53 | "rpc-whitelist-enabled": false, 54 | "scrape-paused-torrents-enabled": true, 55 | "script-torrent-done-enabled": false, 56 | "script-torrent-done-filename": "", 57 | "seed-queue-enabled": false, 58 | "seed-queue-size": 10, 59 | "speed-limit-down": 100, 60 | "speed-limit-down-enabled": false, 61 | "speed-limit-up": 100, 62 | "speed-limit-up-enabled": false, 63 | "start-added-torrents": true, 64 | "trash-original-torrent-files": false, 65 | "umask": 2, 66 | "upload-slots-per-torrent": 14, 67 | "utp-enabled": false, 68 | "watch-dir": "/watch", 69 | "watch-dir-enabled": true 70 | } 71 | -------------------------------------------------------------------------------- /packages/web/components/search/search.styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Wrapper = styled.div` 4 | max-width: 1200px; 5 | margin: 0 auto; 6 | `; 7 | 8 | export const SearchStyles = styled.div` 9 | .search-bar { 10 | &--input { 11 | border: none; 12 | border-radius: 20px; 13 | color: ${({ theme }) => theme.colors.navbarBackground}; 14 | font-size: 1.2em; 15 | outline: none; 16 | padding: 8px 18px; 17 | height: 40px; 18 | width: 100%; 19 | 20 | &-container { 21 | position: relative; 22 | } 23 | 24 | &-submit { 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | background: #e84a5f; 29 | border: none; 30 | border-radius: 20px; 31 | color: #fff; 32 | cursor: pointer; 33 | font-size: 1.2em; 34 | height: 40px; 35 | padding: 8px 18px; 36 | position: absolute; 37 | right: 0; 38 | top: 0; 39 | } 40 | } 41 | 42 | &--container { 43 | background-color: ${({ theme }) => theme.colors.coral}; 44 | padding-top: 32px; 45 | padding-bottom: 32px; 46 | } 47 | 48 | &--title { 49 | color: #fff; 50 | font-size: 2em; 51 | font-weight: 600; 52 | margin-bottom: 5px; 53 | } 54 | 55 | &--subtitle { 56 | color: #fff; 57 | font-size: 1.6em; 58 | font-weight: 500; 59 | margin-bottom: 48px; 60 | } 61 | } 62 | 63 | .search-results { 64 | &--container { 65 | margin-top: 48px; 66 | } 67 | 68 | &--category { 69 | font-weight: 500; 70 | font-size: 1.3em; 71 | margin-bottom: 24px; 72 | margin-left: 32px; 73 | } 74 | 75 | &--row { 76 | display: flex; 77 | } 78 | } 79 | 80 | .carrousel { 81 | &--container { 82 | padding-left: 32px; 83 | padding-right: 32px; 84 | width: 100%; 85 | position: relative; 86 | 87 | .arrow-right, 88 | .arrow-left { 89 | background: transparent; 90 | position: absolute; 91 | outline: none; 92 | border: none; 93 | top: 175px; 94 | } 95 | 96 | .arrow-left { 97 | left: 2px; 98 | } 99 | 100 | .arrow-right { 101 | right: 2px; 102 | } 103 | } 104 | 105 | &--slide { 106 | div { 107 | outline-width: 0px; 108 | } 109 | } 110 | } 111 | `; 112 | -------------------------------------------------------------------------------- /packages/web/components/tmdb-card/tmdb-card.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { FolderOpenOutlined } from '@ant-design/icons'; 3 | import dayjs from 'dayjs'; 4 | 5 | import { 6 | TmdbSearchResult, 7 | EnrichedMovie, 8 | EnrichedTvShow, 9 | } from '../../utils/graphql'; 10 | 11 | import { getImageURL } from '../../utils/get-cached-image-url'; 12 | 13 | import { TVShowSeasonsModalComponent } from '../tvshow-details/tvshow-details.component'; 14 | import { MovieDetailsComponent } from '../movie-details/movie-details.component'; 15 | import { RatingComponent } from '../rating/rating.component'; 16 | 17 | import { TMDBCardStyles } from './tmdb-card.styles'; 18 | 19 | interface TMDBCardComponentProps { 20 | type: 'tvshow' | 'movie'; 21 | result: TmdbSearchResult | EnrichedMovie | EnrichedTvShow; 22 | inLibrary?: boolean; 23 | } 24 | 25 | export function TMDBCardComponent(props: TMDBCardComponentProps) { 26 | const { result, type, inLibrary } = props; 27 | const [isModalOpen, setIsModalOpen] = useState(false); 28 | 29 | return ( 30 | 31 | {/* display season picker modal when it's tvshow */} 32 | {type === 'tvshow' && isModalOpen && ( 33 | setIsModalOpen(false)} 38 | /> 39 | )} 40 | 41 | {/* display movie details */} 42 | {type === 'movie' && isModalOpen && ( 43 | setIsModalOpen(false)} 48 | /> 49 | )} 50 | 51 |
    setIsModalOpen(true)}> 52 |
    60 |
    61 | <> 62 | 63 |
    See details
    64 | 65 |
    66 |
    67 | 68 | 69 | 70 |
    {result.title}
    71 | {result.releaseDate && ( 72 |
    73 | {dayjs(result.releaseDate).format('DD MMM YYYY')} 74 |
    75 | )} 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/api/src/modules/library/library.dto.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, InputType } from '@nestjs/graphql'; 2 | import BigInt from 'graphql-bigint'; 3 | 4 | import { Movie } from 'src/entities/movie.entity'; 5 | import { TVShow } from 'src/entities/tvshow.entity'; 6 | import { TVEpisode } from 'src/entities/tvepisode.entity'; 7 | import { FileType } from 'src/app.dto'; 8 | 9 | @ObjectType() 10 | export class EnrichedMovie extends Movie { 11 | @Field() public overview!: string; 12 | @Field() public voteAverage!: number; 13 | @Field() public releaseDate!: string; 14 | @Field({ nullable: true }) public originalTitle?: string; 15 | @Field({ nullable: true }) public posterPath?: string; 16 | @Field({ nullable: true }) public runtime!: number; 17 | } 18 | 19 | @ObjectType() 20 | export class EnrichedTVShow extends TVShow { 21 | @Field() public overview!: string; 22 | @Field() public voteAverage!: number; 23 | @Field() public releaseDate!: string; 24 | @Field({ nullable: true }) public originalTitle?: string; 25 | @Field({ nullable: true }) public posterPath?: string; 26 | @Field({ nullable: true }) public runtime!: number; 27 | } 28 | 29 | @ObjectType() 30 | export class EnrichedTVEpisode extends TVEpisode { 31 | @Field() public releaseDate?: string; 32 | @Field({ nullable: true }) public voteAverage!: number; 33 | } 34 | 35 | @ObjectType() 36 | export class DownloadingMedia { 37 | @Field() public id!: string; 38 | @Field() public title!: string; 39 | @Field() public tag!: string; 40 | @Field() public resourceId!: number; 41 | @Field((_type) => FileType) public resourceType!: FileType; 42 | @Field() public quality!: string; 43 | @Field() public torrent!: string; 44 | } 45 | 46 | @ObjectType() 47 | export class SearchingMedia { 48 | @Field() public id!: string; 49 | @Field() public title!: string; 50 | @Field() public resourceId!: number; 51 | @Field((_type) => FileType) public resourceType!: FileType; 52 | } 53 | 54 | @InputType() 55 | export class JackettInput { 56 | @Field() public title!: string; 57 | @Field() public downloadLink!: string; 58 | @Field() public quality!: string; 59 | @Field() public tag!: string; 60 | } 61 | 62 | @ObjectType() 63 | export class LibraryCalendar { 64 | @Field((_type) => [EnrichedMovie]) public movies!: EnrichedMovie[]; 65 | @Field((_type) => [EnrichedTVEpisode]) 66 | public tvEpisodes!: EnrichedTVEpisode[]; 67 | } 68 | 69 | @ObjectType() 70 | export class LibraryFileDetails { 71 | @Field() public id!: number; 72 | @Field() public libraryPath!: string; 73 | @Field({ nullable: true }) public torrentFileName?: string; 74 | @Field((_type) => BigInt, { nullable: true }) public libraryFileSize?: number; 75 | } 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## master (pre-release) 4 | 5 | ### Added 6 | 7 | - manual search season pack (https://github.com/iam4x/bobarr/pull/172) 8 | 9 | ### Added 10 | 11 | - added FlareSolverr for solving CloudFare on certains trackers within jackett (https://github.com/iam4x/bobarr/issues/165) 12 | - update jobs ui 13 | - update nodejs to v14 14 | - track downloaded files path in database (https://github.com/iam4x/bobarr/issues/96) 15 | 16 | ### Fixes 17 | 18 | - update strategy for scannig library 19 | - handle multi part episodes when downloading a season pack 20 | - wrap organize library jobs into transactions (better error handling if something fails) 21 | - ensure torrent still exists in refresh torrent job (https://github.com/iam4x/bobarr/issues/153) 22 | 23 | ## [v1.0.0-beta.3] - 2020-12-14 24 | 25 | ### Fixes 26 | 27 | - fix install script, make `./bobarr.sh` an executable 28 | - fix start script by printing all api logs 29 | 30 | ## [v1.0.0-beta.2] - 2020-12-14 31 | 32 | ### Added 33 | 34 | - display downloaded torrent informations in movie details card (https://github.com/iam4x/bobarr/pull/146) 35 | - choose the origanize file strategy between symlink, move or copy (https://github.com/iam4x/bobarr/issues/130) 36 | - upload own .torrent or paste magnet link (https://github.com/iam4x/bobarr/issues/123) 37 | - env variable to change movies/tvshows folder name (https://github.com/iam4x/bobarr/issues/116) 38 | - calendar based on your actual library (https://github.com/iam4x/bobarr/issues/75) 39 | - clear redis cache action in settings 40 | - pushed images on docker hub with arm support (https://github.com/iam4x/bobarr/issues/163 and https://github.com/iam4x/bobarr/issues/41) 41 | - add install and start script (https://github.com/iam4x/bobarr/issues/4) 42 | 43 | ### Fixes 44 | 45 | - sort movies and tvshows by recently added 46 | - handle multiple files with same extensions, like a sample of the movie downloaded 47 | - cache requests to tmdb api (perf) 48 | - fix discover download tvshow fails (https://github.com/iam4x/bobarr/issues/104) 49 | - enable firewall in vpn container, this will prevent download starts before vpn is connected (https://github.com/iam4x/bobarr/issues/132) 50 | - disable ipv6 in vpn container (https://github.com/iam4x/bobarr/issues/133) 51 | 52 | ## [v1.0.0-beta.1] - 2020-05-20 53 | 54 | ### Added 55 | 56 | - download movies / tv shows 57 | - search movies / tv shows with keywords 58 | - search movies / tv shows with filters (year, genre, score...) 59 | - recommendations based on what you have in your library 60 | - scan your library to automatically track what you manually download 61 | - auto-download missing movies / tv shows periodically 62 | - auto-download new tv shows episodes 63 | - support multi languages torrent trackers 64 | -------------------------------------------------------------------------------- /packages/web/components/search/carousel.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect } from 'react'; 2 | import { useTheme } from 'styled-components'; 3 | import { FaChevronCircleRight, FaChevronCircleLeft } from 'react-icons/fa'; 4 | 5 | import { 6 | CarouselProvider, 7 | Slide, 8 | Slider, 9 | ButtonNext, 10 | CarouselContext, 11 | ButtonBack, 12 | } from 'pure-react-carousel'; 13 | 14 | import { 15 | TmdbSearchResult, 16 | useGetLibraryMoviesQuery, 17 | useGetLibraryTvShowsQuery, 18 | } from '../../utils/graphql'; 19 | 20 | import { TMDBCardComponent } from '../tmdb-card/tmdb-card.component'; 21 | 22 | export function CarouselComponent({ 23 | results, 24 | type, 25 | }: { 26 | results: TmdbSearchResult[]; 27 | type: 'movie' | 'tvshow'; 28 | }) { 29 | const theme = useTheme(); 30 | const { data: moviesLibrary } = useGetLibraryMoviesQuery(); 31 | const { data: tvShowsLibrary } = useGetLibraryTvShowsQuery(); 32 | 33 | const tmdbIds = [ 34 | ...(moviesLibrary?.movies?.map(({ tmdbId }) => tmdbId) || []), 35 | ...(tvShowsLibrary?.tvShows?.map(({ tmdbId }) => tmdbId) || []), 36 | ]; 37 | 38 | return ( 39 |
    40 | 48 | 49 | 50 | {results.map((result, index) => ( 51 | 56 | 62 | 63 | ))} 64 | 65 | {results.length > 5 && ( 66 | 67 | 68 | 69 | )} 70 | 71 |
    72 | ); 73 | } 74 | 75 | function ResetCarouselSlideAndGoBack({ watch }: { watch: any }) { 76 | const carouselContext = useContext(CarouselContext); 77 | const [currentSlide, setCurrentSlide] = useState( 78 | carouselContext.state.currentSlide 79 | ); 80 | 81 | useEffect(() => { 82 | function onChange() { 83 | setCurrentSlide(carouselContext.state.currentSlide); 84 | } 85 | carouselContext.subscribe(onChange); 86 | return () => carouselContext.unsubscribe(onChange); 87 | }, [carouselContext]); 88 | 89 | useEffect(() => { 90 | if (carouselContext.state.currentSlide !== 0) { 91 | carouselContext.setStoreState({ currentSlide: 0 }); 92 | } 93 | }, [carouselContext, watch]); 94 | 95 | if (currentSlide === 0) { 96 | return
    77 | } 78 | > 79 | Link 80 | 81 | 82 | 83 | 86 | It will copy the downloaded file to your library, this is 87 | useful when your system does not supports symbolic links. 88 |
    89 | This keeps the torrent seeding and deleting the file in your 90 | library wont delete the original file. 91 |
    92 | } 93 | > 94 | Copy 95 | 96 | 97 | 98 | 101 | It will move the downloaded file to your library, this is 102 | useful when your system does not supports symbolic links. 103 |
    104 | This wont keep the torrent seeding and deleting the file in 105 | your library will be permanent. 106 | 107 | } 108 | > 109 | Move 110 |
    111 |
    112 | 113 | 114 | ); 115 | } 116 | 117 | return ( 118 | 119 | 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /packages/web/components/settings/actions.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Card, notification, Modal, Checkbox, Alert } from 'antd'; 3 | 4 | import { 5 | useStartScanLibraryMutation, 6 | useStartFindNewEpisodesMutation, 7 | useStartDownloadMissingMutation, 8 | useResetLibraryMutation, 9 | useClearCacheMutation, 10 | } from '../../utils/graphql'; 11 | 12 | export function ActionsComponents() { 13 | const [findEpisodes, { loading: loading1 }] = useStartFindNewEpisodesMutation( 14 | { 15 | onCompleted: () => 16 | notification.success({ 17 | message: 'Find new episodes job started', 18 | placement: 'bottomRight', 19 | }), 20 | } 21 | ); 22 | 23 | const [scanLibrary, { loading: loading2 }] = useStartScanLibraryMutation({ 24 | onCompleted: () => 25 | notification.success({ 26 | message: 'Scan library folder started', 27 | placement: 'bottomRight', 28 | }), 29 | }); 30 | 31 | const [ 32 | downloadMissing, 33 | { loading: loading3 }, 34 | ] = useStartDownloadMissingMutation({ 35 | onCompleted: () => 36 | notification.success({ 37 | message: 'Download missing files started', 38 | placement: 'bottomRight', 39 | }), 40 | }); 41 | 42 | const [resetLibrary] = useResetLibraryMutation({ 43 | onCompleted: () => { 44 | Modal.info({ 45 | title: 'Reset succesfull!', 46 | content: 'The page will now reload', 47 | onOk: () => window.location.reload(), 48 | }); 49 | }, 50 | }); 51 | 52 | const [clearCache, { loading: loading4 }] = useClearCacheMutation({ 53 | onCompleted: () => { 54 | Modal.info({ 55 | title: 'Cache cleared correctly!', 56 | content: 'The page will now reload', 57 | onOk: () => window.location.reload(), 58 | }); 59 | }, 60 | }); 61 | 62 | function handleResetClick() { 63 | const mutableState = { deleteFiles: false, resetSettings: false }; 64 | Modal.confirm({ 65 | title: '⚠️ Warning', 66 | content: ( 67 | <> 68 | 73 |
    74 | 76 | (mutableState.deleteFiles = checked) 77 | } 78 | > 79 | Delete files downloaded from disk with bobarr (permanent) 80 | 81 |
    82 |
    83 | 85 | (mutableState.resetSettings = checked) 86 | } 87 | > 88 | Reset settings 89 | 90 |
    91 | 92 | ), 93 | onOk: () => resetLibrary({ variables: mutableState }), 94 | width: 480, 95 | }); 96 | } 97 | 98 | return ( 99 | 100 | 108 | 116 | 124 | 132 | 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /packages/web/components/settings/quality-params.component.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Card, Button, notification, Popover, Radio } from 'antd'; 3 | import { FaQuestionCircle } from 'react-icons/fa'; 4 | 5 | import { 6 | DragDropContext, 7 | Droppable, 8 | Draggable, 9 | DropResult, 10 | } from 'react-beautiful-dnd'; 11 | 12 | import { 13 | useGetQualityQuery, 14 | Quality, 15 | useSaveQualityMutation, 16 | Entertainment, 17 | } from '../../utils/graphql'; 18 | 19 | import { reorder } from './settings.helpers'; 20 | import { RadioChangeEvent } from 'antd/lib/radio'; 21 | 22 | export function QualityParamsComponent() { 23 | const [qualities, setQualities] = useState([]); 24 | const [type, setType] = useState(Entertainment.Movie); 25 | const { data, loading } = useGetQualityQuery({ 26 | variables: { type }, 27 | }); 28 | const [saveQuality, { loading: saveLoading }] = useSaveQualityMutation({ 29 | onError: ({ message }) => 30 | notification.error({ 31 | message: message.replace('GraphQL error: ', ''), 32 | placement: 'bottomRight', 33 | }), 34 | onCompleted: () => 35 | notification.success({ 36 | message: 'Quality params saved', 37 | placement: 'bottomRight', 38 | }), 39 | }); 40 | 41 | const handleDragEnd = (result: DropResult) => { 42 | if (result.destination) { 43 | setQualities( 44 | reorder({ 45 | list: qualities, 46 | startIndex: result.source.index, 47 | endIndex: result.destination.index, 48 | }) 49 | ); 50 | } 51 | }; 52 | 53 | const handleSave = (event: React.MouseEvent) => { 54 | event.preventDefault(); 55 | saveQuality({ 56 | variables: { 57 | qualities: qualities.map((q) => ({ id: q.id, score: q.score })), 58 | }, 59 | }); 60 | }; 61 | 62 | const onTypeChange = (event: RadioChangeEvent) => { 63 | event.preventDefault(); 64 | setType(event.target.value); 65 | }; 66 | 67 | useEffect(() => { 68 | if (data?.qualities) setQualities(data.qualities); 69 | }, [data]); 70 | 71 | return ( 72 | 75 |
    Quality preference
    76 |
    77 | 78 | 79 | 80 |
    81 | 82 | } 83 | className="quality-preference" 84 | loading={loading && !qualities?.length} 85 | > 86 | 87 | 92 | {Entertainment.Movie} 93 | TV Show 94 | 95 | 96 | {(provided) => ( 97 |
    98 | {qualities.map((quality, index) => ( 99 | 104 | {(provided2) => ( 105 |
    110 |
    111 | {quality.name} 112 |
    113 |
    114 | )} 115 |
    116 | ))} 117 | {provided.placeholder} 118 |
    119 | )} 120 |
    121 |
    122 | 130 |
    131 | ); 132 | } 133 | -------------------------------------------------------------------------------- /packages/web/components/downloading/downloading-rows.component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import prettySize from 'prettysize'; 3 | import { add, reduce, map } from 'lodash'; 4 | import { Tag, Progress } from 'antd'; 5 | import { LoadingOutlined } from '@ant-design/icons'; 6 | 7 | import { 8 | DownloadingMedia, 9 | useGetTorrentStatusQuery, 10 | FileType, 11 | TorrentStatus, 12 | } from '../../utils/graphql'; 13 | 14 | interface DownloadingRow extends DownloadingMedia { 15 | torrentStatus: TorrentStatus[]; 16 | } 17 | 18 | export function DownloadingRowsComponent({ 19 | rows, 20 | }: { 21 | rows: DownloadingMedia[]; 22 | }) { 23 | const { data } = useGetTorrentStatusQuery({ 24 | pollInterval: 2000, 25 | variables: { 26 | torrents: rows.map(({ resourceId, resourceType }) => ({ 27 | resourceId, 28 | resourceType, 29 | })), 30 | }, 31 | }); 32 | 33 | const displayedRows = rows 34 | // add torrent status to rows 35 | .map((row) => { 36 | const match = data?.torrents.find( 37 | ({ resourceId }) => row.resourceId === resourceId 38 | ); 39 | return { ...row, torrentStatus: match ? [match] : [] }; 40 | }) 41 | // regroup episodes of same tv episodes 42 | // and merge their status in an array 43 | .reduce((results: DownloadingRow[], curr) => { 44 | const isStopped = 45 | typeof curr.torrentStatus[0]?.status === 'number' && 46 | curr.torrentStatus[0]?.status === 0; 47 | 48 | if (curr.resourceType === FileType.Episode && !isStopped) { 49 | const match = results.find((row) => 50 | row.title 51 | .toUpperCase() 52 | .includes(curr.title.toUpperCase().replace(/ - EPISODE.+/, '')) 53 | ); 54 | 55 | if (match) { 56 | const [, episode] = 57 | /EPISODE (\d+)/.exec(curr.title.toUpperCase()) || []; 58 | 59 | return results.map((row) => 60 | row.id === match.id 61 | ? { 62 | ...row, 63 | torrentStatus: [...row.torrentStatus, ...curr.torrentStatus], 64 | title: `${match.title}, ${episode}`, 65 | } 66 | : row 67 | ); 68 | } 69 | } 70 | return [...results, curr]; 71 | }, []) 72 | // compute displayed data on the component 73 | // from multiple torrent statuses 74 | .map((row) => { 75 | const totalPercent = 76 | reduce(map(row.torrentStatus, 'percentDone'), add, 0) / 77 | row.torrentStatus.length; 78 | 79 | const percent = Math.round(totalPercent * 10000) / 100; 80 | const downloadSpeed = reduce( 81 | map(row.torrentStatus, 'rateDownload'), 82 | add, 83 | 0 84 | ); 85 | 86 | const isStopped = 87 | typeof row.torrentStatus[0]?.status === 'number' && 88 | row.torrentStatus[0]?.status === 0; 89 | 90 | return { 91 | ...row, 92 | torrentStatus: { percent, downloadSpeed, isStopped }, 93 | }; 94 | }); 95 | 96 | return ( 97 |
    98 | {displayedRows.map((row) => ( 99 |
    100 |
    101 | {row.torrentStatus.isStopped ? ( 102 | Download paused 103 | ) : ( 104 | 105 | Downloading 106 | 107 | )} 108 |
    109 |
    {row.title}
    110 |
    ({row.torrent})
    111 |
    112 | ({row.torrentStatus.percent}% 113 | {row.torrentStatus.downloadSpeed ? ( 114 | <> - {prettySize(row.torrentStatus.downloadSpeed)}/s 115 | ) : null} 116 | ) 117 |
    118 |
    119 | 125 |
    126 |
    127 | ))} 128 |
    129 | ); 130 | } 131 | --------------------------------------------------------------------------------