├── .dockerignore ├── .env.template ├── .github ├── FUNDING.yml └── workflows │ ├── dockerbuild.yml │ └── legacy_dockerbuild.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── Makefile ├── README.md ├── backend ├── .gitignore ├── README.md ├── __init__.py ├── benchmarks.py ├── cli.py ├── database.py ├── logger.py ├── main.py ├── plexwrapper.py ├── requirements.txt └── utils.py ├── frontend ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ ├── ContentItem.tsx │ │ ├── ContentList.tsx │ │ ├── ContentPage.tsx │ │ └── ContentTopBar.tsx │ ├── index.css │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── serviceWorker.ts │ ├── setupTests.ts │ ├── stores │ │ ├── ContentStore.ts │ │ ├── MediaStore.ts │ │ └── ServerInfoStore.ts │ ├── types │ │ ├── Content.ts │ │ ├── Media.ts │ │ ├── MediaPart.ts │ │ ├── MediaPartStream.ts │ │ └── index.ts │ └── util │ │ ├── api.ts │ │ └── index.ts ├── tsconfig.json └── yarn.lock └── screenshots ├── demo.gif └── listing.png /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .idea 3 | backend/venv 4 | frontend/node_modules 5 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | PLEX_BASE_URL=http://yourip:yourport 2 | PLEX_TOKEN=yourplextoken 3 | LIBRARY_NAMES=some lib;other lib -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: se1exin 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/dockerbuild.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Dockerhub 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | name: Check out code 12 | - uses: mr-smithers-excellent/docker-build-push@v5 13 | name: Build & push Docker image 14 | with: 15 | image: selexin/cleanarr 16 | addLatest: true 17 | registry: docker.io 18 | dockerfile: Dockerfile 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_PASSWORD }} 21 | 22 | -------------------------------------------------------------------------------- /.github/workflows/legacy_dockerbuild.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Dockerhub 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | name: Check out code 12 | - uses: mr-smithers-excellent/docker-build-push@v5 13 | name: Build & push Docker image 14 | with: 15 | image: selexin/plex-library-cleaner 16 | addLatest: true 17 | registry: docker.io 18 | dockerfile: Dockerfile 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_PASSWORD }} 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | venv 4 | backend/db.json 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 as build-stage 2 | 3 | WORKDIR /frontend 4 | 5 | COPY ./frontend /frontend 6 | 7 | ENV REACT_APP_BACKEND_URL="/" 8 | 9 | RUN yarn install && yarn build 10 | 11 | 12 | FROM tiangolo/uwsgi-nginx-flask:python3.10 13 | 14 | ENV STATIC_INDEX 1 15 | ENV CONFIG_DIR "/config" 16 | 17 | COPY ./backend/requirements.txt/ /app 18 | RUN pip install -r /app/requirements.txt 19 | COPY ./backend /app 20 | 21 | COPY --from=build-stage /frontend/build /app/static 22 | COPY --from=build-stage /frontend/build/static/css /app/static/css 23 | COPY --from=build-stage /frontend/build/static/js /app/static/js 24 | RUN mkdir -p $CONFIG_DIR 25 | 26 | # copied from here: https://github.com/se1exin/Cleanarr/issues/135#issuecomment-2091709103 27 | RUN echo "buffer-size=32768" >> /app/uwsgi.ini 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2020] [se1exin] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMG_NAME?=cleanarr 2 | HOST_PORT?=8080 3 | CONTAINER_PORT?=80 4 | CONFIG_MOUNT?= 5 | 6 | .env: 7 | @if [ ! -f .env ]; then cp .env.template .env; fi 8 | 9 | .PHONY: build 10 | build: .env 11 | @docker build -t=$(IMG_NAME) . 12 | 13 | .PHONY: run 14 | run: 15 | @opts=""; if [ "$(CONFIG_MOUNT)" != "" ]; then opts="$$opts -v $(CONFIG_MOUNT):/config"; fi; \ 16 | docker run --rm --env-file=.env -p $(HOST_PORT):$(CONTAINER_PORT) $$opts -ti $(IMG_NAME) 17 | 18 | .PHONY: benchmark_backend 19 | benchmark_backend: 20 | @cd backend && PYTHONPATH=$$(pwd) pytest -v benchmarks.py 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cleanarr 2 | 3 | A simple UI to help find and delete duplicate and sample files from your Plex server. 4 | 5 | > Note: At this time only Plex Content Libraries (TV/Movies) are supported. 6 | 7 | ## Plex Setup 8 | You need to check `Settings | Library | Allow media deletion` within your plex server’s settings 9 | 10 | You will need a Plex Token: [How to find your Plex Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) 11 | 12 | ## Run with Docker 13 | 14 | This project is available as a docker container on [Docker Hub](https://hub.docker.com/r/selexin/cleanarr). 15 | 16 | ### Docker Parameters 17 | 18 | You will need to set the correct parameters for your setup: 19 | 20 | | Parameter | Function | 21 | | ----- | --- | 22 | | `-v /some/path/on/your/computer:/config` | (**required**) Volume mount for config directory | 23 | | `-e PLEX_BASE_URL="plex_address"` | (**required**) Plex Server Address (e.g. http://192.169.1.100:32400) | 24 | | `-e PLEX_TOKEN="somerandomstring"` | (**required**) A valid Plex token for your Plex Server ([How to find your Plex Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/)) | 25 | | `-e LIBRARY_NAMES="Movies"`| (**optional**) Name(s) of your Plex Libraries to search. Separate multiple library names with ";" character. E.g. `"Movies 1;Movies 2"`. Default value is **"Movies"** | 26 | | `-e BYPASS_SSL_VERIFY=1` | (**optional**) Disable SSL certificate verification. Use this if your Plex Server has "Secure Connections: Required" and you are having issues connecting to it. (Thanks [@booksarestillbetter - #2](https://github.com/se1exin/cleanarr/issues/2)) | 27 | | `-p 5000:80` | (**required**) Expose the UI via the selected port (in this case `5000`). Change `5000` to the port of your choosing, but don't change the number `80`. | 28 | | `-e PAGE_SIZE=50` | (**optional**) To avoid plex timeouts, results are loaded in pages (or chunks). If you recieve Plex Timeout errors, try setting this parameter to a lower value. | 29 | | `-e DEBUG=0` | (**optional**) To enable debug logging set `DEBUG` to `1` | 30 | | `-e PLEX_TIMEOUT=7200` | (**optional**) modify the timeout for wrapper (Error : Failed to load content!) | 31 | 32 | #### Example running directly with docker (with make) 33 | 34 | ##### build 35 | 36 | ```shell 37 | make build # this will create an .env file if it doesn't already exist 38 | # edit .env file 39 | ``` 40 | 41 | ##### run 42 | 43 | ```shell 44 | # set CONFIG_MOUNT to a location on your machine where you wish to store the state from Cleanarr 45 | CONFIG_MOUNT=/tmp/config make run 46 | ``` 47 | 48 | #### Example running directly with docker (manually) 49 | ```shell 50 | # you can build and run manually 51 | docker build -t=selexin/cleanarr:latest . 52 | docker run \ 53 | -e PLEX_BASE_URL="http://192.169.1.100:32400" \ 54 | -e PLEX_TOKEN="somerandomstring" \ 55 | -e LIBRARY_NAMES="Movies" \ 56 | -p 5000:80 \ 57 | -v /some/path/on/your/computer:/config \ 58 | selexin/cleanarr:latest 59 | 60 | ``` 61 | 62 | #### Example using Docker Compose 63 | (Thanks @JesseWebDotCom - #8) 64 | 65 | Note that environment variables should **not** be quoted when using docker-compose.yml format 66 | 67 | ``` 68 | version: '3' 69 | 70 | services: 71 | 72 | cleanarr: 73 | image: selexin/cleanarr:latest 74 | container_name: cleanarr 75 | hostname: cleanarr 76 | ports: 77 | - "5000:80" 78 | environment: 79 | - BYPASS_SSL_VERIFY=1 80 | - PLEX_TOKEN=somerandomstring 81 | - PLEX_BASE_URL=http://192.169.1.100:32400 82 | - LIBRARY_NAMES=Adult Movies;Kid Videos 83 | volumes: 84 | - /some/path/on/your/computer:/config 85 | restart: unless-stopped 86 | ``` 87 | 88 | 89 | You can then access the UI in your browser at [http://localhost:5000/](http://localhost:5000/). 90 | 91 | ## Run from Source / Setup Development Environment 92 | 93 | To run from source you need to run two parts - the Python Backend and React Frontend. 94 | 95 | First clone down this repo: 96 | ``` 97 | git clone https://github.com/se1exin/cleanarr 98 | ``` 99 | 100 | ## Backend 101 | 102 | Requirements: `python3` 103 | 104 | The backend is just a thin wrapper around the Python Plex API using `Flask` to serve requests. I recommend using a `virtualenv` to run. 105 | 106 | You should change to the `backend` folder to get things running: 107 | ``` 108 | cd backend 109 | ``` 110 | 111 | Setup the python environment and dependencies: 112 | ``` 113 | python3 -m venv venv 114 | source venv/bin/activate 115 | pip install -r requirements.txt 116 | ``` 117 | 118 | Run the Backend: 119 | ``` 120 | PLEX_BASE_URL="plex_address" PLEX_TOKEN="somerandomstring" LIBRARY_NAMES="Movies" PLEX_TIMEOUT="7200" FLASK_APP=main python -m flask run 121 | ``` 122 | 123 | The backend will start and run from port `5000` on `localhost` (e.g. [http:localhost:5000](http:localhost:5000)). 124 | 125 | If you are running on a remote server : 126 | ``` 127 | PLEX_BASE_URL="http://plex_address:32400" PLEX_TOKEN="somerandomstring" LIBRARY_NAMES="Movies" FLASK_APP=main python -m flask run --host=IP.remote.server 128 | ``` 129 | See [Flask's Docs for more run options (such as chaning the port)](https://flask.palletsprojects.com/en/1.1.x/cli/). 130 | 131 | ## Frontend 132 | 133 | Requirements: `node`, `yarn`. 134 | 135 | You should change to the `frontend` folder to get things running: 136 | ``` 137 | cd frontend 138 | ``` 139 | 140 | Setup the node environment and dependencies: 141 | ``` 142 | yarn install 143 | ``` 144 | 145 | Run the Frontend development server: 146 | 147 | >Note: change `REACT_APP_BACKEND_URL` to match where your backend is running at - **make sure to include the trailing slash!** 148 | ``` 149 | REACT_APP_BACKEND_URL="http://localhost:5000/" yarn start 150 | ``` 151 | 152 | The frontend will now be available in your browser at [http:localhost:3000](http:localhost:3000). 153 | 154 | 155 | ## Screenshots 156 | 157 | ![Demo of deleting duplicate movies](screenshots/demo.gif) 158 | 159 | 160 | ## Credits 161 | Thanks to the following projects: 162 | - [pkkid/python-plexapi](https://github.com/pkkid/python-plexapi) 163 | - [tiangolo/uwsgi-nginx-flask-docker](https://github.com/tiangolo/uwsgi-nginx-flask-docker) 164 | 165 | ## License 166 | MIT - see [LICENSE.md](https://github.com/se1exin/cleanarr/blob/master/LICENSE.md) 167 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | venv 3 | __pycache__ 4 | test.sh 5 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | cli 2 | --- 3 | 4 | ```sh 5 | PLEX_BASE_URL=http://1.2.3.4:32400 PLEX_TOKEN='abMyPlexToken23' LIBRARY_NAMES='MyFilms;TVShows' ./cli.py 6 | ``` 7 | -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/se1exin/Cleanarr/44b86cad82fcf9b937e015d048d80d6c43fbcabd/backend/__init__.py -------------------------------------------------------------------------------- /backend/benchmarks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # this file is meant for local development only. it expects a populated .env file 4 | # with connection details to a valid plex server. it can be run via pytest, which 5 | # will run multiple iterations and rounds of the get_dupe_content method, or can 6 | # be invoked directly with ./backend/benchmark.py to simply run get_dupe_content() 7 | # and print traces to stdout (note: traces only available if DEBUG=1 set) 8 | 9 | import os 10 | import pytest 11 | import time 12 | from plexwrapper import PlexWrapper 13 | from utils import print_top_traces 14 | from dotenv import load_dotenv 15 | 16 | load_dotenv() 17 | 18 | def get_dupe_content(page): 19 | return PlexWrapper().get_dupe_content(int(page)) 20 | 21 | def test_get_dupe_content(benchmark): 22 | benchmark.pedantic(get_dupe_content, iterations=10, rounds=3) 23 | 24 | 25 | # allow for direct invocation, without pytest 26 | if __name__ == "__main__": 27 | dupes = get_dupe_content(os.getenv("PAGE", "1")) 28 | if dupes: 29 | print(f"found {len(dupes)} dupes") 30 | else: 31 | print("no data found ...") 32 | print_top_traces(10) 33 | -------------------------------------------------------------------------------- /backend/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import re 4 | import sys 5 | 6 | import curses 7 | 8 | from plexwrapper import PlexWrapper 9 | 10 | 11 | class CleanarrCli: 12 | def __init__(self): 13 | self.wrapper = PlexWrapper() 14 | self.items_obj = {} 15 | 16 | @staticmethod 17 | def validate_env(): 18 | valid = True 19 | envs = ("PLEX_BASE_URL", "PLEX_TOKEN", "LIBRARY_NAMES") 20 | for env in envs: 21 | if env not in os.environ: 22 | valid = False 23 | print("Environment variable {} not found".format(env)) 24 | if valid is False: 25 | raise Exception("Please provide all environment variables {}".format(envs)) 26 | 27 | # Curses section 28 | @staticmethod 29 | def draw_checkbox(win, y, x, checked): 30 | try: 31 | if checked: 32 | win.addstr(y, x, "[X] ") 33 | else: 34 | win.addstr(y, x, "[ ] ") 35 | except Exception as err: 36 | print(err) 37 | pass 38 | 39 | @staticmethod 40 | def format_bytes(fbytes): 41 | if fbytes >= 1024**3: # GB 42 | return f"{fbytes / (1024**3):.2f} GB" 43 | elif fbytes >= 1024**2: # MB 44 | return f"{fbytes / (1024**2):.2f} MB" 45 | elif fbytes >= 1024: # KB 46 | return f"{fbytes / 1024:.2f} KB" 47 | else: 48 | return f"{fbytes} bytes" 49 | 50 | def start_curses(self, stdscr): 51 | curses.curs_set(0) 52 | 53 | curses.start_color() 54 | curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK) 55 | curses.init_pair(2, curses.COLOR_BLACK, curses.COLOR_CYAN) 56 | 57 | # Initialize the list of items and checkboxes 58 | items = [] 59 | self.items_obj = {} 60 | firsts = [] 61 | dupes = self.get_all_dupes() 62 | for dupe in dupes: 63 | is_first = True 64 | for media in dupe["media"]: 65 | for part in media["parts"]: 66 | if is_first is True: 67 | firsts.append(len(items)) 68 | is_first = False 69 | items.append( 70 | f"{dupe['title']} - {part['file']} @ {self.format_bytes(part['size'])} [{media['id']}]" 71 | ) 72 | self.items_obj[media["id"]] = { 73 | "dupe": dupe, 74 | } 75 | 76 | checkboxes = [True] * len(items) 77 | for first in firsts: 78 | checkboxes[first] = False 79 | current_index = 0 80 | top_row = 0 81 | bottom_row = min(curses.LINES - 2, len(items)) 82 | continue_with_delete = False 83 | 84 | while True: 85 | stdscr.clear() 86 | 87 | for i in range(top_row, bottom_row): 88 | if i == current_index: 89 | stdscr.attron(curses.color_pair(2)) 90 | self.draw_checkbox(stdscr, i - top_row, 0, checkboxes[i]) 91 | stdscr.addstr(i - top_row, 4, items[i]) 92 | stdscr.attroff(curses.color_pair(2)) 93 | else: 94 | self.draw_checkbox(stdscr, i - top_row, 0, checkboxes[i]) 95 | stdscr.addstr(i - top_row, 4, items[i]) 96 | 97 | stdscr.refresh() 98 | 99 | key = stdscr.getch() 100 | 101 | # Process user input 102 | if key == curses.KEY_UP and current_index > 0: 103 | current_index -= 1 104 | if current_index < top_row: 105 | top_row -= 1 106 | bottom_row -= 1 107 | elif key == curses.KEY_DOWN and current_index < len(items) - 1: 108 | current_index += 1 109 | if current_index >= bottom_row: 110 | top_row += 1 111 | bottom_row += 1 112 | elif key == ord(" "): 113 | checkboxes[current_index] = not checkboxes[current_index] 114 | elif key == ord("\n"): 115 | confirm_win = curses.newwin(5, curses.COLS - 2, curses.LINES - 7, 1) 116 | confirm_win.box() 117 | confirm_win.addstr(1, 2, "Are you sure you want to delete these?") 118 | confirm_win.addstr(4, 2, "[Y] Yes [N] No") 119 | confirm_win.refresh() 120 | 121 | while True: 122 | confirm_key = confirm_win.getch() 123 | if confirm_key == ord("y") or confirm_key == ord("Y"): 124 | confirm_win.clear() 125 | confirm_win.refresh() 126 | confirm_win.addstr(1, 2, "I shall continue with deletion!") 127 | confirm_win.refresh() 128 | continue_with_delete = True 129 | # Continue with the program and handle deletion logic 130 | break 131 | elif confirm_key == ord("n") or confirm_key == ord("N"): 132 | confirm_win.clear() 133 | confirm_win.refresh() 134 | confirm_win.addstr(1, 2, "Deletion canceled!") 135 | confirm_win.refresh() 136 | # Continue with the program 137 | break 138 | 139 | if continue_with_delete is True: 140 | break 141 | 142 | if continue_with_delete is True: 143 | for i, checkbox in enumerate(checkboxes): 144 | if checkbox is True: 145 | match = re.search(r"\[(\d+)\]", items[i]) 146 | if not match: 147 | print( 148 | "Silently erroring: could not find media ID in {}".format( 149 | items[i] 150 | ) 151 | ) 152 | continue 153 | self.delete_media(int(match.group(1))) 154 | 155 | def delete_media(self, media_id): 156 | content_key = self.items_obj[media_id]["dupe"]["key"] 157 | library_name = self.items_obj[media_id]["dupe"]["library"] 158 | print(f"Deleting {media_id} {content_key} in {library_name}".format(media_id)) 159 | return self.wrapper.delete_media( 160 | library_name=library_name, content_key=content_key, media_id=media_id 161 | ) 162 | 163 | # PlexWrapper section 164 | def get_dupe_content(self, page=1): 165 | print("Getting duplicate content for page {}".format(page)) 166 | return self.wrapper.get_dupe_content(page) 167 | 168 | def get_all_dupes(self): 169 | page = 1 170 | dupes = [] 171 | while True: 172 | content = self.get_dupe_content(page) 173 | if len(content) == 0: 174 | break 175 | dupes.extend(content) 176 | page += 1 177 | return dupes 178 | 179 | def dupe_content_summary(self): 180 | dupes = self.get_all_dupes() 181 | for dupe in dupes: 182 | print(dupe["title"]) 183 | for media in dupe["media"]: 184 | for part in media["parts"]: 185 | print(" {}".format(part["file"])) 186 | return dupes 187 | 188 | 189 | if __name__ == "__main__": 190 | # environment validation 191 | try: 192 | CleanarrCli.validate_env() 193 | except Exception as err: 194 | print(err) 195 | sys.exit(1) 196 | 197 | cli = CleanarrCli() 198 | curses.wrapper(cli.start_curses) 199 | -------------------------------------------------------------------------------- /backend/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | 4 | from tinydb import TinyDB, where 5 | from tinydb.table import Document 6 | 7 | from logger import get_logger 8 | 9 | DELETED_SIZE_DOC_ID = 1 10 | IGNORED_ITEMS_TABLE = 'ignored' 11 | 12 | 13 | logger = get_logger(__name__) 14 | 15 | 16 | class Database(object): 17 | def __init__(self): 18 | logger.debug("DB Init") 19 | config_dir = os.environ.get("CONFIG_DIR", "") # Will be set by Dockerfile 20 | self.local = threading.local() 21 | logger.debug("DB Init Success") 22 | 23 | def get_db(self): 24 | if not hasattr(self.local, 'db'): 25 | config_dir = os.environ.get("CONFIG_DIR", "") 26 | self.local.db = TinyDB(os.path.join(config_dir, 'db.json')) 27 | return self.local.db 28 | 29 | def set_deleted_size(self, library_name, deleted_size): 30 | logger.debug("library_name %s, deleted_size %s", library_name, deleted_size) 31 | self.get_db().upsert(Document({ 32 | library_name: deleted_size 33 | }, doc_id=DELETED_SIZE_DOC_ID)) 34 | 35 | def get_deleted_size(self, library_name): 36 | logger.debug("library_name %s", library_name) 37 | data = self.get_db().get(doc_id=DELETED_SIZE_DOC_ID) 38 | if data is not None: 39 | if library_name in data: 40 | return data[library_name] 41 | return 0 42 | 43 | def get_ignored_item(self, content_key): 44 | logger.debug("content_key %s", content_key) 45 | table = self.get_db().table(IGNORED_ITEMS_TABLE) 46 | data = table.get(where('key') == content_key) 47 | return data 48 | 49 | def add_ignored_item(self, content_key): 50 | logger.debug("content_key %s", content_key) 51 | table = self.get_db().table(IGNORED_ITEMS_TABLE) 52 | table.insert({ 53 | 'key': content_key 54 | }) 55 | 56 | def remove_ignored_item(self, content_key): 57 | logger.debug("content_key %s", content_key) 58 | table = self.get_db().table(IGNORED_ITEMS_TABLE) 59 | table.remove(where('key') == content_key) 60 | -------------------------------------------------------------------------------- /backend/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from sys import stdout 4 | 5 | 6 | def get_logger(name): 7 | # Define logger 8 | level = logging.DEBUG if os.environ.get("DEBUG", "0") == "1" else logging.ERROR 9 | logger = logging.getLogger(name) 10 | 11 | logger.setLevel(level) 12 | log_formatter = logging.Formatter("%(name)-12s %(asctime)s %(levelname)-8s %(filename)s:%(funcName)s %(message)s") 13 | console_handler = logging.StreamHandler(stdout) 14 | console_handler.setFormatter(log_formatter) 15 | logger.addHandler(console_handler) 16 | return logger 17 | -------------------------------------------------------------------------------- /backend/main.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import urllib 4 | 5 | import requests as requests 6 | from flask import Flask, jsonify, request, send_file 7 | from flask_cors import CORS 8 | 9 | from database import Database 10 | from logger import get_logger 11 | from plexwrapper import PlexWrapper 12 | 13 | app = Flask(__name__) 14 | CORS(app) 15 | 16 | logger = get_logger(__name__) 17 | 18 | 19 | @app.errorhandler(Exception) 20 | def internal_error(error): 21 | logger.error(error) 22 | return jsonify({"error": str(error)}), 500 23 | 24 | 25 | @app.route("/server/info") 26 | def get_server_info(): 27 | info = PlexWrapper().get_server_info() 28 | return jsonify(info) 29 | 30 | 31 | @app.route("/server/proxy") 32 | def get_server_proxy(): 33 | # Proxy a request to the server - useful when the user 34 | # is viewing the cleanarr dash over HTTPS to avoid the browser 35 | # blocking untrusted server certs 36 | url = request.args.get('url') 37 | r = requests.get(url) 38 | return send_file(io.BytesIO(r.content), mimetype='image/jpeg') 39 | 40 | @app.route("/server/thumbnail") 41 | def get_server_thumbnail(): 42 | # Proxy a request to the server - useful when the user 43 | # is viewing the cleanarr dash over HTTPS to avoid the browser 44 | # blocking untrusted server certs 45 | content_key = urllib.parse.unquote(request.args.get('content_key')) 46 | url = PlexWrapper().get_thumbnail_url(content_key) 47 | r = requests.get(url) 48 | return send_file(io.BytesIO(r.content), mimetype='image/jpeg') 49 | 50 | @app.route("/content/dupes") 51 | def get_dupes(): 52 | page = int(request.args.get("page", 1)) 53 | dupes = PlexWrapper().get_dupe_content(page) 54 | return jsonify(dupes) 55 | 56 | 57 | @app.route("/content/samples") 58 | def get_samples(): 59 | samples = PlexWrapper().get_content_sample_files() 60 | return jsonify(samples) 61 | 62 | 63 | @app.route("/server/deleted-sizes") 64 | def get_deleted_sizes(): 65 | sizes = PlexWrapper().get_deleted_sizes() 66 | return jsonify(sizes) 67 | 68 | 69 | @app.route("/delete/media", methods=["POST"]) 70 | def delete_media(): 71 | content = request.get_json() 72 | library_name = content["library_name"] 73 | content_key = content["content_key"] 74 | media_id = content["media_id"] 75 | 76 | PlexWrapper().delete_media(library_name, content_key, media_id) 77 | 78 | return jsonify({"success": True}) 79 | 80 | 81 | @app.route("/content/ignore", methods=["POST"]) 82 | def add_ignored_item(): 83 | content = request.get_json() 84 | content_key = content["content_key"] 85 | 86 | db = Database() 87 | db.add_ignored_item(content_key) 88 | 89 | return jsonify({"success": True}) 90 | 91 | 92 | @app.route("/content/unignore", methods=["POST"]) 93 | def remove_ignored_item(): 94 | content = request.get_json() 95 | content_key = content["content_key"] 96 | 97 | db = Database() 98 | db.remove_ignored_item(content_key) 99 | 100 | return jsonify({"success": True}) 101 | 102 | 103 | # Static File Hosting Hack 104 | # See https://github.com/tiangolo/uwsgi-nginx-flask-docker/blob/master/deprecated-single-page-apps-in-same-container.md 105 | @app.route("/") 106 | def main(): 107 | index_path = os.path.join(app.static_folder, "index.html") 108 | return send_file(index_path) 109 | 110 | 111 | # Everything not declared before (not a Flask route / API endpoint)... 112 | @app.route("/") 113 | def route_frontend(path): 114 | # ...could be a static file needed by the front end that 115 | # doesn't use the `static` path (like in `