├── .gitignore ├── src ├── frontend │ ├── .browserslistrc │ ├── src │ │ ├── styles │ │ │ ├── README.md │ │ │ └── settings.scss │ │ ├── plugins │ │ │ ├── README.md │ │ │ ├── index.js │ │ │ └── vuetify.js │ │ ├── pages │ │ │ ├── README.md │ │ │ ├── Settings.vue │ │ │ ├── tools.vue │ │ │ └── index.vue │ │ ├── router │ │ │ └── index.js │ │ ├── main.js │ │ ├── components │ │ │ ├── settings │ │ │ │ ├── dynaarr.vue │ │ │ │ ├── connections.vue │ │ │ │ ├── music.vue │ │ │ │ └── dynaarr │ │ │ │ │ └── radarr.vue │ │ │ ├── utils │ │ │ │ └── AsyncImage.vue │ │ │ └── swipearr │ │ │ │ ├── swipeRadarr.vue │ │ │ │ └── swipeSonarr.vue │ │ ├── utils │ │ │ ├── formatters.js │ │ │ └── filterParser.js │ │ ├── App.vue │ │ └── stores │ │ │ ├── mediainfo.js │ │ │ └── settings.js │ ├── public │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ └── android-chrome-512x512.png │ │ └── site.webmanifest │ ├── .editorconfig │ ├── .gitignore │ ├── jsconfig.json │ ├── package.json │ ├── index.html │ └── vite.config.mjs └── backend │ ├── utils │ ├── music_video │ │ ├── __init__.py │ │ ├── validators.py │ │ ├── imvdb_api.py │ │ ├── music_video_detect.py │ │ ├── nfo_writer.py │ │ ├── shazam_api.py │ │ └── main.py │ ├── custom_jellyfin_api.py │ ├── parsers.py │ ├── customSonarApi.py │ ├── validators.py │ ├── log_manager.py │ ├── process_filter.py │ ├── custom_emby_api.py │ ├── config_manager.py │ └── general_arr_actions.py │ ├── requirements.txt │ ├── workers │ ├── config.yml │ ├── workers.py │ ├── playlist_to_music_video.py │ └── spot_to_mediaserver.py │ ├── models │ ├── webSettings.py │ └── media.py │ ├── database │ └── database.py │ ├── main.py │ ├── routers │ ├── system.py │ ├── music_videos.py │ ├── radarr.py │ ├── sonarr.py │ ├── mediaserver.py │ └── spotify.py │ ├── changelog.json │ ├── .gitignore │ └── schemas │ └── settings.py ├── screenshots ├── mobile_settings.png ├── desktop_swipearr.png ├── mobile_swipearr_a.png └── mobile_swipearr_b.png ├── .dockerignore ├── .github └── workflows │ ├── docker-image.yml │ └── dev-docker-image.yml ├── Dockerfile └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/__init__.py: -------------------------------------------------------------------------------- 1 | from utils.music_video.main import download_music_video 2 | -------------------------------------------------------------------------------- /screenshots/mobile_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/screenshots/mobile_settings.png -------------------------------------------------------------------------------- /screenshots/desktop_swipearr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/screenshots/desktop_swipearr.png -------------------------------------------------------------------------------- /screenshots/mobile_swipearr_a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/screenshots/mobile_swipearr_a.png -------------------------------------------------------------------------------- /screenshots/mobile_swipearr_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/screenshots/mobile_swipearr_b.png -------------------------------------------------------------------------------- /src/frontend/src/styles/README.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | This directory is for configuring the styles of the application. 4 | -------------------------------------------------------------------------------- /src/frontend/public/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/favicon.ico -------------------------------------------------------------------------------- /src/frontend/public/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /src/frontend/public/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /src/frontend/public/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /src/frontend/public/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/frontend/public/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/L4stIdi0t/arr-tools/HEAD/src/frontend/public/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /src/frontend/src/plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you 4 | want to use globally. 5 | -------------------------------------------------------------------------------- /src/backend/utils/custom_jellyfin_api.py: -------------------------------------------------------------------------------- 1 | from utils.custom_emby_api import EmbyAPI 2 | 3 | 4 | class JellyfinAPI(EmbyAPI): 5 | def get_users(self): 6 | url = self._url_builder("/Users") 7 | return self._get_request(url) 8 | -------------------------------------------------------------------------------- /src/frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/favicon/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/favicon/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"display":"standalone"} -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | src/frontend/node_modules 2 | src/backend/__pycache__ 3 | src/backend/data 4 | src/backend/.venv 5 | .git 6 | 7 | # Editor directories and files 8 | **/.idea 9 | **/.vscode 10 | **/*.suo 11 | **/*.ntvs* 12 | **/*.njsproj 13 | **/*.sln 14 | **/*.sw? -------------------------------------------------------------------------------- /src/frontend/src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * src/styles/settings.scss 3 | * 4 | * Configures SASS variables and Vuetify overwrites 5 | */ 6 | 7 | // https://vuetifyjs.com/features/sass-variables/` 8 | // @use 'vuetify/settings' with ( 9 | // $color-pack: false 10 | // ); 11 | -------------------------------------------------------------------------------- /src/frontend/src/pages/README.md: -------------------------------------------------------------------------------- 1 | # Pages 2 | 3 | Vue components created in this folder will automatically be converted to navigatable routes. 4 | 5 | Full documentation for this feature can be found in the 6 | Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository. 7 | -------------------------------------------------------------------------------- /src/backend/utils/parsers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def make_filename_safe(input_string): 5 | """Convert input string to a filename-safe string.""" 6 | # Remove any characters that are not alphanumeric, hyphens, underscores, periods, or spaces 7 | return re.sub(r"[^a-zA-Z0-9._\- ]", "", input_string) 8 | -------------------------------------------------------------------------------- /src/frontend/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/index.js 3 | * 4 | * Automatically included in `./src/main.js` 5 | */ 6 | 7 | // Plugins 8 | import vuetify from './vuetify' 9 | import router from '@/router' 10 | 11 | export function registerPlugins(app) { 12 | app 13 | .use(vuetify) 14 | .use(router) 15 | } 16 | -------------------------------------------------------------------------------- /src/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /src/frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * router/index.ts 3 | * 4 | * Automatic routes for `./src/pages/*.vue` 5 | */ 6 | 7 | // Composables 8 | import {createRouter, createWebHistory} from 'vue-router/auto' 9 | 10 | const router = createRouter({ 11 | history: createWebHistory(import.meta.env.BASE_URL), 12 | }) 13 | 14 | export default router 15 | -------------------------------------------------------------------------------- /src/frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "es5", 5 | "module": "esnext", 6 | "baseUrl": "./", 7 | "moduleResolution": "bundler", 8 | "paths": { 9 | "@/*": [ 10 | "src/*" 11 | ] 12 | }, 13 | "lib": [ 14 | "esnext", 15 | "dom", 16 | "dom.iterable", 17 | "scripthost" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | build-and-publish-latest: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2.5.0 14 | 15 | - name: Build and publish "latest" Docker image 16 | uses: VaultVulp/gp-docker-action@1.5.2 17 | with: 18 | github-token: ${{ secrets.GHCR_PAT }} 19 | image-name: main -------------------------------------------------------------------------------- /src/backend/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.111.1 2 | pyarr==5.2.0 3 | pydantic==1.10.17 4 | python-dotenv==1.0.1 5 | PyYAML==6.0.1 6 | Requests==2.32.3 7 | SQLAlchemy==2.0.31 8 | uvicorn==0.30.3 9 | spotipy==2.24.0 10 | 11 | editdistance~=0.8.1 12 | Unidecode~=1.3.8 13 | starlette~=0.37.2 14 | mutagen~=1.47.0 15 | shazamio~=0.6.0 16 | pydub~=0.25.1 17 | yt-dlp~=2024.8.6 18 | random-user-agent~=1.0.1 19 | imageio==2.34.2 20 | opencv-python==4.10.0.84 21 | scenedetect==0.6.4 22 | -------------------------------------------------------------------------------- /.github/workflows/dev-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "develop" 7 | 8 | jobs: 9 | build-and-publish-latest: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2.5.0 14 | 15 | - name: Build and publish "develop" Docker image 16 | uses: VaultVulp/gp-docker-action@1.5.2 17 | with: 18 | github-token: ${{ secrets.GHCR_PAT }} 19 | image-name: develop 20 | -------------------------------------------------------------------------------- /src/frontend/src/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * main.js 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | // Plugins 8 | import {registerPlugins} from '@/plugins' 9 | 10 | // Components 11 | import App from './App.vue' 12 | 13 | // Composables 14 | import {createApp} from 'vue' 15 | import { createPinia } from 'pinia' 16 | 17 | const pinia = createPinia() 18 | const app = createApp(App) 19 | app.use(pinia) 20 | 21 | registerPlugins(app) 22 | 23 | app.mount('#app') 24 | -------------------------------------------------------------------------------- /src/frontend/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.js 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import '@mdi/font/css/materialdesignicons.css' 9 | import 'vuetify/styles' 10 | 11 | // Composables 12 | import {createVuetify} from 'vuetify' 13 | 14 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 15 | export default createVuetify({ 16 | theme: { 17 | defaultTheme: 'dark', 18 | dark: window.matchMedia("(prefers-color-scheme: dark)").matches, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/backend/workers/config.yml: -------------------------------------------------------------------------------- 1 | workers: 2 | workers.radarr: 3 | enabled: true 4 | schedule: 5 | type: interval 6 | interval: 6 7 | max_runtime: null 8 | max_instances: 1 9 | workers.sonarr: 10 | enabled: true 11 | schedule: 12 | type: interval 13 | interval: 6 14 | max_runtime: null 15 | max_instances: 1 16 | workers.spot_to_mediaserver: 17 | enabled: true 18 | schedule: 19 | type: interval 20 | interval: 180 21 | max_runtime: null 22 | max_instances: 1 23 | workers.playlist_to_music_video: 24 | enabled: true 25 | schedule: 26 | type: interval 27 | interval: 180 28 | max_runtime: null 29 | max_instances: 1 30 | -------------------------------------------------------------------------------- /src/frontend/src/components/settings/dynaarr.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 27 | -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuetify-project", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite --port 3006", 6 | "build": "vite build", 7 | "preview": "vite preview", 8 | "lint": "eslint . --fix --ignore-path .gitignore" 9 | }, 10 | "dependencies": { 11 | "@mdi/font": "7.0.96", 12 | "core-js": "^3.34.0", 13 | "dexie": "^4.0.8", 14 | "interactjs": "^1.10.27", 15 | "lodash": "^4.17.21", 16 | "pinia": "^2.2.0", 17 | "roboto-fontface": "*", 18 | "vue": "^3.4.0", 19 | "vuetify": "^3.5.0" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-vue": "^5.0.4", 23 | "sass": "^1.71.1", 24 | "unplugin-fonts": "^1.1.1", 25 | "unplugin-vue-components": "^0.26.0", 26 | "unplugin-vue-router": "^0.8.4", 27 | "vite": "^5.1.5", 28 | "vite-plugin-vuetify": "^2.0.3", 29 | "vue-router": "^4.3.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build the frontend 2 | FROM node:20 AS frontend-builder 3 | 4 | WORKDIR /app/frontend 5 | 6 | COPY /src/frontend/package*.json ./ 7 | 8 | RUN npm install 9 | 10 | COPY /src/frontend/ . 11 | 12 | RUN npm run build 13 | 14 | # Debugging step 15 | RUN ls -la /app/frontend 16 | 17 | # Stage 2: Build the backend and serve the application 18 | FROM python:3.10 19 | 20 | WORKDIR /app/backend 21 | 22 | COPY /src/backend/requirements.txt . 23 | 24 | RUN apt-get update && \ 25 | apt-get install -y ffmpeg 26 | 27 | RUN pip install -r requirements.txt 28 | 29 | COPY /src/backend/ . 30 | 31 | # Copy the built frontend files to the backend static directory 32 | COPY --from=frontend-builder /app/frontend/dist /app/backend/static 33 | 34 | RUN mkdir /app/backend/data 35 | 36 | # Expose port 9000 for the application 37 | EXPOSE 9000 38 | 39 | # Command to run the backend application 40 | CMD ["python", "main.py"] 41 | -------------------------------------------------------------------------------- /src/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Arr Tools 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/backend/models/webSettings.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sqlalchemy import Column, Integer, VARCHAR, TypeDecorator 4 | from sqlalchemy.orm import declarative_base 5 | 6 | Base = declarative_base() 7 | 8 | 9 | class JSONEncodedDict(TypeDecorator): 10 | """Enables JSON storage by encoding and decoding on the fly.""" 11 | 12 | impl = VARCHAR 13 | cache_ok = True 14 | 15 | def process_bind_param(self, value, dialect): 16 | if value is not None: 17 | if isinstance(value, str): 18 | return value 19 | 20 | value = json.dumps(value) 21 | return value 22 | 23 | def process_result_value(self, value, dialect): 24 | if value is not None: 25 | value = json.loads(value) 26 | return value 27 | 28 | 29 | class SwipeArrSeenRadarr(Base): 30 | __tablename__ = "swipeArrSeenRadarr" 31 | 32 | id = Column(Integer, primary_key=True) 33 | itemId = Column(Integer, nullable=False) 34 | 35 | 36 | class SwipeArrSeenSonarr(Base): 37 | __tablename__ = "swipeArrSeenSonarr" 38 | 39 | id = Column(Integer, primary_key=True) 40 | itemId = Column(Integer, nullable=False) 41 | -------------------------------------------------------------------------------- /src/backend/database/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Annotated 3 | 4 | from dotenv import load_dotenv 5 | from fastapi import Depends 6 | from sqlalchemy import create_engine 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | from models.media import Base as media_base 10 | from models.webSettings import Base as web_base 11 | 12 | # region Configuration and Setup 13 | 14 | # Load environment variables from .env file 15 | load_dotenv() 16 | 17 | # Get database URLs from environment variables 18 | DATABASE_URL_PROGRAM = "sqlite:///data/database.db" 19 | 20 | # Ensure the directory exists 21 | os.makedirs(os.path.dirname(os.path.abspath("data/database.db")), exist_ok=True) 22 | 23 | # Create engines for user and program data 24 | program_data_engine = create_engine(DATABASE_URL_PROGRAM) 25 | 26 | # Create tables if they do not exist 27 | media_base.metadata.create_all(program_data_engine) 28 | web_base.metadata.create_all(program_data_engine) 29 | 30 | # Create session makers for user and program data 31 | ProgramSessionLocal = sessionmaker( 32 | autocommit=False, autoflush=True, bind=program_data_engine 33 | ) 34 | 35 | # endregion 36 | 37 | 38 | # region Dependency Injection 39 | def get_program_db(): 40 | db = ProgramSessionLocal() 41 | try: 42 | yield db 43 | finally: 44 | db.close() 45 | 46 | 47 | # Dependencies annotated with types for FastAPI 48 | program_db_dependency = Annotated[ProgramSessionLocal, Depends(get_program_db)] 49 | 50 | # endregion 51 | -------------------------------------------------------------------------------- /src/frontend/src/components/utils/AsyncImage.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 25 | 26 | 73 | -------------------------------------------------------------------------------- /src/backend/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import uvicorn 5 | from fastapi import FastAPI 6 | from fastapi.staticfiles import StaticFiles 7 | from starlette.responses import FileResponse 8 | 9 | from routers import mediaserver 10 | from routers import music_videos 11 | from routers import radarr 12 | from routers import sonarr 13 | from routers import spotify 14 | from routers import system 15 | from utils.log_manager import LoggingManager 16 | from workers.workers import WorkerManager 17 | 18 | logging_manager = LoggingManager() 19 | logging_manager.log("Program is starting", level=logging.INFO) 20 | 21 | app = FastAPI() 22 | 23 | app.include_router(radarr.router, prefix="/api") 24 | app.include_router(sonarr.router, prefix="/api") 25 | app.include_router(spotify.router, prefix="/api") 26 | app.include_router(music_videos.router, prefix="/api") 27 | app.include_router(mediaserver.router, prefix="/api") 28 | app.include_router(system.router, prefix="/api") 29 | 30 | os.makedirs("./static/assets", exist_ok=True) # Fix if using empty pull 31 | app.mount("/assets", StaticFiles(directory="static/assets"), name="assets") 32 | os.makedirs("./static/favicon", exist_ok=True) # Fix if using empty pull 33 | app.mount("/favicon", StaticFiles(directory="static/favicon"), name="favicon") 34 | 35 | 36 | @app.get("/{path:path}") 37 | async def read_index(): 38 | return FileResponse("./static/index.html") 39 | 40 | 41 | def main_start(): 42 | manager = WorkerManager("./workers/config.yml") 43 | manager.start_workers() 44 | 45 | 46 | if __name__ == "__main__": 47 | main_start() 48 | uvicorn.run(app, host="0.0.0.0", port=9000) 49 | -------------------------------------------------------------------------------- /src/frontend/src/pages/Settings.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 51 | 52 | 55 | -------------------------------------------------------------------------------- /src/backend/utils/customSonarApi.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from pyarr import SonarrAPI 4 | from pyarr.types import JsonObject 5 | from requests import Response 6 | 7 | 8 | class customSonarAPI(SonarrAPI): 9 | def upd_series_editor(self, data: JsonObject) -> JsonObject: 10 | """The Updates operation allows to edit properties of multiple movies at once 11 | 12 | Args: 13 | data (JsonObject): Updated movie information:: 14 | 15 | {"movieIds":[28],"tags":[3],"applyTags":"add"} 16 | {"movieIds":[28],"monitored":true} 17 | {"movieIds":[28],"qualityProfileId":1} 18 | {"movieIds":[28],"minimumAvailability":"inCinemas"} 19 | {"movieIds":[28],"rootFolderPath":"/defaults/"} 20 | 21 | Returns: 22 | JsonArray: Dictionary containing updated record 23 | """ 24 | 25 | return self._put("series/editor", self.ver_uri, data=data) 26 | 27 | # DELETE /series/{id} 28 | def del_series( 29 | self, 30 | id_: int, 31 | delete_files: bool = False, 32 | add_import_list_exclusion: bool = False, 33 | ) -> Union[Response, JsonObject, dict[Any, Any]]: 34 | """Delete the series with the given ID 35 | 36 | Args: 37 | id_ (int): Database ID for series 38 | delete_files (bool, optional): If true series folder and files will be deleted. Defaults to False. 39 | 40 | Returns: 41 | dict: Blank dictionary 42 | """ 43 | # File deletion does not work 44 | params = { 45 | "deleteFiles": delete_files, 46 | "addImportListExclusion": add_import_list_exclusion, 47 | } 48 | return self._delete(f"series/{id_}", self.ver_uri, params=params) 49 | -------------------------------------------------------------------------------- /src/frontend/vite.config.mjs: -------------------------------------------------------------------------------- 1 | // Plugins 2 | import Components from 'unplugin-vue-components/vite' 3 | import Vue from '@vitejs/plugin-vue' 4 | import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify' 5 | import ViteFonts from 'unplugin-fonts/vite' 6 | import VueRouter from 'unplugin-vue-router/vite' 7 | 8 | // Utilities 9 | import {defineConfig} from 'vite' 10 | import {fileURLToPath, URL} from 'node:url' 11 | 12 | // https://vitejs.dev/config/ 13 | export default defineConfig({ 14 | plugins: [ 15 | VueRouter(), 16 | Vue({ 17 | template: {transformAssetUrls} 18 | }), 19 | // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme 20 | Vuetify({ 21 | autoImport: true, 22 | styles: { 23 | configFile: 'src/styles/settings.scss', 24 | }, 25 | }), 26 | Components(), 27 | ViteFonts({ 28 | google: { 29 | families: [{ 30 | name: 'Roboto', 31 | styles: 'wght@100;300;400;500;700;900', 32 | }], 33 | }, 34 | }), 35 | ], 36 | define: {'process.env': {}}, 37 | resolve: { 38 | alias: { 39 | '@': fileURLToPath(new URL('./src', import.meta.url)) 40 | }, 41 | extensions: [ 42 | '.js', 43 | '.json', 44 | '.jsx', 45 | '.mjs', 46 | '.ts', 47 | '.tsx', 48 | '.vue', 49 | ], 50 | }, 51 | server: { 52 | port: 3000, 53 | 54 | proxy: { 55 | '/api': { 56 | target: 'http://127.0.0.1:9000/api', // your backend server URL 57 | changeOrigin: true, 58 | rewrite: (path) => path.replace(/^\/api/, '') 59 | } 60 | } 61 | 62 | }, 63 | }) 64 | -------------------------------------------------------------------------------- /src/backend/utils/validators.py: -------------------------------------------------------------------------------- 1 | import editdistance 2 | from unidecode import unidecode 3 | 4 | 5 | def normalize_string(input_str: str, remove_case: bool = True) -> str: 6 | """ 7 | Normalize the input string by removing emojis and replacing complicated characters with simpler versions. 8 | 9 | Args: 10 | remove_case: If it should remove the case of the string. 11 | input_str (str): The string to normalize. 12 | 13 | Returns: 14 | str: The normalized string. 15 | """ 16 | normalized = unidecode(input_str).strip() 17 | if remove_case: 18 | normalized = normalized.casefold() 19 | return normalized 20 | 21 | 22 | def fuzzy_str_match(query, target, max_changes_per_word=1, normalize_words=True): 23 | """ 24 | Check if two strings are fuzzy matches, allowing a maximum number of changes per word. 25 | 26 | Args: 27 | query (str): The query string to match. 28 | target (str): The target string to match against. 29 | max_changes_per_word (int, optional): The maximum number of changes allowed per word. 30 | Defaults to 1. 31 | normalize_words: Makes the string normalize words. 32 | 33 | Returns: 34 | bool: True if the strings are fuzzy matches within the specified maximum changes per word, False otherwise. 35 | """ 36 | if normalize_words: 37 | query = normalize_string(query, remove_case=True) 38 | target = normalize_string(target, remove_case=True) 39 | 40 | if query == target: 41 | return True 42 | 43 | query_words = query.split() 44 | target_words = target.split() 45 | 46 | if len(query_words) != len(target_words): 47 | return False 48 | 49 | for q_word, t_word in zip(query_words, target_words): 50 | if editdistance.eval(q_word, t_word) > max_changes_per_word: 51 | return False 52 | 53 | return True 54 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/validators.py: -------------------------------------------------------------------------------- 1 | from utils.config_manager import ConfigManager 2 | from utils.log_manager import LoggingManager 3 | from utils.validators import normalize_string, fuzzy_str_match 4 | 5 | # region Configuration and Setup 6 | config_manager = ConfigManager() 7 | config = config_manager.get_config() 8 | logging_manager = LoggingManager() 9 | 10 | 11 | # endregion 12 | 13 | 14 | def validate_youtube_title(title: str, song_title: str, song_artists: list): 15 | mv_config = config.MUSICVIDEO 16 | title = normalize_string(title) 17 | 18 | score = 100 19 | 20 | if any( 21 | fuzzy_str_match(title, bad_keyword) for bad_keyword in mv_config.bad_keywords 22 | ): 23 | return False 24 | for good_keyword in mv_config.good_keywords: 25 | if fuzzy_str_match(title, good_keyword): 26 | score += 1 27 | for bad_keyword in mv_config.bad_keywords: 28 | if fuzzy_str_match(title, bad_keyword): 29 | score -= 1 30 | 31 | title = " ".join("".join(e for e in title if e.isalnum() or e.isspace()).split()) 32 | title_words = title.split(" ") 33 | 34 | song_title = " ".join( 35 | "".join(e for e in song_title if e.isalnum() or e.isspace()).split() 36 | ) 37 | song_title_words = song_title.split(" ") 38 | 39 | for song_word in song_title_words: 40 | if not any( 41 | fuzzy_str_match(song_word, title_word) for title_word in title_words 42 | ): 43 | return False 44 | 45 | artists = " ".join(map(str, song_artists)) 46 | artists = " ".join( 47 | "".join(e for e in artists if e.isalnum() or e.isspace()).split() 48 | ) 49 | artists_words = artists.split(" ") 50 | 51 | for title_word in title_words: 52 | if any( 53 | fuzzy_str_match(title_word, artist_word) for artist_word in artists_words 54 | ): 55 | return score 56 | -------------------------------------------------------------------------------- /src/frontend/src/utils/formatters.js: -------------------------------------------------------------------------------- 1 | export function formatTimeLength(seconds) { 2 | const hours = Math.floor(seconds / 3600); 3 | const minutes = Math.floor((seconds % 3600) / 60); 4 | const remainingSeconds = Math.round(seconds % 60); 5 | 6 | if (hours > 0) { 7 | return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`; 8 | } else { 9 | return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; 10 | } 11 | } 12 | 13 | export function relativeDate(dateStr) { 14 | const date = new Date(dateStr); 15 | const now = new Date(); 16 | const seconds = Math.floor((now - date) / 1000); 17 | const minutes = Math.floor(seconds / 60); 18 | const hours = Math.floor(minutes / 60); 19 | const days = Math.floor(hours / 24); 20 | const months = Math.floor(days / 30); 21 | const years = Math.floor(days / 365); 22 | 23 | if (years === 1) return `1 year ago`; 24 | if (years > 1) return `${years} years ago`; 25 | if (months === 1) return `1 month ago`; 26 | if (months > 1) return `${months} months ago`; 27 | if (days > 1) return `${days} days ago`; 28 | if (days === 1) return `Yesterday`; 29 | return `Today`; 30 | } 31 | 32 | export function formatDate(isoString) { 33 | const date = new Date(isoString); 34 | return date.toLocaleString('en-US', { 35 | year: 'numeric', 36 | month: 'long', 37 | day: 'numeric', 38 | hour: 'numeric', 39 | minute: 'numeric', 40 | second: 'numeric', 41 | timeZoneName: 'short' 42 | }); 43 | } 44 | 45 | export function formatBytes(bytes, decimals = 2) { 46 | if (bytes === 0) return "0 Bytes"; 47 | const k = 1024; 48 | const dm = decimals < 0 ? 0 : decimals; 49 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 50 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 51 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; 52 | } 53 | -------------------------------------------------------------------------------- /src/backend/utils/log_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | 5 | class LoggingManager: 6 | def __init__(self, log_file_name="main.log"): 7 | self.logger = None 8 | file_path_base = "./data/logs" 9 | os.makedirs(file_path_base, exist_ok=True) 10 | self.log_file_path = os.path.join(file_path_base, log_file_name) 11 | self.debug_file_overwrite = False 12 | if any(os.path.exists(f"./data/DEBUG{ext}") for ext in ["", ".txt", ".log"]): 13 | self.debug_file_overwrite = True 14 | print("Debug file overwrite is enabled") 15 | 16 | self.setup_logging() 17 | 18 | def setup_logging(self): 19 | # Create a logger 20 | self.logger = logging.getLogger("central_logger") 21 | self.logger.setLevel(logging.INFO) 22 | if self.debug_file_overwrite: 23 | self.logger.setLevel(logging.DEBUG) 24 | 25 | fh = logging.FileHandler(self.log_file_path) 26 | 27 | formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 28 | fh.setFormatter(formatter) 29 | 30 | self.logger.addHandler(fh) 31 | 32 | def log(self, message, level=logging.INFO, print_message=True): 33 | if not self.logger: 34 | print("Logger not initialized") 35 | 36 | if level == logging.DEBUG: 37 | self.logger.debug(message) 38 | elif level == logging.INFO: 39 | self.logger.info(message) 40 | elif level == logging.WARNING: 41 | self.logger.warning(message) 42 | elif level == logging.ERROR: 43 | self.logger.error(message) 44 | elif level == logging.CRITICAL: 45 | self.logger.critical(message) 46 | 47 | if print_message: 48 | print(message) 49 | 50 | def set_log_level(self, log_level): 51 | if self.debug_file_overwrite: 52 | self.log("Debug file overwrite is enabled, ignoring log level change") 53 | return 54 | self.logger.setLevel(log_level) 55 | -------------------------------------------------------------------------------- /src/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 63 | 64 | 73 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/imvdb_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from random_user_agent.user_agent import UserAgent 3 | 4 | from utils.validators import fuzzy_str_match 5 | 6 | user_agent_rotator = UserAgent(limit=100) 7 | 8 | 9 | def imvdb_search(artists: list, title: str, album: str): 10 | url = f'https://imvdb.com/api/v1/search/videos?q={"+".join(artists)}+{title}' 11 | HEADERS = { 12 | "User-Agent": user_agent_rotator.get_random_user_agent(), 13 | "Accept": "application/json, text/plain, */*", 14 | } 15 | 16 | response = requests.get(url=url, headers=HEADERS) 17 | if response.status_code == 429: 18 | raise NotImplementedError("Rate limit exceeded") 19 | if response.status_code != 200: 20 | return None, None 21 | 22 | results = response.json()["results"] 23 | imvdb_id = None 24 | for result in results: 25 | artist_match = any( 26 | fuzzy_str_match(artist, str(result_artist["name"])) 27 | for artist in artists 28 | for result_artist in result["artists"] 29 | ) 30 | try: 31 | title_match = fuzzy_str_match(title, str(result["song_title"])) 32 | except: 33 | print(result) 34 | raise 35 | if artist_match and title_match: 36 | imvdb_id = result["id"] 37 | break 38 | 39 | if imvdb_id is None: 40 | return None, None 41 | 42 | url = f"https://imvdb.com/api/v1/video/{imvdb_id}?include=sources,featured,credits,popularity" 43 | response = requests.get(url=url, headers=HEADERS) 44 | if response.status_code == 429: 45 | raise NotImplementedError("Rate limit exceeded") 46 | if response.status_code != 200: 47 | return None, None 48 | 49 | result = response.json() 50 | youtube_id = None 51 | for source in result["sources"]: 52 | if source["source"] == "youtube": 53 | youtube_id = source["source_data"] 54 | break 55 | if youtube_id is None: 56 | return None, None 57 | 58 | return youtube_id, { 59 | "year": result["year"], 60 | "artists": [artist["name"] for artist in result["artists"]], 61 | "featured_artists": [artist["name"] for artist in result["featured_artists"]], 62 | "views_all_time": result["popularity"]["views_all_time"], 63 | } 64 | -------------------------------------------------------------------------------- /src/backend/routers/system.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Annotated 3 | 4 | from fastapi import APIRouter 5 | from fastapi import Form 6 | from fastapi.responses import JSONResponse 7 | 8 | import schemas.settings as settings 9 | from database.database import program_db_dependency 10 | from models.webSettings import SwipeArrSeenRadarr, SwipeArrSeenSonarr 11 | from utils.config_manager import ConfigManager 12 | 13 | # region Configuration and Setup 14 | router = APIRouter(prefix="/system", tags=["system"]) 15 | config_manager = ConfigManager() 16 | 17 | 18 | # endregion 19 | 20 | 21 | @router.get("/health") 22 | async def search_music(): 23 | return JSONResponse({"message": "OK"}) 24 | 25 | 26 | @router.get("/changelog") 27 | async def get_changelog(): 28 | with open("./changelog.json") as f: 29 | return JSONResponse(json.loads(f.read())) 30 | 31 | 32 | @router.get("/settings") 33 | async def get_settings() -> settings.Config: 34 | config_manager.load_config_file() 35 | return config_manager.get_config() 36 | 37 | 38 | @router.get("/user-settings") 39 | async def get_user_settings(): 40 | try: 41 | with open("./data/user-config.json", "r", encoding="utf-8") as userFile: 42 | return json.loads(userFile.read()) 43 | except FileNotFoundError: 44 | return 45 | 46 | 47 | @router.put("/user-settings") 48 | async def put_user_settings(settings: Annotated[str, Form()]): 49 | with open("./data/user-config.json", "w", encoding="utf-8") as userFile: 50 | userFile.write(settings) 51 | 52 | 53 | @router.get("/seen-items") 54 | async def get_seen_items(db: program_db_dependency): 55 | return { 56 | "radarr": [item.itemId for item in db.query(SwipeArrSeenRadarr).all()], 57 | "sonarr": [item.itemId for item in db.query(SwipeArrSeenSonarr).all()], 58 | } 59 | 60 | 61 | @router.post("/seen-item") 62 | async def add_seen_item( 63 | db: program_db_dependency, id: Annotated[int, Form()], arr: Annotated[str, Form()] 64 | ): 65 | db_model = None 66 | arr = arr.casefold() 67 | if arr == "radarr": 68 | db_model = SwipeArrSeenRadarr 69 | elif arr == "sonarr": 70 | db_model = SwipeArrSeenSonarr 71 | 72 | if not db_model: 73 | return 74 | 75 | new_item = db_model(itemId=id) 76 | db.add(new_item) 77 | db.commit() 78 | 79 | 80 | @router.delete("/clear-seen-items") 81 | async def clear_seen_items(db: program_db_dependency): 82 | db.delete(SwipeArrSeenSonarr) 83 | db.delete(SwipeArrSeenRadarr) 84 | db.commit() 85 | -------------------------------------------------------------------------------- /src/backend/routers/music_videos.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Query 4 | from fastapi import HTTPException 5 | 6 | import schemas.settings as settings 7 | from utils.config_manager import ConfigManager 8 | from utils.log_manager import LoggingManager 9 | 10 | # region Configuration and Setup 11 | router = APIRouter(prefix="/music-video", tags=["Music video"]) 12 | config_manager = ConfigManager() 13 | config = config_manager.get_config() 14 | logging_manager = LoggingManager() 15 | 16 | 17 | # endregion 18 | 19 | 20 | @router.get( 21 | "/settings", 22 | response_model=settings.MusicVideoSettings, 23 | description="Get Music video settings", 24 | ) 25 | def get_settings() -> settings.MusicVideoSettings: 26 | config = config_manager.get_config() 27 | return config.MUSICVIDEO 28 | 29 | 30 | @router.post("/settings", description="Update Music video settings") 31 | def post_settings(settings: settings.MusicVideoSettings): 32 | logging_manager.log("Updating Music video settings", level=logging.DEBUG) 33 | config = config_manager.get_config() 34 | config.MUSICVIDEO = settings 35 | config_manager.save_config_file(config) 36 | 37 | return HTTPException(status_code=200, detail="Music video settings updated") 38 | 39 | 40 | @router.get("/playlists", description="Get all playlists for conversion") 41 | def get_playlists(): 42 | config = config_manager.get_config() 43 | return config.MUSICVIDEO.convert_playlists 44 | 45 | 46 | @router.put("/playlist", description="Add a playlist for conversion") 47 | def put_playlist( 48 | playlist_id: str = Query(..., description="ID of playlist"), 49 | ): 50 | """ 51 | Add a playlist for conversion. 52 | 53 | Args: 54 | playlist_id (str): ID of playlist. 55 | 56 | Raises: 57 | HTTPException: If the playlist already exists 58 | """ 59 | config = config_manager.get_config() 60 | 61 | if playlist_id not in config.MUSICVIDEO.convert_playlists: 62 | config.MUSICVIDEO.convert_playlists.append(playlist_id) 63 | config_manager.save_config_file(config) 64 | 65 | return HTTPException(status_code=200, detail=f"Playlist added {playlist_id}") 66 | else: 67 | raise HTTPException( 68 | status_code=400, detail=f"Playlist already exists {playlist_id}" 69 | ) 70 | 71 | 72 | @router.delete("/playlist", description="Delete a playlist from the conversion list") 73 | def delete_playlist(playlist_id: str): 74 | config = config_manager.get_config() 75 | 76 | if playlist_id in config.MUSICVIDEO.convert_playlists: 77 | config.MUSICVIDEO.convert_playlists.remove(playlist_id) 78 | config_manager.save_config_file(config) 79 | return HTTPException(status_code=200, detail=f"Playlist deleted {playlist_id}") 80 | 81 | raise HTTPException(status_code=400, detail=f"Playlist not found {playlist_id}") 82 | -------------------------------------------------------------------------------- /src/backend/workers/workers.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from datetime import datetime, timedelta 4 | from importlib import import_module 5 | 6 | import yaml 7 | 8 | 9 | class Worker: 10 | def __init__(self, name, schedule, max_runtime, max_instances): 11 | self.name = name 12 | self.schedule = schedule 13 | self.max_runtime = max_runtime 14 | self.max_instances = max_instances 15 | self.last_run = None 16 | self.running = False 17 | self.instances = 0 18 | 19 | def run(self): 20 | current_time = datetime.now() 21 | if self.schedule["type"] == "startup": 22 | if self.last_run is None: 23 | self.execute_worker() 24 | return # Exit after one run if it's a startup type 25 | elif self.schedule["type"] == "interval": 26 | next_run_time = ( 27 | self.last_run + timedelta(minutes=self.schedule["interval"]) 28 | if self.last_run 29 | else None 30 | ) 31 | if self.last_run is None or current_time >= next_run_time: 32 | self.execute_worker() 33 | 34 | def execute_worker(self): 35 | if self.instances < self.max_instances: 36 | print(f"Running {self.name} worker...") 37 | self.last_run = datetime.now() 38 | self.running = True 39 | script_module = import_module(f"{self.name}") 40 | script_module.run() 41 | self.running = False 42 | else: 43 | print(f"Maximum instances reached for {self.name}.") 44 | 45 | 46 | class WorkerManager: 47 | def __init__(self, config_file): 48 | self.workers = {} 49 | self.load_config(config_file) 50 | 51 | def load_config(self, config_file): 52 | with open(config_file, "r") as f: 53 | config = yaml.safe_load(f) 54 | for name, details in config["workers"].items(): 55 | self.workers[name] = Worker( 56 | name, 57 | details["schedule"], 58 | details["max_runtime"], 59 | details["max_instances"], 60 | ) 61 | 62 | def start_workers(self): 63 | for worker in self.workers.values(): 64 | if worker.schedule["type"] == "startup": 65 | worker_thread = threading.Thread(target=worker.run) 66 | worker_thread.start() 67 | elif worker.schedule["type"] == "interval": 68 | worker_thread = threading.Thread(target=self.run_worker, args=(worker,)) 69 | worker_thread.start() 70 | 71 | def run_worker(self, worker): 72 | while True: 73 | worker.run() 74 | time.sleep( 75 | 1 76 | ) # Keep this to prevent a tight loop that consumes too much CPU. 77 | 78 | 79 | if __name__ == "__main__": 80 | manager = WorkerManager("config.yml") 81 | manager.start_workers() 82 | -------------------------------------------------------------------------------- /src/frontend/src/components/settings/connections.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 65 | 66 | 69 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/music_video_detect.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | from scenedetect import SceneManager, open_video, ContentDetector 3 | 4 | # TODO: Check the entire length of the video for scene changes 5 | 6 | 7 | def find_scenes(video_path): 8 | video = open_video(video_path) 9 | scene_manager = SceneManager() 10 | scene_manager.add_detector(ContentDetector(threshold=42)) 11 | scene_manager.detect_scenes(video) 12 | return scene_manager.get_scene_list() 13 | 14 | 15 | def detect_movement(video_path, skip_frames, check_duration, threshold=25): 16 | cap = cv2.VideoCapture(video_path) 17 | if not cap.isOpened(): 18 | print(f"Error: Could not open video {video_path}") 19 | return None 20 | 21 | # Get video properties 22 | fps = cap.get(cv2.CAP_PROP_FPS) 23 | total_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT) 24 | width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 25 | height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 26 | 27 | short_side = min(width, height) 28 | padding = short_side // 4 29 | 30 | start_check = int((fps * check_duration) / skip_frames) 31 | center_frame_num = int(total_frames // 2) 32 | center_check = int(center_frame_num / skip_frames) - start_check // 2 33 | 34 | def frame_has_movement(frame1, frame2): 35 | # Only consider the center part 36 | center_frame1 = frame1[padding:-padding, padding:-padding] 37 | center_frame2 = frame2[padding:-padding, padding:-padding] 38 | 39 | gray1 = cv2.cvtColor(center_frame1, cv2.COLOR_BGR2GRAY) 40 | gray2 = cv2.cvtColor(center_frame2, cv2.COLOR_BGR2GRAY) 41 | 42 | diff = cv2.absdiff(gray1, gray2) 43 | _, thresh = cv2.threshold(diff, threshold, 255, cv2.THRESH_BINARY) 44 | movement = cv2.countNonZero(thresh) 45 | return movement > 0 46 | 47 | frame_count = 0 48 | prev_frame = None 49 | 50 | movement_detected = False 51 | while True: 52 | ret, frame = cap.read() 53 | if not ret: 54 | break 55 | 56 | if frame_count % skip_frames == 0: 57 | if frame_count >= start_check and frame_count < ( 58 | start_check + fps * check_duration 59 | ): 60 | if prev_frame is not None and frame_has_movement(prev_frame, frame): 61 | movement_detected = True 62 | break 63 | 64 | if frame_count >= center_check and frame_count < ( 65 | center_check + fps * check_duration 66 | ): 67 | if prev_frame is not None and frame_has_movement(prev_frame, frame): 68 | movement_detected = True 69 | break 70 | 71 | prev_frame = frame 72 | 73 | frame_count += 1 74 | 75 | if frame_count > (start_check + fps * 2 * check_duration): 76 | break 77 | 78 | cap.release() 79 | if not movement_detected: 80 | return False 81 | 82 | scene_list = find_scenes(video_path) 83 | min_scene_changes = total_frames / fps / 45 84 | return len(scene_list) > min_scene_changes 85 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/nfo_writer.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from utils.config_manager import ConfigManager 4 | 5 | # TODO: Fix that it does not work with media server 6 | 7 | # region Configuration and Setup 8 | config_manager = ConfigManager() 9 | config = config_manager.get_config() 10 | 11 | 12 | # endregion 13 | 14 | 15 | MUSICBRAINZ_HEADERS = { 16 | "User-Agent": "Arr-Tools/0.1.0 (https://github.com/L4stIdi0t/arr-tools)" 17 | } 18 | 19 | 20 | def get_lastfm_data(artist: str, title: str): 21 | url = f"https://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key={config.MUSICVIDEO.lastfm_api_key}&artist={artist}&track={title}&format=json" 22 | response = requests.get(url) 23 | if response.status_code != 200: 24 | return None 25 | return response.json().get("track", None) 26 | 27 | 28 | def get_musicbrainz_release_date(artist: str, title: str): 29 | url = f"https://musicbrainz.org/ws/2/release/?query=artist:{artist} AND title:{title}&fmt=json" 30 | response = requests.get(url, headers=MUSICBRAINZ_HEADERS) 31 | if response.status_code != 200: 32 | return None 33 | try: 34 | return response.json()["releases"][0]["date"].split("-")[0] 35 | except: 36 | return None 37 | 38 | 39 | import xml.sax.saxutils as saxutils 40 | 41 | 42 | def create_nfo(artist: str, title: str, thumb_relative_path: str): 43 | safe_artist = saxutils.escape(artist) 44 | safe_title = saxutils.escape(title) 45 | 46 | lastfm_data = get_lastfm_data(artist, title) 47 | 48 | if not lastfm_data: 49 | return f""" 50 | 51 | 52 | {safe_title} 53 | {safe_artist} 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | true 62 | {thumb_relative_path} 63 | youtube 64 | 65 | """ 66 | 67 | genre_tags = "".join( 68 | f' {genre["name"]}\n' 69 | for genre in lastfm_data["toptags"]["tag"][:3] 70 | ).strip() 71 | 72 | year_tag = "" 73 | year = get_musicbrainz_release_date(artist, title) 74 | if year: 75 | year_tag = f"{year}" 76 | 77 | return f""" 78 | 79 | 80 | {safe_title} 81 | {safe_artist} 82 | 83 | 84 | {year_tag} 85 | 86 | 87 | 88 | 89 | true 90 | {genre_tags} 91 | 92 | {saxutils.escape(lastfm_data['artist']['name'])} 93 | {f"{lastfm_data['artist']['mbid']}" if 'mbid' in lastfm_data['artist'] else ''} 94 | 95 | {thumb_relative_path} 96 | youtube 97 | 98 | """ 99 | -------------------------------------------------------------------------------- /src/backend/utils/process_filter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | 4 | def _evaluate_numeric_condition(value: float, condition: str) -> bool: 5 | if "><" in condition: 6 | min_val, max_val = map(float, condition.split("><")) 7 | return min_val < value < max_val 8 | elif ">=" in condition: 9 | return value >= float(condition[2:]) 10 | elif "<=" in condition: 11 | return value <= float(condition[2:]) 12 | elif ">" in condition: 13 | return value > float(condition[1:]) 14 | elif "<" in condition: 15 | return value < float(condition[1:]) 16 | elif "!=" in condition: 17 | return value != float(condition[2:]) 18 | elif "==" in condition: 19 | return value == float(condition[2:]) 20 | return False 21 | 22 | 23 | def _evaluate_string_condition(value: str, condition: str) -> bool: 24 | condition = condition.casefold().strip() 25 | value = value.casefold().strip() 26 | 27 | def parse_condition(condition: str) -> bool: 28 | if "&&" in condition: 29 | parts = condition.split("&&") 30 | return all(parse_condition(part) for part in parts) 31 | elif "||" in condition: 32 | parts = condition.split("||") 33 | return any(parse_condition(part) for part in parts) 34 | elif "!" in condition: 35 | part = condition[1:] 36 | return part not in value 37 | else: 38 | return condition in value 39 | 40 | return parse_condition(condition) 41 | 42 | 43 | def _evaluate_condition(value: Any, condition: str) -> bool: 44 | if isinstance(value, (int, float)): 45 | return _evaluate_numeric_condition(float(value), condition) 46 | elif isinstance(value, str): 47 | return _evaluate_string_condition(value, condition) 48 | return False 49 | 50 | 51 | def _get_value_from_path(item: Dict[str, Any], path: str) -> Any: 52 | keys = path.split(".") 53 | for key in keys: 54 | if key not in item: 55 | return None 56 | item = item[key] 57 | return item 58 | 59 | 60 | def _process_value(value: Any) -> Any: 61 | if isinstance(value, list): 62 | return " ".join(map(str, value)) 63 | return value 64 | 65 | 66 | def filter_items( 67 | items: List[Dict[str, Any]], filters: List[Dict[str, str]] 68 | ) -> List[Dict[str, Any]]: 69 | filtered_items = [] 70 | 71 | for item in items: 72 | match = True 73 | for filter_criteria in filters: 74 | for path, condition in filter_criteria.items(): 75 | value = _get_value_from_path(item, path) 76 | if value is not None: 77 | value = _process_value(value) 78 | if value is None or not _evaluate_condition(value, condition): 79 | match = False 80 | break 81 | if not match: 82 | break 83 | if match: 84 | filtered_items.append(item) 85 | 86 | return filtered_items 87 | 88 | 89 | # Example filters: 90 | # filters = [ 91 | # {"sortTitle": "bill||ted"}, 92 | # {"ratings.imdb.votes": "1000><10000"}, 93 | # {"genres": "Comedy&&Adventure"} 94 | # ] 95 | -------------------------------------------------------------------------------- /src/backend/changelog.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "version": "0.2.4", 4 | "added": [], 5 | "removed": [], 6 | "changed": [ 7 | "Improve performance for Sonarr and Radarr workers" 8 | ] 9 | }, 10 | { 11 | "version": "0.2.3", 12 | "added": [], 13 | "removed": [], 14 | "changed": [ 15 | "Swipearr for Sonarr should support list exclusion", 16 | "Fix wrongly monitoring season 0 episodes", 17 | "Do not scan for unreleased episodes", 18 | "Spotify to mediaserver checks if it is a Spotify generated playlist" 19 | ] 20 | }, 21 | { 22 | "version": "0.2.2", 23 | "added": [], 24 | "removed": [], 25 | "changed": [ 26 | "It automatically searches again for not yet processed episodes" 27 | ] 28 | }, 29 | { 30 | "version": "0.2.1", 31 | "added": [ 32 | "Music Video; Also downloads subtitles and create an NFO file" 33 | ], 34 | "removed": [], 35 | "changed": [ 36 | "Config file; Added a new option to download subtitles", 37 | "Music video detection; The scene change detection is changed" 38 | ] 39 | }, 40 | { 41 | "version": "0.2.0", 42 | "added": [ 43 | "Spotify to Mediaserver; A new worker that converts your playlists to audio and video playlists", 44 | "Playlist to Music Video; A new worker that converts your playlists to music videos", 45 | "Music Video; A new system which can download music videos from Youtube (check legal issues)" 46 | ], 47 | "removed": [], 48 | "changed": [ 49 | "DynaArr; Moved the settings page to settings" 50 | ] 51 | }, 52 | { 53 | "version": "0.1.4", 54 | "added": [ 55 | "Debug logs, creating a file named DEBUG in the /data directory will enable debug logging" 56 | ], 57 | "removed": [], 58 | "changed": [ 59 | "Repo transferred to a different user", 60 | "Fixed; Favicon not showing", 61 | "Fixed; Config file not saving correctly", 62 | "Using a store for the webui" 63 | ] 64 | }, 65 | { 66 | "version": "0.1.3", 67 | "added": [ 68 | "SwipeArr; Tv shows now display total episode and season count", 69 | "DynaArr; Episode and season filters" 70 | ], 71 | "removed": [ 72 | "DynaArr; id, qualityProfileId, hasFile and monitored UI filters" 73 | ], 74 | "changed": [] 75 | }, 76 | { 77 | "version": "0.1.2", 78 | "added": [ 79 | "DynaArr; Checks if Arr instances are busy searching" 80 | ], 81 | "removed": [], 82 | "changed": [ 83 | "Fixed; Search requests going crazy", 84 | "Fixed; UI SwipeArr for devices with not enough height" 85 | ] 86 | }, 87 | { 88 | "version": "0.1.1", 89 | "added": [], 90 | "removed": [], 91 | "changed": [ 92 | "Fixed; API endpoints for Emby and Jellyfin", 93 | "Fixed; Media server info not saving" 94 | ] 95 | }, 96 | { 97 | "version": "0.1.0", 98 | "added": [ 99 | "SwipeArr; A Tinder like interface to manage your libraries", 100 | "DynaArr; An automated management system for Arr suite", 101 | "Tool - Delete unmonitored; Delete all unmonitored files" 102 | ], 103 | "removed": [], 104 | "changed": [] 105 | } 106 | ] -------------------------------------------------------------------------------- /src/frontend/src/utils/filterParser.js: -------------------------------------------------------------------------------- 1 | function _evaluate_numeric_condition(value, condition) { 2 | if (condition.includes('><')) { 3 | const [min_val, max_val] = condition.split('><').map(parseFloat); 4 | return min_val < value && value < max_val; 5 | } else if (condition.includes('>=')) { 6 | return value >= parseFloat(condition.slice(2)); 7 | } else if (condition.includes('<=')) { 8 | return value <= parseFloat(condition.slice(2)); 9 | } else if (condition.includes('>')) { 10 | return value > parseFloat(condition.slice(1)); 11 | } else if (condition.includes('<')) { 12 | return value < parseFloat(condition.slice(1)); 13 | } else if (condition.includes('!=')) { 14 | return value != parseFloat(condition.slice(2)); 15 | } else if (condition.includes('==')) { 16 | return value == parseFloat(condition.slice(2)); 17 | } 18 | return false; 19 | } 20 | 21 | function _evaluate_string_condition(value, condition) { 22 | condition = condition.toLowerCase().trim(); 23 | value = value.toLowerCase().trim(); 24 | 25 | function parse_condition(condition) { 26 | if (condition.includes('&&')) { 27 | return condition.split('&&').every(parse_condition); 28 | } else if (condition.includes('||')) { 29 | return condition.split('||').some(parse_condition); 30 | } else if (condition.includes('!')) { 31 | return !value.includes(condition.slice(1)); 32 | } else { 33 | return value.includes(condition); 34 | } 35 | } 36 | 37 | return parse_condition(condition); 38 | } 39 | 40 | function _evaluate_condition(value, condition) { 41 | if (typeof value === 'number') { 42 | return _evaluate_numeric_condition(value, condition); 43 | } else if (typeof value === 'string') { 44 | return _evaluate_string_condition(value, condition); 45 | } 46 | return false; 47 | } 48 | 49 | function _get_value_from_path(item, path) { 50 | const keys = path.split('.'); 51 | for (const key of keys) { 52 | if (!(key in item)) { 53 | return null; 54 | } 55 | item = item[key]; 56 | } 57 | return item; 58 | } 59 | 60 | function _process_value(value) { 61 | if (Array.isArray(value)) { 62 | return value.join(' '); 63 | } 64 | return value; 65 | } 66 | 67 | export function filter_items(items, filters) { 68 | const filtered_items = []; 69 | 70 | for (const item of items) { 71 | let match = true; 72 | for (const filter_criteria of filters) { 73 | for (const path in filter_criteria) { 74 | const condition = filter_criteria[path]; 75 | let value = _get_value_from_path(item, path); 76 | if (value !== null) { 77 | value = _process_value(value); 78 | } 79 | if (value === null || !_evaluate_condition(value, condition)) { 80 | match = false; 81 | break; 82 | } 83 | } 84 | if (!match) { 85 | break; 86 | } 87 | } 88 | if (match) { 89 | filtered_items.push(item); 90 | } 91 | } 92 | 93 | return filtered_items; 94 | } 95 | 96 | // Example filters: 97 | // const filters = [ 98 | // {"sortTitle": "bill||ted"}, 99 | // {"ratings.imdb.votes": "1000><10000"}, 100 | // {"genres": "Comedy&&Adventure"} 101 | // ]; 102 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/shazam_api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BytesIO 3 | 4 | import requests 5 | import shazamio 6 | from pydub import AudioSegment 7 | from random_user_agent.params import OperatingSystem 8 | from random_user_agent.user_agent import UserAgent 9 | 10 | from utils.music_video.validators import validate_youtube_title 11 | 12 | user_agent_rotator = UserAgent( 13 | operating_systems=[OperatingSystem.ANDROID.value, OperatingSystem.IOS.value], 14 | limit=100, 15 | ) 16 | 17 | 18 | def shazam_cdn_search(title: str, artists: list, track_id: int = 0): 19 | youtube_cdn_url = f'https://cdn.shazam.com/video/v3/-/GB/web/{track_id}/youtube/video?q={"+".join(artists)}+%22{title}%22' 20 | youtube_cdn_url = youtube_cdn_url.replace(" ", "+") 21 | 22 | HEADERS = { 23 | "X-Shazam-Platform": "IPHONE", 24 | "X-Shazam-AppVersion": "14.1.0", 25 | "Accept": "*/*", 26 | "Accept-Language": "en", 27 | "Accept-Encoding": "gzip, deflate", 28 | "User-Agent": user_agent_rotator.get_random_user_agent(), 29 | } 30 | 31 | cdn_data = requests.get(url=youtube_cdn_url, headers=HEADERS) 32 | if cdn_data.status_code != 200: 33 | return None 34 | 35 | cdn_data = cdn_data.json() 36 | youtube_url = cdn_data.get("actions", None)[0].get("uri", None) 37 | if youtube_url is not None and "youtu" in youtube_url: 38 | if not validate_youtube_title(cdn_data.get("caption"), title, artists): 39 | return None 40 | youtube_url = youtube_url.replace("https://youtu.be/", "") 41 | youtube_url = youtube_url.replace("?autoplay=1", "") 42 | return youtube_url 43 | else: 44 | return None 45 | 46 | 47 | def shazam_confirm_song( 48 | file_path: str, song_name: str, artists: list, album: str = None 49 | ): 50 | shazam = shazamio.Shazam() 51 | try: 52 | audio_data = None 53 | audio_segment = None 54 | 55 | # Read and decode mp4 file 56 | with open(file_path, "rb") as f: 57 | mp4_data = f.read() 58 | 59 | for i in range(3): 60 | if i == 0: 61 | audio_bytes = mp4_data 62 | else: 63 | if not audio_data: 64 | audio_data = BytesIO(mp4_data) 65 | audio_segment = AudioSegment.from_file(audio_data, format="mp4") 66 | 67 | if i == 1: 68 | half_audio = audio_segment[5_000:10_000] 69 | elif i == 2: 70 | half_audio = audio_segment[30_000:42_000] 71 | else: 72 | raise Exception("This many loops is not implemented yet") 73 | 74 | buffer = half_audio.export(format="ogg") 75 | buffer = BytesIO() 76 | half_audio.export(buffer, format="mp4") 77 | audio_bytes = buffer.getvalue() 78 | 79 | recognized_track_info = asyncio.run(shazam.recognize(audio_bytes)) 80 | track_title = recognized_track_info.get("track", {}).get("title", None) 81 | track_artist = recognized_track_info.get("track", {}).get("subtitle", None) 82 | if validate_youtube_title( 83 | f"{track_title} {track_artist}", song_name, artists 84 | ): 85 | return True 86 | return False 87 | except Exception as e: 88 | return None 89 | -------------------------------------------------------------------------------- /src/frontend/src/stores/mediainfo.js: -------------------------------------------------------------------------------- 1 | // stores/mediaStore.js 2 | import { defineStore } from 'pinia'; 3 | import {reactive, ref} from 'vue'; 4 | 5 | export const useMediaStore = defineStore('mediaStore', () => { 6 | const radarrItems = ref(null); 7 | const sonarrItems = ref(null); 8 | const radarrInfo = ref({ 9 | quality_profiles: [], 10 | tags: [] 11 | }); 12 | 13 | const sonarrInfo = ref({ 14 | quality_profiles: [], 15 | tags: [] 16 | }); 17 | const mediaInfo = ref( 18 | { 19 | users: [], 20 | } 21 | ); 22 | 23 | const fetched = reactive({ 24 | radarrItems: false, 25 | sonarrItems: false, 26 | radarrInfo: false, 27 | sonarrInfo: false, 28 | mediaInfo: false 29 | }); 30 | 31 | async function fetchRadarrItems(force = false) { 32 | if (!fetched.radarrItems || force) { 33 | const response = await fetch('/api/radarr/items'); 34 | radarrItems.value = await response.json(); 35 | fetched.radarrItems = true; 36 | } 37 | } 38 | 39 | async function fetchSonarrItems(force = false) { 40 | if (!fetched.sonarrItems || force) { 41 | const response = await fetch('/api/sonarr/items'); 42 | sonarrItems.value = await response.json(); 43 | fetched.sonarrItems = true; 44 | } 45 | } 46 | 47 | async function fetchRadarrInfo(force = false) { 48 | if (!fetched.radarrInfo || force) { 49 | const response = await fetch('/api/radarr/info'); 50 | radarrInfo.value = await response.json(); 51 | fetched.radarrInfo = true; 52 | } 53 | } 54 | 55 | async function fetchSonarrInfo(force = false) { 56 | if (!fetched.sonarrInfo || force) { 57 | const response = await fetch('/api/sonarr/info'); 58 | sonarrInfo.value = await response.json(); 59 | fetched.sonarrInfo = true; 60 | } 61 | } 62 | 63 | async function fetchMediaInfo() { 64 | if (!fetched.mediaInfo) { 65 | const response = await fetch('/api/mediaserver/media-info'); 66 | mediaInfo.value = await response.json(); 67 | fetched.mediaInfo = true; 68 | updateItemsWithMediaInfo(); 69 | } 70 | } 71 | 72 | function updateItemsWithMediaInfo() { 73 | if (mediaInfo.value) { 74 | const { movies, series } = mediaInfo.value; 75 | 76 | if (radarrItems.value) { 77 | radarrItems.value.forEach(item => { 78 | item.favorited = !!movies.favorited.includes(item.id); 79 | item.played = !!movies.played.includes(item.id); 80 | }); 81 | } 82 | 83 | if (sonarrItems.value) { 84 | sonarrItems.value.forEach(item => { 85 | item.favorited = !!series.favorited.includes(item.id); 86 | item.played = !!series.played.includes(item.id); 87 | }); 88 | } 89 | } 90 | } 91 | 92 | async function fetchAllData() { 93 | await Promise.all([ 94 | fetchRadarrItems(), 95 | fetchSonarrItems(), 96 | fetchRadarrInfo(), 97 | fetchSonarrInfo(), 98 | fetchMediaInfo() 99 | ]); 100 | } 101 | 102 | return { 103 | radarrItems, 104 | sonarrItems, 105 | radarrInfo, 106 | sonarrInfo, 107 | mediaInfo, 108 | fetchRadarrItems, 109 | fetchSonarrItems, 110 | fetchRadarrInfo, 111 | fetchSonarrInfo, 112 | fetchMediaInfo, 113 | fetchAllData, 114 | }; 115 | }); 116 | -------------------------------------------------------------------------------- /src/frontend/src/pages/tools.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 115 | -------------------------------------------------------------------------------- /src/backend/routers/radarr.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Query 4 | from fastapi.responses import JSONResponse 5 | from pyarr import RadarrAPI 6 | 7 | import schemas.settings as settings 8 | from utils.config_manager import ConfigManager 9 | from utils.log_manager import LoggingManager 10 | from workers.radarr import delete_unmonitored_files 11 | from workers.radarr import run as radarr_run 12 | 13 | # region Configuration and Setup 14 | router = APIRouter(prefix="/radarr", tags=["Radarr"]) 15 | config_manager = ConfigManager() 16 | config = config_manager.get_config() 17 | radarr = RadarrAPI(config.RADARR.base_url, config.RADARR.api_key) 18 | logging_manager = LoggingManager() 19 | 20 | 21 | # endregion 22 | 23 | 24 | @router.get("/info") 25 | def get_info(): 26 | config = config_manager.get_config() 27 | radarr = RadarrAPI(config.RADARR.base_url, config.RADARR.api_key) 28 | quality_profiles = [ 29 | {k: v for k, v in d.items() if k in ["name", "id"]} 30 | for d in radarr.get_quality_profile() 31 | ] 32 | tags = [ 33 | {k: v for k, v in d.items() if k in ["label", "id"]} for d in radarr.get_tag() 34 | ] 35 | 36 | return {"quality_profiles": quality_profiles, "tags": tags} 37 | 38 | 39 | @router.get("/items", description="Get all movies from Radarr") 40 | def get_items(): 41 | config = config_manager.get_config() 42 | radarr = RadarrAPI(config.RADARR.base_url, config.RADARR.api_key) 43 | return radarr.get_movie() 44 | 45 | 46 | @router.post("/item", description="Edit an item") 47 | def edit_item(item: dict): 48 | return radarr.upd_movie(item) 49 | 50 | 51 | @router.delete("/item", description="Delete an item") 52 | def delete_item( 53 | id: int = Query(..., description="ID of the item to delete"), 54 | importListExclusion: bool = Query( 55 | True, description="Whether to add the item to import list exclusion" 56 | ), 57 | deleteFiles: bool = Query(False, description="Whether to delete associated files"), 58 | ): 59 | radarr.del_movie(id, delete_files=deleteFiles, add_exclusion=importListExclusion) 60 | 61 | return {"message": f"Item with ID {id} deleted successfully."} 62 | 63 | 64 | @router.get( 65 | "/settings", 66 | response_model=settings.RadarrSettings, 67 | description="Get Radarr settings", 68 | ) 69 | def get_settings() -> settings.RadarrSettings: 70 | config = config_manager.get_config() 71 | return config.RADARR 72 | 73 | 74 | @router.post("/settings", description="Update Radarr settings") 75 | def post_settings(settings: settings.RadarrSettings): 76 | logging_manager.log("Updating Radarr settings", level=logging.DEBUG) 77 | config = config_manager.get_config() 78 | config.RADARR = settings 79 | config_manager.save_config_file(config) 80 | 81 | 82 | @router.get("/dry", description="Get the results of a dry run") 83 | def get_dry_run() -> JSONResponse: 84 | return JSONResponse(radarr_run(dry=True)) 85 | 86 | 87 | @router.get( 88 | "/delete-unmonitored", description="Get the results of a dry run of deleting" 89 | ) 90 | def get_dry_run_delete() -> JSONResponse: 91 | return JSONResponse(delete_unmonitored_files(dry=True)) 92 | 93 | 94 | @router.delete( 95 | "/delete-unmonitored", 96 | description="Delete the unmonitored files WARNING, no cancel...", 97 | ) 98 | def delete_unmonitored_files_call() -> JSONResponse: 99 | return JSONResponse(delete_unmonitored_files()) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # More ARR tools 2 | Welcome this repo contains tools that I use to manage my media library. Main tools are Dynarr and Swipearr. But there are more tools which are unlisted here. 3 | 4 | ## Dynarr 5 | Dynarr helps manage the quality and organization of your files within Sonarr and Radarr, ensuring your media library is always optimized to your preferences. 6 | 7 | ### Features 8 | - **Quality Profiles**: Automatically assign different quality profiles based on popularity and user preferences. 9 | - Ultra, High, Normal, Low, Favorited, On-Resume, Very Popular, Popular, Less Popular, Unpopular 10 | - **Quality Upgrades**: Search and upgrade files to better quality versions. 11 | - **Decay Management**: Automatically manage file retention based on specified decay days for various categories like favorites, suggested, popular, etc. 12 | - **Monitoring**: Dynamically monitor or unmonitor files based on filters and user-defined criteria. 13 | - **Exclusions**: Define tags and users to exclude from quality upgrades, monitoring, and deletions. 14 | - **Popular Filters**: Customize filters for different popularity categories to better manage your collection. 15 | - **Integration with Media Servers**: Create and manage collections in Emby/Jellyfin. Support for Plex can be added by editing `/utils/media_server_interaction.py`. 16 | 17 | ## Swipearr 18 | Swipearr offers a Tinder-like interface for browsing your media collections, supporting desktop, mobile, and touchscreen devices. 19 | 20 | ### Features 21 | - **Cross-Platform Compatibility**: Supports desktop and mobile devices. 22 | - **Interaction Methods**: Use keyboard shortcuts, touchscreen, mouse, or swiping to browse your collections. 23 | - **Web config**: Use a web interface to configure things. 24 | 25 | ## Screenshots 26 | ![Desktop Swipe Arr](screenshots/desktop_swipearr.png) 27 | ![Mobile Swipe Arr - A](screenshots/mobile_swipearr_a.png) 28 | ![Mobile Swipe Arr - B](screenshots/mobile_swipearr_b.png) 29 | ![Mobile Settings](screenshots/mobile_settings.png) 30 | 31 | 32 | ## Install 33 | Unraid: 34 | Use the community app store 35 | 36 | Docker: 37 | ```sh 38 | docker run --name arr-tools -p 9000:9000 -v $(pwd)/data:/app/backend/data ghcr.io/l4stidi0t/arr-tools/main:latest 39 | ``` 40 | 41 | Docker compose: 42 | ```yml 43 | services: 44 | arr-tools: 45 | image: ghcr.io/l4stidi0t/arr-tools/main:latest 46 | container_name: arr-tools 47 | ports: 48 | - "9000:9000" 49 | volumes: 50 | - ./data:/app/backend/data 51 | ``` 52 | 53 | ## Recommended things 54 | ### Tools 55 | * [Recyclarr](https://github.com/recyclarr/recyclarr): creating Quality Profiles 56 | * [Listrr](https://listrr.pro/): creating import lists 57 | * [MDBList](https://mdblist.com): creating import lists 58 | 59 | ## Want to-do: 60 | * Decaying watched episodes instead of instant delete 61 | * Expanded tag exclusion like disable unmonitoring 62 | * Expand filters and usage 63 | * Web configurable running intervals 64 | * Mass items selector(Arrs already have this, but you need to hit a small radio button) 65 | * Import/export playlists from external tools like spotify 66 | * Convert music audio playlist to music video playlist 67 | * Support Lidarr 68 | 69 | ## Possible issues: 70 | * Large libraries might be slow, especially Sonarr(tested on 2000 series library) 71 | * Deletion does not work if ARR can not delete items 72 | * If you use multiple ARR instances like high and low quality instances, I did not have any problems 73 | 74 | ## Contribution 75 | Plex support will not be added by me, someone can change `/utils/media_server_interaction.py` to support Plex. 76 | -------------------------------------------------------------------------------- /src/backend/models/media.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from sqlalchemy import Column, Integer, String, VARCHAR, TypeDecorator 4 | from sqlalchemy.orm import declarative_base 5 | 6 | Base = declarative_base() 7 | 8 | 9 | class JSONEncodedDict(TypeDecorator): 10 | """Enables JSON storage by encoding and decoding on the fly.""" 11 | 12 | impl = VARCHAR 13 | cache_ok = True 14 | 15 | def process_bind_param(self, value, dialect): 16 | if value is not None: 17 | if isinstance(value, str): 18 | return value 19 | 20 | value = json.dumps(value) 21 | return value 22 | 23 | def process_result_value(self, value, dialect): 24 | if value is not None: 25 | value = json.loads(value) 26 | return value 27 | 28 | 29 | class FavoriteMovies(Base): 30 | __tablename__ = "favoriteMovies" 31 | 32 | id = Column(Integer, primary_key=True) 33 | name = Column(String, nullable=False) 34 | mediaId = Column(Integer, nullable=False) 35 | 36 | userIds = Column(JSONEncodedDict) 37 | date = Column(Integer, nullable=False) 38 | 39 | 40 | class FavoriteSeries(Base): 41 | __tablename__ = "favoriteSeries" 42 | 43 | id = Column(Integer, primary_key=True) 44 | name = Column(String, nullable=False) 45 | mediaId = Column(Integer, nullable=False) 46 | 47 | userIds = Column(JSONEncodedDict) 48 | date = Column(Integer, nullable=False) 49 | 50 | 51 | class OnResumeMovies(Base): 52 | __tablename__ = "onResumeMovies" 53 | 54 | id = Column(Integer, primary_key=True) 55 | name = Column(String, nullable=False) 56 | mediaId = Column(Integer, nullable=False) 57 | 58 | userIds = Column(JSONEncodedDict) 59 | date = Column(Integer, nullable=False) 60 | 61 | 62 | class OnResumeSeries(Base): 63 | __tablename__ = "onResumeSeries" 64 | 65 | id = Column(Integer, primary_key=True) 66 | name = Column(String, nullable=False) 67 | mediaId = Column(Integer, nullable=False) 68 | 69 | userIds = Column(JSONEncodedDict) 70 | date = Column(Integer, nullable=False) 71 | 72 | 73 | class PlayedMovies(Base): 74 | __tablename__ = "PlayedMovies" 75 | 76 | id = Column(Integer, primary_key=True) 77 | name = Column(String, nullable=False) 78 | mediaId = Column(Integer, nullable=False) 79 | 80 | userIds = Column(JSONEncodedDict) 81 | date = Column(Integer, nullable=False) 82 | 83 | 84 | class PlayedSeries(Base): 85 | __tablename__ = "PlayedSeries" 86 | 87 | id = Column(Integer, primary_key=True) 88 | name = Column(String, nullable=False) 89 | mediaId = Column(Integer, nullable=False) 90 | 91 | userIds = Column(JSONEncodedDict) 92 | date = Column(Integer, nullable=False) 93 | 94 | 95 | class PlayedEpisodes(Base): 96 | __tablename__ = "PlayedEpisodes" 97 | 98 | id = Column(Integer, primary_key=True) 99 | name = Column(String, nullable=False) 100 | mediaId = Column(String, nullable=False) 101 | 102 | userIds = Column(JSONEncodedDict) 103 | date = Column(Integer, nullable=False) 104 | 105 | 106 | class musicVideoCache(Base): 107 | __tablename__ = "musicVideoCache" 108 | 109 | id = Column(Integer, primary_key=True) 110 | youtubeId = Column(String) 111 | downloadError = Column(Integer, default=0) 112 | title = Column(String, nullable=False) 113 | artists = Column(JSONEncodedDict, nullable=False) 114 | album = Column(String) 115 | dateAdded = Column(Integer, nullable=False) 116 | additionalInfo = Column(JSONEncodedDict) 117 | -------------------------------------------------------------------------------- /src/backend/routers/sonarr.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter, Query 4 | from fastapi.responses import JSONResponse 5 | from pyarr import SonarrAPI 6 | 7 | import schemas.settings as settings 8 | from utils.config_manager import ConfigManager 9 | from utils.customSonarApi import customSonarAPI 10 | from utils.log_manager import LoggingManager 11 | from workers.sonarr import delete_unmonitored_files 12 | from workers.sonarr import run as sonarr_run 13 | 14 | # region Configuration and Setup 15 | router = APIRouter(prefix="/sonarr", tags=["Sonarr"]) 16 | config_manager = ConfigManager() 17 | config = config_manager.get_config() 18 | sonarr = customSonarAPI(config.SONARR.base_url, config.SONARR.api_key) 19 | logging_manager = LoggingManager() 20 | 21 | 22 | # endregion 23 | 24 | 25 | @router.get("/info") 26 | def get_info(): 27 | config = config_manager.get_config() 28 | sonarr = SonarrAPI(config.SONARR.base_url, config.SONARR.api_key) 29 | quality_profiles = [ 30 | {k: v for k, v in d.items() if k in ["name", "id"]} 31 | for d in sonarr.get_quality_profile() 32 | ] 33 | tags = [ 34 | {k: v for k, v in d.items() if k in ["label", "id"]} for d in sonarr.get_tag() 35 | ] 36 | 37 | return {"quality_profiles": quality_profiles, "tags": tags} 38 | 39 | 40 | @router.get("/items", description="Get all series from Sonarr") 41 | def get_items(): 42 | config = config_manager.get_config() 43 | sonarr = SonarrAPI(config.SONARR.base_url, config.SONARR.api_key) 44 | return sonarr.get_series() 45 | 46 | 47 | @router.post("/item", description="Edit an item") 48 | def edit_item(item: dict): 49 | return sonarr.upd_series(item) 50 | 51 | 52 | @router.delete("/item", description="Delete an item") 53 | def delete_item( 54 | id: int = Query(..., description="ID of the item to delete"), 55 | importListExclusion: bool = Query( 56 | True, description="Whether to add the item to import list exclusion" 57 | ), 58 | deleteFiles: bool = Query(True, description="Whether to delete associated files"), 59 | ): 60 | sonarr.del_series( 61 | id, delete_files=deleteFiles, add_import_list_exclusion=importListExclusion 62 | ) 63 | 64 | return {"message": f"Item with ID {id} deleted successfully."} 65 | 66 | 67 | @router.get( 68 | "/settings", 69 | response_model=settings.SonarrSettings, 70 | description="Get Sonarr settings", 71 | ) 72 | def get_settings() -> settings.SonarrSettings: 73 | config = config_manager.get_config() 74 | return config.SONARR 75 | 76 | 77 | @router.post("/settings", description="Update Sonarr settings") 78 | def post_settings(settings: settings.SonarrSettings): 79 | logging_manager.log("Updating Sonarr settings", level=logging.DEBUG) 80 | config = config_manager.get_config() 81 | config.SONARR = settings 82 | config_manager.save_config_file(config) 83 | 84 | 85 | @router.get("/dry", description="Get the results of a dry run") 86 | def get_dry_run() -> JSONResponse: 87 | return JSONResponse(sonarr_run(dry=True)) 88 | 89 | 90 | @router.get( 91 | "/delete-unmonitored", description="Get the results of a dry run of deleting" 92 | ) 93 | def get_dry_run_delete() -> JSONResponse: 94 | return JSONResponse(delete_unmonitored_files(dry=True)) 95 | 96 | 97 | @router.delete( 98 | "/delete-unmonitored", 99 | description="Delete the unmonitored files WARNING, no cancel...", 100 | ) 101 | def delete_unmonitored_files_call() -> JSONResponse: 102 | return JSONResponse(delete_unmonitored_files()) 103 | -------------------------------------------------------------------------------- /src/backend/.gitignore: -------------------------------------------------------------------------------- 1 | **__pycache__ 2 | /static/* 3 | /data 4 | 5 | # Editor directories and files 6 | .idea 7 | .vscode 8 | *.suo 9 | *.ntvs* 10 | *.njsproj 11 | *.sln 12 | *.sw? 13 | 14 | /testing.py 15 | 16 | # Created by https://www.toptal.com/developers/gitignore/api/pycharm+all,windows,linux,venv 17 | # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm+all,windows,linux,venv 18 | 19 | ### Linux ### 20 | *~ 21 | 22 | # temporary files which can be created if a process still has a handle open of a deleted file 23 | .fuse_hidden* 24 | 25 | # KDE directory preferences 26 | .directory 27 | 28 | # Linux trash folder which might appear on any partition or disk 29 | .Trash-* 30 | 31 | # .nfs files are created when an open file is removed but is still being accessed 32 | .nfs* 33 | 34 | ### PyCharm+all ### 35 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 36 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 37 | 38 | # User-specific stuff 39 | .idea/**/workspace.xml 40 | .idea/**/tasks.xml 41 | .idea/**/usage.statistics.xml 42 | .idea/**/dictionaries 43 | .idea/**/shelf 44 | 45 | # AWS User-specific 46 | .idea/**/aws.xml 47 | 48 | # Generated files 49 | .idea/**/contentModel.xml 50 | 51 | # Sensitive or high-churn files 52 | .idea/**/dataSources/ 53 | .idea/**/dataSources.ids 54 | .idea/**/dataSources.local.xml 55 | .idea/**/sqlDataSources.xml 56 | .idea/**/dynamic.xml 57 | .idea/**/uiDesigner.xml 58 | .idea/**/dbnavigator.xml 59 | 60 | # Gradle 61 | .idea/**/gradle.xml 62 | .idea/**/libraries 63 | 64 | # Gradle and Maven with auto-import 65 | # When using Gradle or Maven with auto-import, you should exclude module files, 66 | # since they will be recreated, and may cause churn. Uncomment if using 67 | # auto-import. 68 | # .idea/artifacts 69 | # .idea/compiler.xml 70 | # .idea/jarRepositories.xml 71 | # .idea/modules.xml 72 | # .idea/*.iml 73 | # .idea/modules 74 | # *.iml 75 | # *.ipr 76 | 77 | # CMake 78 | cmake-build-*/ 79 | 80 | # Mongo Explorer plugin 81 | .idea/**/mongoSettings.xml 82 | 83 | # File-based project format 84 | *.iws 85 | 86 | # IntelliJ 87 | out/ 88 | 89 | # mpeltonen/sbt-idea plugin 90 | .idea_modules/ 91 | 92 | # JIRA plugin 93 | atlassian-ide-plugin.xml 94 | 95 | # Cursive Clojure plugin 96 | .idea/replstate.xml 97 | 98 | # SonarLint plugin 99 | .idea/sonarlint/ 100 | 101 | # Crashlytics plugin (for Android Studio and IntelliJ) 102 | com_crashlytics_export_strings.xml 103 | crashlytics.properties 104 | crashlytics-build.properties 105 | fabric.properties 106 | 107 | # Editor-based Rest Client 108 | .idea/httpRequests 109 | 110 | # Android studio 3.1+ serialized cache file 111 | .idea/caches/build_file_checksums.ser 112 | 113 | ### PyCharm+all Patch ### 114 | # Ignore everything but code style settings and run configurations 115 | # that are supposed to be shared within teams. 116 | 117 | .idea/* 118 | 119 | !.idea/codeStyles 120 | !.idea/runConfigurations 121 | 122 | ### venv ### 123 | # Virtualenv 124 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 125 | .Python 126 | [Bb]in 127 | [Ii]nclude 128 | [Ll]ib 129 | [Ll]ib64 130 | [Ll]ocal 131 | [Ss]cripts 132 | pyvenv.cfg 133 | .venv 134 | pip-selfcheck.json 135 | 136 | ### Windows ### 137 | # Windows thumbnail cache files 138 | Thumbs.db 139 | Thumbs.db:encryptable 140 | ehthumbs.db 141 | ehthumbs_vista.db 142 | 143 | # Dump file 144 | *.stackdump 145 | 146 | # Folder config file 147 | [Dd]esktop.ini 148 | 149 | # Recycle Bin used on file shares 150 | $RECYCLE.BIN/ 151 | 152 | # Windows Installer files 153 | *.cab 154 | *.msi 155 | *.msix 156 | *.msm 157 | *.msp 158 | 159 | # Windows shortcuts 160 | *.lnk 161 | 162 | # End of https://www.toptal.com/developers/gitignore/api/pycharm+all,windows,linux,venv 163 | /.cache 164 | /musicVideoOutput/ 165 | /temp/ 166 | -------------------------------------------------------------------------------- /src/backend/routers/mediaserver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import APIRouter 4 | from pyarr import RadarrAPI 5 | 6 | import schemas.settings as settings 7 | from utils.config_manager import ConfigManager 8 | from utils.customSonarApi import customSonarAPI 9 | from utils.general_arr_actions import link_arr_to_media_server 10 | from utils.log_manager import LoggingManager 11 | from utils.media_server_interaction import MediaServerinteracter 12 | 13 | # region Configuration and Setup 14 | router = APIRouter(prefix="/mediaserver", tags=["Media server"]) 15 | config_manager = ConfigManager() 16 | config = config_manager.get_config() 17 | logging_manager = LoggingManager() 18 | 19 | 20 | # endregion 21 | 22 | 23 | @router.get("/info") 24 | def get_info(): 25 | config = config_manager.get_config() 26 | media_server = MediaServerinteracter( 27 | config.MEDIASERVER.media_server_type, 28 | config.MEDIASERVER.media_server_base_url, 29 | config.MEDIASERVER.media_server_api_key, 30 | ) 31 | users = media_server.get_users() 32 | 33 | return { 34 | "users": users, 35 | } 36 | 37 | 38 | @router.get("/media-info") 39 | def get_media_info(): 40 | config = config_manager.get_config() 41 | media_server = MediaServerinteracter( 42 | config.MEDIASERVER.media_server_type, 43 | config.MEDIASERVER.media_server_base_url, 44 | config.MEDIASERVER.media_server_api_key, 45 | ) 46 | sonarr = customSonarAPI(config.SONARR.base_url, config.SONARR.api_key) 47 | radarr = RadarrAPI(config.RADARR.base_url, config.RADARR.api_key) 48 | series = sonarr.get_series() 49 | movies = radarr.get_movie() 50 | 51 | favorites = media_server.get_all_favorites() 52 | played = media_server.get_played() 53 | 54 | favorited_sonarr = list( 55 | set( 56 | arr_item["id"] 57 | for item in favorites["Series"] 58 | if (arr_item := link_arr_to_media_server(item, series)) is not None 59 | ) 60 | ) 61 | played_sonarr = list( 62 | set( 63 | arr_item["id"] 64 | for item in played["Series"] 65 | if (arr_item := link_arr_to_media_server(item, series)) is not None 66 | ) 67 | ) 68 | favorited_radarr = list( 69 | set( 70 | arr_item["id"] 71 | for item in favorites["Movies"] 72 | if (arr_item := link_arr_to_media_server(item, movies)) is not None 73 | ) 74 | ) 75 | played_radarr = list( 76 | set( 77 | arr_item["id"] 78 | for item in played["Movies"] 79 | if (arr_item := link_arr_to_media_server(item, movies)) is not None 80 | ) 81 | ) 82 | 83 | return { 84 | "series": {"favorited": favorited_sonarr, "played": played_sonarr}, 85 | "movies": {"favorited": favorited_radarr, "played": played_radarr}, 86 | } 87 | 88 | 89 | @router.get( 90 | "/settings", 91 | response_model=settings.MediaServerSettings, 92 | description="Get mediaserver settings", 93 | ) 94 | def get_settings() -> settings.MediaServerSettings: 95 | config = config_manager.get_config() 96 | return config.MEDIASERVER 97 | 98 | 99 | @router.post("/settings", description="Update Radarr settings") 100 | def post_settings(settings: settings.MediaServerSettings): 101 | logging_manager.log("Updating Mediaserver settings", level=logging.DEBUG) 102 | config = config_manager.get_config() 103 | config.MEDIASERVER = settings 104 | config_manager.save_config_file(config) 105 | 106 | 107 | @router.get("/playlists", description="Get all playlists from the media server") 108 | def get_playlists(): 109 | config = config_manager.get_config() 110 | media_server = MediaServerinteracter( 111 | config.MEDIASERVER.media_server_type, 112 | config.MEDIASERVER.media_server_base_url, 113 | config.MEDIASERVER.media_server_api_key, 114 | ) 115 | 116 | return media_server.get_playlist_items() 117 | 118 | 119 | # @router.get("/dry", description="Get the results of a dry run") 120 | # def get_dry_run() -> JSONResponse: 121 | # return JSONResponse(radarr_run(dry=True)) 122 | -------------------------------------------------------------------------------- /src/frontend/src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 92 | 93 | 129 | -------------------------------------------------------------------------------- /src/backend/routers/spotify.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | import spotipy 5 | from fastapi import APIRouter, Query 6 | from fastapi import HTTPException 7 | from spotipy.oauth2 import SpotifyClientCredentials 8 | 9 | import schemas.settings as settings 10 | from utils.config_manager import ConfigManager 11 | from utils.log_manager import LoggingManager 12 | 13 | # region Configuration and Setup 14 | router = APIRouter(prefix="/spotify", tags=["Spotify"]) 15 | config_manager = ConfigManager() 16 | config = config_manager.get_config() 17 | logging_manager = LoggingManager() 18 | 19 | 20 | # endregion 21 | 22 | 23 | @router.get( 24 | "/settings", 25 | response_model=settings.SpotifySettings, 26 | description="Get Spotify settings", 27 | ) 28 | def get_settings() -> settings.SpotifySettings: 29 | config = config_manager.get_config() 30 | return config.SPOTIFY 31 | 32 | 33 | @router.post("/settings", description="Update Spotify settings") 34 | def post_settings(settings: settings.SpotifySettings): 35 | logging_manager.log("Updating Spotify settings", level=logging.DEBUG) 36 | config = config_manager.get_config() 37 | config.SPOTIFY = settings 38 | config_manager.save_config_file(config) 39 | 40 | return HTTPException(status_code=200, detail="Spotify settings updated") 41 | 42 | 43 | @router.get("/playlists", description="Get all playlists for conversion") 44 | def get_playlists(): 45 | config = config_manager.get_config() 46 | return config.SPOTIFY.playlists 47 | 48 | 49 | def get_playlist_id(playlist_url_id: str): 50 | pattern = r"\/playlist\/(\w+)" 51 | match = re.search(pattern, playlist_url_id) 52 | if match: 53 | playlist_id = match.group(1) 54 | else: 55 | playlist_id = playlist_url_id 56 | return playlist_id 57 | 58 | 59 | @router.put("/playlist", description="Add a playlist for conversion") 60 | def put_playlist( 61 | playlist_url_id: str = Query(..., description="Spotify playlist URL or ID"), 62 | playlist_type: str = Query( 63 | ..., description="Type of playlist: audio, video, or both" 64 | ), 65 | ): 66 | """ 67 | Add a playlist for conversion. 68 | 69 | Args: 70 | playlist_url_id (str): Spotify playlist URL or ID. 71 | playlist_type (str): Type of playlist. Can be 'audio', 'video', or 'both'. 72 | 73 | Raises: 74 | HTTPException: If the playlist already exists or if there are issues with the 75 | client ID/secret or finding the playlist. 76 | """ 77 | 78 | config = config_manager.get_config() 79 | 80 | playlist_id = get_playlist_id(playlist_url_id) 81 | 82 | for playlist in config.SPOTIFY.playlists: 83 | if playlist["id"] == playlist_id: 84 | raise HTTPException( 85 | status_code=400, 86 | detail=f"Playlist already exists {playlist['name']}, ID: {playlist_id}", 87 | ) 88 | 89 | try: 90 | auth_manager = SpotifyClientCredentials( 91 | client_id=config.SPOTIFY.client_id, 92 | client_secret=config.SPOTIFY.client_secret, 93 | ) 94 | sp = spotipy.Spotify(auth_manager=auth_manager) 95 | except: 96 | raise HTTPException( 97 | status_code=400, detail="Client ID and/or secret are incorrect" 98 | ) 99 | 100 | try: 101 | playlist_name = sp.playlist(playlist_id)["name"] 102 | except: 103 | raise HTTPException( 104 | status_code=400, detail="Can not find playlist, is it a private playlist?" 105 | ) 106 | 107 | config.SPOTIFY.playlists.append( 108 | {"id": playlist_id, "type": playlist_type, "name": playlist_name} 109 | ) 110 | config_manager.save_config_file(config) 111 | 112 | return HTTPException( 113 | status_code=200, detail=f"Playlist added {playlist_name}, ID: {playlist_id}" 114 | ) 115 | 116 | 117 | @router.delete("/playlist", description="Delete a playlist from the conversion list") 118 | def delete_playlist(playlist_url_id: str): 119 | config = config_manager.get_config() 120 | 121 | playlist_id = get_playlist_id(playlist_url_id) 122 | 123 | for playlist in config.SPOTIFY.playlists: 124 | if playlist["id"] == playlist_id: 125 | config.SPOTIFY.playlists.remove(playlist) 126 | config_manager.save_config_file(config) 127 | return HTTPException( 128 | status_code=200, 129 | detail=f"Playlist deleted {playlist['name']}, ID: {playlist_id}", 130 | ) 131 | 132 | raise HTTPException(status_code=400, detail=f"Playlist not found {playlist_id}") 133 | -------------------------------------------------------------------------------- /src/backend/utils/custom_emby_api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class EmbyAPI: 5 | def __init__(self, api_key, base_url): 6 | self.api_key = api_key 7 | self.base_url = base_url 8 | 9 | def _url_builder(self, endpoint, **kwargs): 10 | url = self.base_url + endpoint 11 | if len(kwargs) > 0: 12 | url += "?" 13 | for key, value in kwargs.items(): 14 | url += f"{key}={value}&" 15 | url = url[:-1] 16 | return url 17 | 18 | def _url_extender(self, url, **kwargs): 19 | if len(kwargs) > 0: 20 | for key, value in kwargs.items(): 21 | url += f"&{key}={value}" 22 | url = url[:-1] 23 | return url 24 | 25 | def _get_request(self, requested_url, method="GET"): 26 | if method == "GET": 27 | response = requests.get( 28 | requested_url, headers={"X-Emby-Token": self.api_key} 29 | ) 30 | elif method == "POST": 31 | response = requests.post( 32 | requested_url, headers={"X-Emby-Token": self.api_key} 33 | ) 34 | elif method == "DELETE": 35 | response = requests.delete( 36 | requested_url, headers={"X-Emby-Token": self.api_key} 37 | ) 38 | else: 39 | raise Exception("Method not supported") 40 | 41 | if response.status_code == 200: 42 | return response.json() 43 | elif response.status_code == 204: 44 | return None 45 | else: 46 | print(f"Error: {response.status_code}") 47 | print(response.text) 48 | print(response.url) 49 | return None 50 | 51 | def test_connection(self): 52 | url = self._url_builder("/System/Info") 53 | return self._get_request(url) 54 | 55 | def get_users(self): 56 | url = self._url_builder("/Users/Query") 57 | return self._get_request(url)["Items"] 58 | 59 | def get_items(self, recursive=True, **kwargs): 60 | url = self._url_builder("/Items", recursive=recursive, **kwargs) 61 | 62 | return self._get_request(url)["Items"] 63 | 64 | def get_user_items(self, user_id, recursive=True, **kwargs): 65 | url = self._url_builder( 66 | f"/Users/{user_id}/Items", recursive=recursive, **kwargs 67 | ) 68 | 69 | return self._get_request(url)["Items"] 70 | 71 | def get_user_favorites(self, user_id, types=["Movie", "Series"]): 72 | url_types = "".join(f"{item}%2C" for item in types) 73 | url = self._url_builder( 74 | f"/Users/{user_id}/Items", 75 | recursive=True, 76 | Filters="IsFavorite", 77 | IncludeItemTypes=url_types, 78 | ) 79 | return self._get_request(url)["Items"] 80 | 81 | def get_user_resume(self, user_id): 82 | url = self._url_builder( 83 | f"/Users/{user_id}/Items/Resume", recursive=True, MediaTypes="Video" 84 | ) 85 | 86 | return self._get_request(url)["Items"] 87 | 88 | def get_user_played(self, user_id): 89 | # Why if querying movie, episode and serie it does not return all series? ... 90 | items = [] 91 | url = self._url_builder( 92 | f"/Users/{user_id}/Items", 93 | recursive=True, 94 | Filters="IsPlayed", 95 | IncludeItemTypes="Movie,Episode", 96 | ) 97 | items += self._get_request(url)["Items"] 98 | url = self._url_builder( 99 | f"/Users/{user_id}/Items", 100 | recursive=True, 101 | Filters="IsPlayed", 102 | IncludeItemTypes="Series", 103 | ) 104 | items += self._get_request(url)["Items"] 105 | 106 | return items 107 | 108 | def get_media_libraries(self): 109 | url = self._url_builder("/Library/VirtualFolders/Query") 110 | return self._get_request(url)["Items"] 111 | 112 | def post_new_playlist(self, name, entry_ids, media_type): 113 | string_ids = ",".join(map(str, entry_ids)) 114 | url = self._url_builder( 115 | "/Playlists", name=name, ids=string_ids, media_type=media_type 116 | ) 117 | return self._get_request(url, "POST") 118 | 119 | def get_playlist_items(self, id): 120 | url = self._url_builder(f"/Playlists/{id}/Items") 121 | return self._get_request(url)["Items"] 122 | 123 | def remove_items_from_playlist(self, id, entry_ids): 124 | url = self._url_builder( 125 | f"/Playlists/{id}/Items", EntryIds=",".join(map(str, entry_ids)) 126 | ) 127 | return self._get_request(url, "DELETE") 128 | 129 | def add_items_to_playlist(self, id, entry_ids): 130 | url = self._url_builder( 131 | f"/Playlists/{id}/Items", ids=",".join(map(str, entry_ids)) 132 | ) 133 | return self._get_request(url, "POST") 134 | -------------------------------------------------------------------------------- /src/backend/utils/config_manager.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | 5 | from pydantic import ValidationError 6 | 7 | import schemas.settings as settings 8 | from utils.log_manager import LoggingManager 9 | 10 | logging_manager = LoggingManager() 11 | 12 | CONFIG_VERSION = "0.1.2" 13 | 14 | 15 | # region Upgrade functions 16 | 17 | 18 | def upgrade_v0_1_1_to_v0_1_2(config_data): 19 | # Define changes needed to upgrade from v0.1.1 to v0.1.2 20 | config_data["MUSICVIDEO"]["download_subtitles"] = True 21 | config_data["MUSICVIDEO"]["subtitle_languages"] = ["en"] 22 | config_data["MISC"]["config_version"] = "0.1.2" 23 | return config_data 24 | 25 | 26 | def upgrade_v0_1_0_to_v0_1_1(config_data): 27 | # Define changes needed to upgrade from v0.1.0 to v0.1.1 28 | config_data["SPOTIFY"] = settings.SpotifySettings().dict() 29 | config_data["MUSICVIDEO"] = settings.MusicVideoSettings().dict() 30 | config_data["MISC"]["config_version"] = "0.1.1" 31 | return config_data 32 | 33 | 34 | # endregion 35 | 36 | upgrade_map = { 37 | "0.1.0": upgrade_v0_1_0_to_v0_1_1, 38 | "0.1.1": upgrade_v0_1_1_to_v0_1_2, 39 | } 40 | 41 | 42 | class ConfigManager: 43 | def __init__( 44 | self, 45 | config_file_path: str = "./data/config.json", 46 | latest_version=CONFIG_VERSION, 47 | ): 48 | self.config_file_path = Path(config_file_path) 49 | self.config_file_data = None 50 | self.latest_version = latest_version 51 | self.load_config_file() 52 | 53 | def write_default_configs(self): 54 | logging_manager.log("Writing default config", level=logging.INFO) 55 | default_config = settings.Config( 56 | SONARR=settings.SonarrSettings(), 57 | RADARR=settings.RadarrSettings(), 58 | SPOTIFY=settings.SpotifySettings(), 59 | MUSICVIDEO=settings.MusicVideoSettings(), 60 | MEDIASERVER=settings.MediaServerSettings(), 61 | MISC=settings.MiscSettings(config_version=CONFIG_VERSION), 62 | ) 63 | self.config_file_data = default_config 64 | 65 | with open( 66 | str(self.config_file_path).replace(".json", "-default.json"), "w" 67 | ) as f: 68 | json.dump(default_config.dict(), f, indent=4) 69 | 70 | def upgrade_config(self, existing_config_data): 71 | logging_manager.log("Upgrading config file", level=logging.INFO) 72 | current_version = existing_config_data.get("MISC", {}).get( 73 | "config_version", "0.0.0" 74 | ) 75 | 76 | while current_version in upgrade_map and current_version != self.latest_version: 77 | logging_manager.log(f"Upgrading from {current_version}", level=logging.INFO) 78 | upgrade_func = upgrade_map[current_version] 79 | existing_config_data = upgrade_func(existing_config_data) 80 | current_version = existing_config_data["MISC"]["config_version"] 81 | 82 | return existing_config_data 83 | 84 | def load_config_file(self): 85 | try: 86 | if not self.config_file_path.exists(): 87 | self.write_default_configs() 88 | else: 89 | with open(self.config_file_path) as json_file: 90 | config_data = json.load(json_file) 91 | upgraded = False 92 | if config_data["MISC"]["config_version"] != self.latest_version: 93 | logging_manager.log( 94 | "Config version mismatch, upgrading config", 95 | level=logging.INFO, 96 | ) 97 | config_data = self.upgrade_config(config_data) 98 | upgraded = True 99 | self.config_file_data = settings.Config(**config_data) 100 | if upgraded: 101 | self.save_config_file(self.config_file_data) 102 | except ValidationError: 103 | logging_manager.log( 104 | "Config file is invalid, writing default config", level=logging.WARNING 105 | ) 106 | self.write_default_configs() 107 | except FileNotFoundError: 108 | logging_manager.log( 109 | "Config file not found, writing default config", level=logging.WARNING 110 | ) 111 | self.write_default_configs() 112 | 113 | def save_config_file(self, config_data: settings.Config): 114 | self.config_file_data = config_data 115 | logging_manager.log("Saving config file", level=logging.DEBUG) 116 | logging_manager.log( 117 | json.dumps(config_data.dict(), indent=4), 118 | level=logging.DEBUG, 119 | print_message=False, 120 | ) 121 | with open(self.config_file_path, "w") as f: 122 | json.dump(config_data.dict(), f, indent=4) 123 | 124 | def get_config(self) -> settings.Config: 125 | self.load_config_file() 126 | return self.config_file_data 127 | 128 | 129 | # Example usage: 130 | if __name__ == "__main__": 131 | config_manager = ConfigManager() 132 | config = config_manager.get_config() 133 | print(json.dumps(config.dict(), indent=4)) 134 | -------------------------------------------------------------------------------- /src/backend/workers/playlist_to_music_video.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from utils.config_manager import ConfigManager 4 | from utils.log_manager import LoggingManager 5 | from utils.media_server_interaction import MediaServerinteracter 6 | from utils.music_video.main import download_music_video 7 | from utils.validators import fuzzy_str_match 8 | 9 | # TODO: 10 | # - Add write overview to playlist and make it locked with info about playlist and that it is a managed playlist 11 | 12 | 13 | # region Configuration and Setup 14 | config_manager = ConfigManager() 15 | config = config_manager.get_config() 16 | logging_manager = LoggingManager() 17 | media_server = MediaServerinteracter( 18 | config.MEDIASERVER.media_server_type, 19 | config.MEDIASERVER.media_server_base_url, 20 | config.MEDIASERVER.media_server_api_key, 21 | ) 22 | 23 | 24 | # endregion 25 | 26 | 27 | def get_playlist_tracks(playlist_id): 28 | playlist_tracks = [] 29 | offset = 0 30 | 31 | while True: 32 | response = sptpy.playlist_items(playlist_id, offset=offset) 33 | items = response["items"] 34 | playlist_tracks.extend(items) 35 | if len(playlist_tracks) == response["total"]: 36 | break 37 | offset += len(items) 38 | 39 | playlist_tracks = [ 40 | playlist_track 41 | for playlist_track in playlist_tracks 42 | if not playlist_track["track"].get("episode", False) 43 | ] 44 | 45 | return playlist_tracks 46 | 47 | 48 | def process_playlist_tracks(playlist_tracks, media_server_tracks_by_title): 49 | matched_tracks = set() 50 | unmatched_tracks = [] 51 | 52 | for playlist_track in playlist_tracks: 53 | track_name = playlist_track["Name"] 54 | track_artists_set = playlist_track["Artists"] 55 | 56 | # Check by title quickly 57 | potential_matches = media_server_tracks_by_title.get(track_name, []) 58 | 59 | # If not matched fuzzy match by title 60 | if not potential_matches: 61 | for key, value in media_server_tracks_by_title.items(): 62 | if fuzzy_str_match(track_name, key): 63 | potential_matches = value 64 | break 65 | 66 | for media_server_track in potential_matches: 67 | if any( 68 | fuzzy_str_match(artist, media_server_artist) 69 | for artist in track_artists_set 70 | for media_server_artist in media_server_track["Artists"] 71 | ): 72 | matched_tracks.add(media_server_track["id"]) 73 | break 74 | 75 | unmatched_tracks.append(playlist_track) 76 | 77 | return matched_tracks, unmatched_tracks 78 | 79 | 80 | def process_playlist_video( 81 | playlist_tracks, 82 | existing_server_playlists, 83 | media_server_tracks_by_title, 84 | playlist_name, 85 | ): 86 | track_ids, missing_tracks = process_playlist_tracks( 87 | playlist_tracks, media_server_tracks_by_title 88 | ) 89 | 90 | existing_playlist = None 91 | playlist_name = f"MV - {playlist_name} - arrTools" 92 | for playlist in existing_server_playlists: 93 | if playlist["title"] == playlist_name: 94 | existing_playlist = playlist 95 | break 96 | 97 | if existing_playlist: 98 | logging_manager.log( 99 | f"Playlist {playlist_name} already exists", level=logging.DEBUG 100 | ) 101 | playlist_id = existing_playlist["id"] 102 | existing_entries = media_server.get_items_from_playlist(playlist_id) 103 | existing_entries_ids = [entry["PlaylistItemId"] for entry in existing_entries] 104 | if len(existing_entries_ids) > 0: 105 | media_server.remove_items_from_playlist(playlist_id, existing_entries_ids) 106 | media_server.add_items_to_playlist(playlist_id, track_ids) 107 | else: 108 | logging_manager.log( 109 | f"Creating new playlist {playlist_name}", level=logging.DEBUG 110 | ) 111 | media_server.create_playlist(playlist_name, track_ids, "Audio") 112 | 113 | for missing_track in missing_tracks: 114 | download_music_video(missing_track["Name"], missing_track["Artists"]) 115 | 116 | 117 | def index_tracks_by_title(media_server_tracks): 118 | media_server_tracks_by_title = {} 119 | for track in media_server_tracks: 120 | title = track["title"] 121 | if title not in media_server_tracks_by_title: 122 | media_server_tracks_by_title[title] = [] 123 | media_server_tracks_by_title[title].append(track) 124 | return media_server_tracks_by_title 125 | 126 | 127 | def main(): 128 | media_server_video_tracks_by_title = index_tracks_by_title( 129 | media_server.get_music_video_items() 130 | ) 131 | 132 | existing_server_playlists = media_server.get_playlist_items() 133 | existing_server_playlists_by_id = { 134 | playlist["id"]: playlist["title"] for playlist in existing_server_playlists 135 | } 136 | 137 | for playlist_id in config.MUSICVIDEO.convert_playlists: 138 | playlist_tracks = media_server.get_items_from_playlist(playlist_id) 139 | playlist_name = existing_server_playlists_by_id.get(playlist_id, None) 140 | if not playlist_name: 141 | continue 142 | 143 | process_playlist_video( 144 | playlist_tracks, 145 | existing_server_playlists, 146 | media_server_video_tracks_by_title, 147 | playlist_name, 148 | ) 149 | 150 | 151 | def run(): 152 | global sptpy, config, media_server 153 | config = config_manager.get_config() 154 | media_server = MediaServerinteracter( 155 | config.MEDIASERVER.media_server_type, 156 | config.MEDIASERVER.media_server_base_url, 157 | config.MEDIASERVER.media_server_api_key, 158 | ) 159 | 160 | main() 161 | -------------------------------------------------------------------------------- /src/backend/schemas/settings.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel, Field 5 | 6 | 7 | class DecayMethod(Enum): 8 | NONE = 0 # Not used 9 | DELETE = 1 # Not used 10 | DELETE_AND_DOWNGRADE = 2 11 | DOWNGRADE = 3 12 | 13 | 14 | class Quality(Enum): 15 | LOW = 0 16 | NORMAL = 1 17 | HIGH = 2 18 | ULTRA = 3 19 | 20 | 21 | class DecayStartTimer(Enum): 22 | ADDED = 0 23 | SHORTEST = 1 24 | WATCHED_ELSE_ADDED = 2 25 | WATCHED_ELSE_SHORTEST = 3 26 | 27 | 28 | class SonarrMonitoring(Enum): 29 | SEASON = 0 30 | THREE_EPISODES = 1 31 | SIX_EPISODES = 2 32 | SERIE = 3 33 | 34 | 35 | class SonarrSettings(BaseModel): 36 | base_url: str = "http://sonarr:8989/" 37 | api_key: str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 38 | enabled: bool = False 39 | 40 | use_watched: bool = True 41 | use_favorite: bool = True 42 | 43 | ultra_quality_profile: Optional[int] = Field(default=None) 44 | high_quality_profile: Optional[int] = Field(default=None) 45 | normal_quality_profile: Optional[int] = Field(default=None) 46 | low_quality_profile: Optional[int] = Field(default=None) 47 | watched_quality_profile: int = 2 48 | favorited_quality_profile: int = 3 49 | very_popular_quality_profile: int = 2 50 | popular_quality_profile: int = 1 51 | less_popular_quality_profile: int = 0 52 | unpopular_quality_profile: int = 0 53 | search_for_quality_upgrades: bool = True 54 | monitor_quality_changes: bool = True 55 | exclude_tags_from_quality_upgrades: list = [] 56 | exclude_users_from_quality_upgrades: list = [] 57 | 58 | watched_decay_days: int = 30 59 | favorite_decay_days: int = 180 60 | very_popular_decay_days: int = 30 61 | popular_decay_days: int = 60 62 | less_popular_decay_days: int = 90 63 | unpopular_decay_days: int = 120 64 | decay_method: int = 3 65 | decay_start_timer: int = 3 66 | 67 | mark_favorited_as_monitored: bool = True 68 | mark_very_popular_as_monitored: bool = True 69 | mark_popular_as_monitored: bool = True 70 | mark_less_popular_as_monitored: bool = True 71 | mark_unpopular_as_monitored: bool = False 72 | mark_unpopular_as_unmonitored: bool = True 73 | exclude_tags_from_monitoring: list = [] 74 | exclude_users_from_monitoring: list = [] 75 | monitoring_amount: int = 0 76 | base_monitoring_amount: int = 1 77 | 78 | delete_unmonitored_files: bool = False 79 | exclude_tags_from_deletion: list = [] 80 | 81 | popular_filters: dict = { 82 | "very_popular": [], 83 | "popular": [], 84 | "less_popular": [], 85 | "unpopular": [], 86 | } 87 | 88 | 89 | class RadarrSettings(BaseModel): 90 | base_url: str = "http://radarr:7878/" 91 | api_key: str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 92 | enabled: bool = False 93 | 94 | ultra_quality_profile: Optional[int] = Field(default=None) 95 | high_quality_profile: Optional[int] = Field(default=None) 96 | normal_quality_profile: Optional[int] = Field(default=None) 97 | low_quality_profile: Optional[int] = Field(default=None) 98 | watched_quality_profile: int = 2 99 | favorited_quality_profile: int = 3 100 | on_resume_quality_profile: int = 2 101 | very_popular_quality_profile: int = 2 102 | popular_quality_profile: int = 1 103 | less_popular_quality_profile: int = 0 104 | unpopular_quality_profile: int = 0 105 | search_for_quality_upgrades: bool = True 106 | monitor_quality_changes: bool = True 107 | exclude_tags_from_quality_upgrades: list = [] 108 | exclude_users_from_quality_upgrades: list = [] 109 | 110 | use_watched: bool = True 111 | use_favorite: bool = True 112 | use_on_resume: bool = False 113 | 114 | watched_decay_days: int = 30 115 | favorite_decay_days: int = 180 116 | on_resume_decay_days: int = 14 117 | very_popular_decay_days: int = 30 118 | popular_decay_days: int = 60 119 | less_popular_decay_days: int = 90 120 | unpopular_decay_days: int = 120 121 | decay_method: int = 3 122 | decay_start_timer: int = 3 123 | 124 | mark_favorited_as_monitored: bool = True 125 | mark_on_resume_as_monitored: bool = False 126 | mark_very_popular_as_monitored: bool = True 127 | mark_popular_as_monitored: bool = True 128 | mark_less_popular_as_monitored: bool = True 129 | mark_unpopular_as_monitored: bool = False 130 | mark_unpopular_as_unmonitored: bool = True 131 | exclude_tags_from_monitoring: list = [] 132 | exclude_users_from_monitoring: list = [] 133 | 134 | delete_unmonitored_files: bool = False 135 | exclude_tags_from_deletion: list = [] 136 | 137 | popular_filters: dict = { 138 | "very_popular": [], 139 | "popular": [], 140 | "less_popular": [], 141 | "unpopular": [], 142 | } 143 | 144 | 145 | class SpotifySettings(BaseModel): 146 | client_id: str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 147 | client_secret: str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 148 | playlists: list = [] 149 | 150 | 151 | class MusicVideoSettings(BaseModel): 152 | enabled: bool = True 153 | use_imvdb: bool = True 154 | use_shazam_search: bool = True 155 | use_youtube_search: bool = False 156 | good_keywords: list = [ 157 | "official", 158 | "official video", 159 | "music video", 160 | "vevo", 161 | "uncensured", 162 | "uncensored", 163 | ] 164 | bad_keywords: list = ["acoustic", "lyrics", "remix"] 165 | exclude_words: list = [ 166 | "tutorial", 167 | "cover", 168 | "lesson", 169 | "karaoke", 170 | "lessons", 171 | "live", 172 | "audio", 173 | ] 174 | check_song_with_recognition: bool = True 175 | check_song_for_movement: bool = True 176 | convert_playlists: list = [] 177 | download_subtitles: bool = True 178 | subtitle_languages: list = ["en"] 179 | lastfm_api_key: str = ( 180 | "2dc3914abf35f0d9c92d97d8f8e42b43" # Do not forget to change this, it is from beets... 181 | ) 182 | 183 | 184 | class MediaServerSettings(BaseModel): 185 | media_server_type: str = "emby" 186 | media_server_base_url: str = "http://media_server:8096/" 187 | media_server_api_key: str = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" 188 | create_leaving_soon_collections: bool = False 189 | 190 | 191 | class MiscSettings(BaseModel): 192 | log_level: str = "INFO" 193 | config_version: str = "0.1.2" 194 | 195 | 196 | class Config(BaseModel): 197 | SONARR: SonarrSettings 198 | RADARR: RadarrSettings 199 | SPOTIFY: SpotifySettings 200 | MUSICVIDEO: MusicVideoSettings 201 | MEDIASERVER: MediaServerSettings 202 | MISC: MiscSettings 203 | -------------------------------------------------------------------------------- /src/frontend/src/components/swipearr/swipeRadarr.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 135 | 136 | 181 | -------------------------------------------------------------------------------- /src/backend/utils/general_arr_actions.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from collections import defaultdict 4 | from typing import List, Tuple, Dict 5 | 6 | 7 | def get_start_time( 8 | item: dict, decay_start_timer: int, played_items=None 9 | ) -> datetime.datetime: 10 | """Retrieve the start time of an item based on the decay_start_timer configuration. 11 | 12 | Args: 13 | item (dict): The item dictionary containing the start time information. 14 | decay_start_timer (int): Specifies how to decay the start time. 15 | 16 | Returns: 17 | datetime: The start time of the item as a timezone-aware datetime object. 18 | """ 19 | utc_now = datetime.datetime.now(datetime.timezone.utc) 20 | 21 | def get_shortest_time(): 22 | # Convert strings to datetime objects, default to a very old date if not available 23 | added = datetime.datetime.strptime( 24 | item.get("added", "1000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 25 | ).replace(tzinfo=datetime.timezone.utc) 26 | last_aired = datetime.datetime.strptime( 27 | item.get("lastAired", "1000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 28 | ).replace(tzinfo=datetime.timezone.utc) 29 | in_cinemas = datetime.datetime.strptime( 30 | item.get("inCinemas", "1000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 31 | ).replace(tzinfo=datetime.timezone.utc) 32 | digital_release = datetime.datetime.strptime( 33 | item.get("digitalRelease", "1000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 34 | ).replace(tzinfo=datetime.timezone.utc) 35 | 36 | # Calculate differences from utc_now 37 | time_diffs = [ 38 | (abs(utc_now - added), added), 39 | (abs(utc_now - last_aired), last_aired), 40 | (abs(utc_now - in_cinemas), in_cinemas), 41 | (abs(utc_now - digital_release), digital_release), 42 | ] 43 | 44 | # Find the closest time 45 | closest_time = min(time_diffs, key=lambda x: x[0])[1] 46 | return closest_time 47 | 48 | try: 49 | if decay_start_timer == 0: 50 | return datetime.datetime.strptime( 51 | item.get("added", "0000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 52 | ).replace(tzinfo=datetime.timezone.utc) 53 | elif decay_start_timer == 1: 54 | return get_shortest_time() 55 | elif not played_items: 56 | print("You should add played items to the function call") 57 | return get_shortest_time() 58 | elif decay_start_timer == 2: 59 | for played in played_items: 60 | if played["name"] == item["title"]: 61 | return played["date"] 62 | return datetime.datetime.strptime( 63 | item.get("added", "0000-01-01T00:00:00Z"), "%Y-%m-%dT%H:%M:%SZ" 64 | ).replace(tzinfo=datetime.timezone.utc) 65 | elif decay_start_timer == 3: 66 | for played in played_items: 67 | if played["name"] == item["title"]: 68 | return played["date"] 69 | return get_shortest_time() 70 | 71 | except KeyError: 72 | return utc_now 73 | 74 | 75 | def reassign_based_on_age( 76 | items, decay_days, decay_start_timer, played_items, source_list, target_list=None 77 | ) -> None: 78 | """Reassigns items to a different list based on their age relative to a specified decay period. 79 | 80 | This function iterates over the given items, calculates their age using the get_start_time function, 81 | and compares it to the current date minus the specified decay days. If an item's age exceeds the 82 | decay period, it is removed from the source list. If a target list is provided, the item is appended 83 | to the target list. 84 | 85 | Args: 86 | items (list): The list of items to be checked and potentially reassigned. 87 | decay_days (int): The number of days representing the decay period. 88 | source_list (list): The list from which items are removed if they exceed the decay period. 89 | target_list (list, optional): The list to which items are appended if they exceed the decay period. 90 | Defaults to None, in which case items are simply removed from the source list. 91 | """ 92 | threshold_date = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( 93 | days=decay_days 94 | ) 95 | for item in list(items): 96 | start_time = get_start_time(item, decay_start_timer, played_items) 97 | if start_time < threshold_date: 98 | source_list.remove(item) 99 | if target_list is not None: 100 | target_list.append(item) 101 | 102 | 103 | def classify_items_by_decay( 104 | items: List[dict], 105 | decay_days: int, 106 | decay_start_timer: int, 107 | played_items: list, 108 | quality_profile: int, 109 | ) -> List[Tuple[int, List[dict]]]: 110 | return [(quality_profile, items)] 111 | 112 | decay_profiles = {} 113 | 114 | for item in items: 115 | start_time = get_start_time( 116 | item=item, decay_start_timer=decay_start_timer, played_items=played_items 117 | ) 118 | age_days = (now_time - start_time).days 119 | 120 | if age_days >= decay_days: 121 | profile_level = max(1, quality_profile - (age_days // decay_days)) 122 | 123 | if profile_level not in decay_profiles: 124 | decay_profiles[profile_level] = [] 125 | 126 | decay_profiles[profile_level].append(item) 127 | 128 | result = [ 129 | (profile, decay_profiles[profile]) 130 | for profile in sorted(decay_profiles.keys(), reverse=True) 131 | ] 132 | return result 133 | 134 | 135 | def link_arr_to_media_server(media_server_item: dict, arr_items: list) -> dict: 136 | """ 137 | Link an item from a media server to a corresponding item in a list. 138 | 139 | Args: 140 | media_server_item (dict): A dictionary containing information about the media server item, including 'Name'. 141 | arr_items (list): A list of dictionaries, each containing 'title' and 'year' information. 142 | 143 | Returns: 144 | dict: The matched dictionary from arr_items if a match is found, otherwise None. 145 | """ 146 | import re 147 | 148 | original_title = media_server_item["Name"].casefold() 149 | title = re.sub(r"\s*\(\d{4}\)$", "", original_title) # Remove (year) from the title 150 | year_match = re.search(r"\((\d{4})\)$", original_title) 151 | year = ( 152 | int(year_match.group(1)) 153 | if year_match and 1900 <= int(year_match.group(1)) <= 2200 154 | else None 155 | ) 156 | 157 | for arr_item in arr_items: 158 | arr_title = arr_item["title"].casefold() 159 | arr_year = arr_item.get("year") 160 | if arr_title == original_title or (title == arr_title and year == arr_year): 161 | return arr_item 162 | 163 | 164 | def combine_tuples(tuples: List[Tuple[int, List[int]]]) -> List[Tuple[int, List[int]]]: 165 | combined_dict = defaultdict(list) 166 | 167 | for key, value in tuples: 168 | combined_dict[key].extend(value) 169 | 170 | return [(key, combined_dict[key]) for key in sorted(combined_dict)] 171 | 172 | 173 | def sort_tuples( 174 | tuples: List[Tuple[int, List[int]]], reverse: bool = True 175 | ) -> List[Tuple[int, List[int]]]: 176 | return sorted(tuples, key=lambda x: x[0], reverse=reverse) 177 | 178 | 179 | def subtract_dicts( 180 | tuples: List[Tuple[int, List[Dict]]], 181 | ) -> List[Tuple[int, List[Dict]]]: 182 | previous_elements = [] 183 | 184 | for i in range(len(tuples)): 185 | current_elements = tuples[i][1] 186 | new_elements = [ 187 | item for item in current_elements if item not in previous_elements 188 | ] 189 | tuples[i] = (tuples[i][0], new_elements) 190 | previous_elements.extend(current_elements) 191 | 192 | return tuples 193 | 194 | 195 | def check_ids(ids: list, items: List[Dict]): 196 | unique_ids = set() 197 | for item in items: 198 | unique_ids.add(item["id"]) 199 | 200 | available_ids = [] 201 | unavailable_ids = [] 202 | for _id in ids: 203 | if _id in unique_ids: 204 | available_ids.append(_id) 205 | else: 206 | unavailable_ids.append(_id) 207 | return available_ids, unavailable_ids 208 | -------------------------------------------------------------------------------- /src/backend/workers/spot_to_mediaserver.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import spotipy 4 | from spotipy.oauth2 import SpotifyClientCredentials 5 | 6 | from utils.config_manager import ConfigManager 7 | from utils.log_manager import LoggingManager 8 | from utils.media_server_interaction import MediaServerinteracter 9 | from utils.music_video.main import download_music_video 10 | from utils.validators import fuzzy_str_match 11 | 12 | # TODO: 13 | # - Add write overview to playlist and make it locked with info about playlist and that it is a managed playlist 14 | 15 | 16 | # region Configuration and Setup 17 | config_manager = ConfigManager() 18 | config = config_manager.get_config() 19 | logging_manager = LoggingManager() 20 | sptpy = None 21 | media_server = MediaServerinteracter( 22 | config.MEDIASERVER.media_server_type, 23 | config.MEDIASERVER.media_server_base_url, 24 | config.MEDIASERVER.media_server_api_key, 25 | ) 26 | 27 | 28 | # endregion 29 | 30 | 31 | def get_playlist_tracks(playlist_id): 32 | playlist_tracks = [] 33 | offset = 0 34 | 35 | while True: 36 | response = sptpy.playlist_items(playlist_id, offset=offset) 37 | items = response["items"] 38 | playlist_tracks.extend(items) 39 | if len(playlist_tracks) == response["total"]: 40 | break 41 | offset += len(items) 42 | 43 | playlist_tracks = [ 44 | playlist_track 45 | for playlist_track in playlist_tracks 46 | if not playlist_track["track"].get("episode", False) 47 | ] 48 | 49 | return playlist_tracks 50 | 51 | 52 | def process_playlist_tracks(playlist_tracks, media_server_tracks_by_title): 53 | matched_tracks = set() 54 | unmatched_tracks = [] 55 | 56 | for playlist_track in playlist_tracks: 57 | track_name = playlist_track["track"]["name"] 58 | track_album = playlist_track["track"]["album"]["name"] 59 | track_artists_set = { 60 | artist["name"] for artist in playlist_track["track"]["artists"] 61 | } 62 | 63 | # Check by title quickly 64 | potential_matches = media_server_tracks_by_title.get(track_name, []) 65 | 66 | # If not matched fuzzy match by title 67 | if not potential_matches: 68 | for key, value in media_server_tracks_by_title.items(): 69 | if fuzzy_str_match(track_name, key): 70 | potential_matches = value 71 | break 72 | 73 | for media_server_track in potential_matches: 74 | # if song is in the album but artist does not match it is still most likely a match 75 | if fuzzy_str_match(track_album, media_server_track["album"]) or any( 76 | fuzzy_str_match(artist, media_server_artist) 77 | for artist in track_artists_set 78 | for media_server_artist in media_server_track["artists"] 79 | ): 80 | matched_tracks.add(media_server_track["id"]) 81 | break 82 | 83 | unmatched_tracks.append(playlist_track["track"]) 84 | 85 | return matched_tracks, unmatched_tracks 86 | 87 | 88 | def process_playlist_audio( 89 | playlist_tracks, existing_server_playlists, media_server_tracks_by_title, playlist 90 | ): 91 | track_ids, _ = process_playlist_tracks( 92 | playlist_tracks, media_server_tracks_by_title 93 | ) 94 | 95 | existing_playlist = None 96 | playlist_name = f"{playlist['name']} - arrTools" 97 | for playlist in existing_server_playlists: 98 | if playlist["title"] == playlist_name: 99 | existing_playlist = playlist 100 | break 101 | 102 | if existing_playlist: 103 | logging.info(f"Playlist {playlist_name} already exists", level=logging.DEBUG) 104 | playlist_id = existing_playlist["id"] 105 | existing_entries = media_server.get_items_from_playlist(playlist_id) 106 | existing_entries_ids = [entry["PlaylistItemId"] for entry in existing_entries] 107 | if len(existing_entries_ids) > 0: 108 | media_server.remove_items_from_playlist(playlist_id, existing_entries_ids) 109 | media_server.add_items_to_playlist(playlist_id, track_ids) 110 | else: 111 | logging_manager.log( 112 | f"Creating new playlist {playlist_name}", level=logging.DEBUG 113 | ) 114 | media_server.create_playlist(playlist_name, track_ids, "Audio") 115 | 116 | 117 | def process_playlist_video( 118 | playlist_tracks, existing_server_playlists, media_server_tracks_by_title, playlist 119 | ): 120 | track_ids, missing_tracks = process_playlist_tracks( 121 | playlist_tracks, media_server_tracks_by_title 122 | ) 123 | 124 | existing_playlist = None 125 | playlist_name = f"MV - {playlist['name']} - arrTools" 126 | for playlist in existing_server_playlists: 127 | if playlist["title"] == playlist_name: 128 | existing_playlist = playlist 129 | break 130 | 131 | if existing_playlist: 132 | logging_manager.log( 133 | f"Playlist {playlist_name} already exists", level=logging.DEBUG 134 | ) 135 | playlist_id = existing_playlist["id"] 136 | existing_entries = media_server.get_items_from_playlist(playlist_id) 137 | existing_entries_ids = [entry["PlaylistItemId"] for entry in existing_entries] 138 | if len(existing_entries_ids) > 0: 139 | media_server.remove_items_from_playlist(playlist_id, existing_entries_ids) 140 | media_server.add_items_to_playlist(playlist_id, track_ids) 141 | else: 142 | logging_manager.log( 143 | f"Creating new playlist {playlist_name}", level=logging.DEBUG 144 | ) 145 | media_server.create_playlist(playlist_name, track_ids, "Audio") 146 | 147 | for missing_track in missing_tracks: 148 | download_music_video( 149 | missing_track["name"], 150 | [artist["name"] for artist in missing_track["artists"]], 151 | ) 152 | 153 | 154 | def index_tracks_by_title(media_server_tracks): 155 | media_server_tracks_by_title = {} 156 | for track in media_server_tracks: 157 | title = track["title"] 158 | if title not in media_server_tracks_by_title: 159 | media_server_tracks_by_title[title] = [] 160 | media_server_tracks_by_title[title].append(track) 161 | return media_server_tracks_by_title 162 | 163 | 164 | def main(): 165 | media_server_audio_tracks_by_title = None 166 | media_server_video_tracks_by_title = None 167 | 168 | existing_server_playlists = media_server.get_playlist_items() 169 | 170 | for playlist in config.SPOTIFY.playlists: 171 | playlist_id = playlist["id"] 172 | if playlist_id.startswith("37i9dQZF"): 173 | print("Spotify API update, Spotify generated playlists no longer supported") 174 | continue 175 | playlist_tracks = get_playlist_tracks(playlist["id"]) 176 | if playlist["type"] in ["audio", "both"]: 177 | if not media_server_audio_tracks_by_title: 178 | media_server_audio_tracks_by_title = index_tracks_by_title( 179 | media_server.get_music_items() 180 | ) 181 | process_playlist_audio( 182 | playlist_tracks, 183 | existing_server_playlists, 184 | media_server_audio_tracks_by_title, 185 | playlist, 186 | ) 187 | if playlist["type"] in ["video", "both"]: 188 | if not media_server_video_tracks_by_title: 189 | media_server_video_tracks_by_title = index_tracks_by_title( 190 | media_server.get_music_video_items() 191 | ) 192 | process_playlist_video( 193 | playlist_tracks, 194 | existing_server_playlists, 195 | media_server_video_tracks_by_title, 196 | playlist, 197 | ) 198 | 199 | 200 | def run(): 201 | global sptpy, config, media_server 202 | config = config_manager.get_config() 203 | auth_manager = SpotifyClientCredentials( 204 | client_id=config.SPOTIFY.client_id, client_secret=config.SPOTIFY.client_secret 205 | ) 206 | sptpy = spotipy.Spotify(auth_manager=auth_manager) 207 | media_server = MediaServerinteracter( 208 | config.MEDIASERVER.media_server_type, 209 | config.MEDIASERVER.media_server_base_url, 210 | config.MEDIASERVER.media_server_api_key, 211 | ) 212 | 213 | main() 214 | -------------------------------------------------------------------------------- /src/backend/utils/music_video/main.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import logging 3 | import os 4 | import shutil 5 | import time 6 | 7 | import requests 8 | import yt_dlp 9 | from sqlalchemy.orm import Session 10 | 11 | from database.database import get_program_db 12 | from models.media import musicVideoCache 13 | from utils.config_manager import ConfigManager 14 | from utils.log_manager import LoggingManager 15 | from utils.music_video.imvdb_api import imvdb_search 16 | from utils.music_video.music_video_detect import detect_movement 17 | from utils.music_video.nfo_writer import create_nfo 18 | from utils.music_video.shazam_api import shazam_cdn_search 19 | from utils.music_video.shazam_api import shazam_confirm_song 20 | from utils.parsers import make_filename_safe 21 | 22 | # region Configuration and Setup 23 | config_manager = ConfigManager() 24 | config = config_manager.get_config() 25 | logging_manager = LoggingManager() 26 | db: Session = next(get_program_db()) 27 | 28 | 29 | # endregion 30 | 31 | 32 | def _cleanup_files(base_path): 33 | directory, file_prefix = os.path.split(base_path) 34 | for filename in os.listdir(directory): 35 | if filename.startswith(file_prefix): 36 | file_path = os.path.join(directory, filename) 37 | try: 38 | os.remove(file_path) 39 | except OSError as e: 40 | print(f"Error: {e.filename} - {e.strerror}.") 41 | 42 | 43 | def _check_cache(title: str, artists: list, album: str = None): 44 | existing_data = ( 45 | db.query(musicVideoCache) 46 | .filter_by(title=title, artists=artists, album=album) 47 | .first() 48 | ) 49 | if not existing_data: 50 | return None 51 | if not existing_data.youtubeId and existing_data.dateAdded + 2592000 > time.time(): 52 | db.delete(existing_data) 53 | db.commit() 54 | return None 55 | return existing_data 56 | 57 | 58 | os.makedirs("./temp/downloading", exist_ok=True) 59 | shutil.rmtree("./temp/downloading", ignore_errors=True) # Cleanup old downloads if any 60 | 61 | 62 | def _download_music_video( 63 | youtube_id: str, title: str, artists: list, album: str = None 64 | ): 65 | ydl_opts = { 66 | # download the best mp4 format 67 | "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", 68 | # download the best audio and video and then merge them 69 | # "format": "bv+ba/b", 70 | "outtmpl": f"./temp/downloading/{youtube_id}.%(ext)s", 71 | "no_warnings": True, 72 | "write-thumbnail": True, 73 | "subtitlesformat": "srt", 74 | "subtitleslangs": config.MUSICVIDEO.subtitle_languages, 75 | "writesubtitles": config.MUSICVIDEO.download_subtitles, 76 | "writeautomaticsub": config.MUSICVIDEO.download_subtitles, 77 | "sponsorblock-mark": True, 78 | "postprocessors": [ 79 | { 80 | "key": "FFmpegVideoConvertor", 81 | "preferedformat": "mp4", 82 | } 83 | ], 84 | } 85 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 86 | ydl.download([youtube_id]) 87 | 88 | correct_song = True 89 | if config.MUSICVIDEO.check_song_with_recognition: 90 | correct_song = shazam_confirm_song( 91 | f"./temp/downloading/{youtube_id}.mp4", title, artists, album 92 | ) 93 | 94 | if not correct_song: 95 | _cleanup_files(f"./temp/downloading/{youtube_id}") 96 | return False 97 | 98 | is_music_video = True 99 | if config.MUSICVIDEO.check_song_for_movement: 100 | is_music_video = detect_movement(f"./temp/downloading/{youtube_id}.mp4", 5, 10) 101 | 102 | if not is_music_video: 103 | _cleanup_files(f"./temp/downloading/{youtube_id}") 104 | return False 105 | 106 | artist_safe = make_filename_safe(artists[0]) 107 | title_safe = make_filename_safe(title) 108 | os.makedirs(f"./musicVideoOutput/{artist_safe}", exist_ok=True) 109 | shutil.move( 110 | f"./temp/downloading/{youtube_id}.mp4", 111 | f"./musicVideoOutput/{artist_safe}/{title_safe}.mp4", 112 | ) 113 | 114 | # region Download thumbnail from i.ytimg.com 115 | os.makedirs(f"./musicVideoOutput/{artist_safe}", exist_ok=True) 116 | thumbnail_urls = [ 117 | f"https://i.ytimg.com/vi/{youtube_id}/maxresdefault.jpg", 118 | f"https://i.ytimg.com/vi/{youtube_id}/hqdefault.jpg", 119 | ] 120 | thumbnail_path = f"./musicVideoOutput/{artist_safe}/{title_safe}.jpg" 121 | for url in thumbnail_urls: 122 | response = requests.get(url) 123 | if response.status_code == 200: 124 | with open(thumbnail_path, "wb") as thumbnail_file: 125 | thumbnail_file.write(response.content) 126 | break 127 | # endregion 128 | 129 | # region Move subtitles 130 | subtitle_formats = ["srt", "vtt", "ass", "ssa", "sub", "idx"] 131 | for fmt in subtitle_formats: 132 | srt_files = glob.glob(f"./temp/downloading/{youtube_id}*.{fmt}") 133 | for srt_file in srt_files: 134 | language_code = srt_file.split(f"{youtube_id}.")[-1] 135 | new_srt_path = ( 136 | f"./musicVideoOutput/{artist_safe}/{title_safe}.{language_code}" 137 | ) 138 | shutil.move(srt_file, new_srt_path) 139 | # endregion 140 | 141 | # region Create NFO file 142 | nfo_content = create_nfo(artists[0], title, f"{title_safe}.jpg") 143 | 144 | if not nfo_content: 145 | return True 146 | 147 | nfo_path = f"./musicVideoOutput/{artist_safe}/{title_safe}.nfo" 148 | with open(nfo_path, "w") as nfo_file: 149 | nfo_file.write(nfo_content) 150 | # endregion 151 | return True 152 | 153 | 154 | def download_music_video(title: str, artists: list, album: str = None): 155 | mv_config = config.MUSICVIDEO 156 | 157 | if not mv_config.enabled: 158 | return False 159 | 160 | if os.path.exists( 161 | f"./musicVideoOutput/{make_filename_safe(artists[0])}/{make_filename_safe(title)}.jpg" 162 | ): 163 | return True 164 | 165 | print(f"Downloading {title} by {artists[0]}") 166 | 167 | existing_data = _check_cache(title, artists, album) 168 | if existing_data: 169 | if existing_data.downloadError: 170 | return False 171 | try: 172 | if _download_music_video(existing_data.youtubeId, title, artists, album): 173 | existing_data.downloadError = 0 174 | db.commit() 175 | return True 176 | else: 177 | existing_data.downloadError = 1 178 | db.commit() 179 | return False 180 | except Exception as e: 181 | logging_manager.log( 182 | f"Error downloading music video: {e}", level=logging.ERROR 183 | ) 184 | existing_data.downloadError = 1 185 | db.commit() 186 | return False 187 | 188 | if ( 189 | not mv_config.use_youtube_search 190 | and not mv_config.use_shazam_search 191 | and not mv_config.use_imvdb 192 | ): 193 | raise NotImplementedError("No search method is enabled") 194 | 195 | youtube_id, additional_info = None, None 196 | if mv_config.use_imvdb: 197 | youtube_id, additional_info = imvdb_search( 198 | title=title, artists=artists, album=album 199 | ) 200 | if youtube_id is None and mv_config.use_shazam_search: 201 | youtube_id = shazam_cdn_search(title=title, artists=artists) 202 | if youtube_id is None and mv_config.use_youtube_search: 203 | raise NotImplementedError("Youtube search is not yet implemented") 204 | 205 | db_addition = musicVideoCache( 206 | youtubeId=youtube_id, 207 | title=title, 208 | artists=artists, 209 | album=album, 210 | additionalInfo=additional_info, 211 | dateAdded=round(time.time()), 212 | ) 213 | 214 | if not youtube_id: 215 | db.add(db_addition) 216 | db.commit() 217 | return False 218 | 219 | try: 220 | if _download_music_video(youtube_id, title, artists, album): 221 | db_addition.downloadError = 0 222 | db.add(db_addition) 223 | db.commit() 224 | return True 225 | else: 226 | db_addition.downloadError = 1 227 | db.add(db_addition) 228 | db.commit() 229 | return False 230 | except Exception as e: 231 | logging_manager.log(f"Error downloading music video: {e}", level=logging.ERROR) 232 | db_addition.downloadError = 1 233 | db.add(db_addition) 234 | db.commit() 235 | return False 236 | 237 | db.add(db_addition) 238 | db.commit() 239 | return False 240 | -------------------------------------------------------------------------------- /src/frontend/src/components/swipearr/swipeSonarr.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 143 | 144 | 231 | -------------------------------------------------------------------------------- /src/frontend/src/stores/settings.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import {reactive, ref} from 'vue'; 3 | import {useMediaStore} from "@/stores/mediainfo"; 4 | const mediaInfoStore = useMediaStore(); 5 | 6 | export const useSettingsStore = defineStore('settings', () => { 7 | const fetchedSettings = reactive({ 8 | mediaserver: null, 9 | radarr: null, 10 | sonarr: null, 11 | spotify: null, 12 | musicvideo: null 13 | }); 14 | 15 | const mediaserverSettings = ref({ 16 | "media_server_type": "emby", 17 | "media_server_base_url": "http://media_server:8096/", 18 | "media_server_api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 19 | "create_leaving_soon_collections": false 20 | }); 21 | 22 | async function getMediaserverSettings() { 23 | if (fetchedSettings.mediaserver) { 24 | return; 25 | } 26 | 27 | try { 28 | const response = await fetch('/api/mediaserver/settings'); 29 | 30 | if (!response.ok) { 31 | const errorData = await response.json(); 32 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 33 | } 34 | 35 | mediaserverSettings.value = await response.json(); 36 | fetchedSettings.mediaserver = true; 37 | } catch (error) { 38 | console.error('Error:', error); 39 | } 40 | } 41 | 42 | async function postMediaserverSettings() { 43 | if (!fetchedSettings.mediaserver) { 44 | console.error('Settings not fetched yet'); 45 | return; 46 | } 47 | try { 48 | const response = await fetch('/api/mediaserver/settings', { 49 | method: 'POST', 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | 'Accept': 'application/json' 53 | }, 54 | body: JSON.stringify(mediaserverSettings.value) 55 | }); 56 | 57 | if (!response.ok) { 58 | const errorData = await response.json(); 59 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 60 | } 61 | 62 | const data = await response.json(); 63 | console.log('Successful response:', data); 64 | } catch (error) { 65 | console.error('Error:', error); 66 | } 67 | 68 | await getMediaserverSettings(); 69 | // Ensure mediaInfoStore is properly imported and instantiated before this call 70 | await mediaInfoStore.fetchMediaInfo(true); 71 | } 72 | 73 | const radarrSettings = ref({ 74 | "base_url": "http://radarr:7878/", 75 | "api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 76 | "enabled": false, 77 | "ultra_quality_profile": 0, 78 | "high_quality_profile": 0, 79 | "normal_quality_profile": 0, 80 | "low_quality_profile": 0, 81 | "watched_quality_profile": 2, 82 | "favorited_quality_profile": 3, 83 | "on_resume_quality_profile": 2, 84 | "very_popular_quality_profile": 2, 85 | "popular_quality_profile": 1, 86 | "less_popular_quality_profile": 0, 87 | "unpopular_quality_profile": 0, 88 | "search_for_quality_upgrades": true, 89 | "monitor_quality_changes": true, 90 | "exclude_tags_from_quality_upgrades": [], 91 | "exclude_users_from_quality_upgrades": [], 92 | "use_watched": true, 93 | "use_favorite": true, 94 | "use_on_resume": false, 95 | "watched_decay_days": 30, 96 | "favorite_decay_days": 180, 97 | "on_resume_decay_days": 14, 98 | "very_popular_decay_days": 30, 99 | "popular_decay_days": 60, 100 | "less_popular_decay_days": 90, 101 | "unpopular_decay_days": 120, 102 | "decay_method": 3, 103 | "decay_start_timer": 3, 104 | "mark_favorited_as_monitored": true, 105 | "mark_on_resume_as_monitored": false, 106 | "mark_very_popular_as_monitored": true, 107 | "mark_popular_as_monitored": true, 108 | "mark_less_popular_as_monitored": true, 109 | "mark_unpopular_as_monitored": false, 110 | "mark_unpopular_as_unmonitored": true, 111 | "exclude_tags_from_monitoring": [], 112 | "exclude_users_from_monitoring": [], 113 | "delete_unmonitored_files": false, 114 | "exclude_tags_from_deletion": [], 115 | "popular_filters": { 116 | "very_popular": [], 117 | "popular": [], 118 | "less_popular": [], 119 | "unpopular": [] 120 | } 121 | }); 122 | 123 | async function getRadarrSettings() { 124 | if (fetchedSettings.radarr) { 125 | return; 126 | } 127 | 128 | try { 129 | const response = await fetch('/api/radarr/settings'); 130 | 131 | if (!response.ok) { 132 | const errorData = await response.json(); 133 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 134 | } 135 | 136 | radarrSettings.value = await response.json(); 137 | fetchedSettings.radarr = true; 138 | } catch (error) { 139 | console.error('Error:', error); 140 | } 141 | } 142 | 143 | async function postRadarrSettings() { 144 | if (!fetchedSettings.radarr) { 145 | console.error('Settings not fetched yet'); 146 | return; 147 | } 148 | 149 | try { 150 | const response = await fetch('/api/radarr/settings', { 151 | method: 'POST', 152 | headers: { 153 | 'Content-Type': 'application/json', 154 | 'Accept': 'application/json' 155 | }, 156 | body: JSON.stringify(radarrSettings.value) 157 | }); 158 | 159 | if (!response.ok) { 160 | const errorData = await response.json(); 161 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 162 | } 163 | 164 | const data = await response.json(); 165 | console.log('Successful response:', data); 166 | } catch (error) { 167 | console.error('Error:', error); 168 | } 169 | 170 | await getRadarrSettings(); 171 | // Ensure mediaInfoStore is properly imported and instantiated before this call 172 | await mediaInfoStore.fetchRadarrInfo(true); 173 | } 174 | 175 | const sonarrSettings = ref({ 176 | "base_url": "http://sonarr:8989/", 177 | "api_key": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 178 | "enabled": false, 179 | "use_watched": true, 180 | "use_favorite": true, 181 | "ultra_quality_profile": 0, 182 | "high_quality_profile": 0, 183 | "normal_quality_profile": 0, 184 | "low_quality_profile": 0, 185 | "watched_quality_profile": 2, 186 | "favorited_quality_profile": 3, 187 | "very_popular_quality_profile": 2, 188 | "popular_quality_profile": 1, 189 | "less_popular_quality_profile": 0, 190 | "unpopular_quality_profile": 0, 191 | "search_for_quality_upgrades": true, 192 | "monitor_quality_changes": true, 193 | "exclude_tags_from_quality_upgrades": [], 194 | "exclude_users_from_quality_upgrades": [], 195 | "watched_decay_days": 30, 196 | "favorite_decay_days": 180, 197 | "very_popular_decay_days": 30, 198 | "popular_decay_days": 60, 199 | "less_popular_decay_days": 90, 200 | "unpopular_decay_days": 120, 201 | "decay_method": 3, 202 | "decay_start_timer": 3, 203 | "mark_favorited_as_monitored": true, 204 | "mark_very_popular_as_monitored": true, 205 | "mark_popular_as_monitored": true, 206 | "mark_less_popular_as_monitored": true, 207 | "mark_unpopular_as_monitored": false, 208 | "mark_unpopular_as_unmonitored": true, 209 | "exclude_tags_from_monitoring": [], 210 | "exclude_users_from_monitoring": [], 211 | "monitoring_amount": 0, 212 | "base_monitoring_amount": 1, 213 | "delete_unmonitored_files": false, 214 | "exclude_tags_from_deletion": [], 215 | "popular_filters": { 216 | "very_popular": [], 217 | "popular": [], 218 | "less_popular": [], 219 | "unpopular": [] 220 | } 221 | }); 222 | 223 | async function getSonarrSettings() { 224 | if (fetchedSettings.sonarr) { 225 | return; 226 | } 227 | 228 | try { 229 | const response = await fetch('/api/sonarr/settings'); 230 | 231 | if (!response.ok) { 232 | const errorData = await response.json(); 233 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 234 | } 235 | 236 | sonarrSettings.value = await response.json(); 237 | fetchedSettings.sonarr = true; 238 | } catch (error) { 239 | console.error('Error:', error); 240 | } 241 | } 242 | 243 | async function postSonarrSettings() { 244 | if (!fetchedSettings.sonarr) { 245 | console.error('Settings not fetched yet'); 246 | return; 247 | } 248 | 249 | try { 250 | const response = await fetch('/api/sonarr/settings', { 251 | method: 'POST', 252 | headers: { 253 | 'Content-Type': 'application/json', 254 | 'Accept': 'application/json' 255 | }, 256 | body: JSON.stringify(sonarrSettings.value) 257 | }); 258 | 259 | if (!response.ok) { 260 | const errorData = await response.json(); 261 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 262 | } 263 | 264 | const data = await response.json(); 265 | console.log('Successful response:', data); 266 | } catch (error) { 267 | console.error('Error:', error); 268 | } 269 | 270 | await getSonarrSettings(); 271 | // Ensure mediaInfoStore is properly imported and instantiated before this call 272 | await mediaInfoStore.fetchSonarrInfo(true); 273 | } 274 | 275 | const spotifySettings = ref({ 276 | "client_id": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 277 | "client_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 278 | "playlists": [] 279 | }); 280 | 281 | async function getSpotifySettings(force = false) { 282 | if (fetchedSettings.spotify && !force) { 283 | return; 284 | } 285 | 286 | try { 287 | const response = await fetch('/api/spotify/settings'); 288 | 289 | if (!response.ok) { 290 | const errorData = await response.json(); 291 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 292 | } 293 | 294 | spotifySettings.value = await response.json(); 295 | fetchedSettings.spotify = true; 296 | } catch (error) { 297 | console.error('Error:', error); 298 | } 299 | } 300 | 301 | async function postSpotifySettings() { 302 | if (!fetchedSettings.spotify) { 303 | console.error('Settings not fetched yet'); 304 | return; 305 | } 306 | try { 307 | const response = await fetch('/api/spotify/settings', { 308 | method: 'POST', 309 | headers: { 310 | 'Content-Type': 'application/json', 311 | 'Accept': 'application/json' 312 | }, 313 | body: JSON.stringify(spotifySettings.value) 314 | }); 315 | 316 | if (!response.ok) { 317 | const errorData = await response.json(); 318 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 319 | } 320 | 321 | const data = await response.json(); 322 | console.log('Successful response:', data); 323 | } catch (error) { 324 | console.error('Error:', error); 325 | } 326 | } 327 | 328 | const musicvideoSettings = ref({ 329 | "enabled": true, 330 | "use_imvdb": true, 331 | "use_shazam_search": true, 332 | "use_youtube_search": false, 333 | "good_keywords": [ 334 | "official", 335 | "official video", 336 | "music video", 337 | "vevo", 338 | "uncensured", 339 | "uncensored" 340 | ], 341 | "bad_keywords": [ 342 | "acoustic", 343 | "lyrics", 344 | "remix" 345 | ], 346 | "exclude_words": [ 347 | "tutorial", 348 | "cover", 349 | "lesson", 350 | "karaoke", 351 | "lessons", 352 | "live", 353 | "audio" 354 | ], 355 | "check_song_with_recognition": true, 356 | "check_song_for_movement": true, 357 | "convert_playlists": [], 358 | "download_subtitles": true, 359 | "subtitle_languages": ["en"] 360 | }); 361 | 362 | async function getMusicvideoSettings() { 363 | if (fetchedSettings.musicvideo) { 364 | return; 365 | } 366 | 367 | try { 368 | const response = await fetch('/api/music-video/settings'); 369 | 370 | if (!response.ok) { 371 | const errorData = await response.json(); 372 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 373 | } 374 | 375 | musicvideoSettings.value = await response.json(); 376 | fetchedSettings.musicvideo = true; 377 | } catch (error) { 378 | console.error('Error:', error); 379 | } 380 | } 381 | 382 | async function postMusicvideoSettings() { 383 | if (!fetchedSettings.musicvideo) { 384 | console.error('Settings not fetched yet'); 385 | return; 386 | } 387 | try { 388 | const response = await fetch('/api/music-video/settings', { 389 | method: 'POST', 390 | headers: { 391 | 'Content-Type': 'application/json', 392 | 'Accept': 'application/json' 393 | }, 394 | body: JSON.stringify(musicvideoSettings.value) 395 | }); 396 | 397 | if (!response.ok) { 398 | const errorData = await response.json(); 399 | throw new Error(`Error: ${response.status} - ${errorData.detail}`); 400 | } 401 | 402 | const data = await response.json(); 403 | console.log('Successful response:', data); 404 | } catch (error) { 405 | console.error('Error:', error); 406 | } 407 | } 408 | 409 | return { 410 | mediaserverSettings, 411 | getMediaserverSettings, 412 | postMediaserverSettings, 413 | radarrSettings, 414 | getRadarrSettings, 415 | postRadarrSettings, 416 | sonarrSettings, 417 | getSonarrSettings, 418 | postSonarrSettings, 419 | spotifySettings, 420 | getSpotifySettings, 421 | postSpotifySettings, 422 | musicvideoSettings, 423 | getMusicvideoSettings, 424 | postMusicvideoSettings 425 | }; 426 | }); 427 | -------------------------------------------------------------------------------- /src/frontend/src/components/settings/music.vue: -------------------------------------------------------------------------------- 1 | 261 | 262 | 327 | 328 | 331 | -------------------------------------------------------------------------------- /src/frontend/src/components/settings/dynaarr/radarr.vue: -------------------------------------------------------------------------------- 1 | 263 | 264 | 475 | 476 | 479 | --------------------------------------------------------------------------------