├── .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 |
10 |
11 | Sonarr
12 | Radarr
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
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 |
2 |
3 |
4 |
11 |
12 |
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 |
25 |
26 | mdi-arrow-collapse-left
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | {{drawer}}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
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 |
2 |
3 |
4 |
5 |
6 | Home
7 | Settings
8 | SwipeArr
9 | Tools
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | API
21 | Github
22 |
23 |
24 |
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 |
20 |
21 | MediaServer
22 |
23 |
25 |
27 |
28 |
29 |
30 | Save
31 |
32 |
33 |
34 | Radarr
35 |
36 |
37 |
38 |
39 |
40 | Save
41 |
42 |
43 |
44 | Sonarr
45 |
46 |
47 |
48 |
49 |
50 | Save
51 |
52 |
53 |
54 | Spotify
55 |
56 |
57 |
58 |
59 |
60 | Save
61 |
62 |
63 |
64 |
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 |
2 |
3 |
4 |
5 | Output:
6 |
7 |
8 |
9 | No Changes
10 |
11 |
12 | {{ output }}
13 |
14 |
15 |
16 | Close
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {{ tool.title }}
25 |
26 |
27 | {{ tool.description }}
28 |
29 |
30 | {{ page }}
31 |
32 |
33 |
34 |
36 |
37 |
38 |
39 |
40 |
41 |
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 | 
27 | 
28 | 
29 | 
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 |
2 |
3 | Welcome to Dyna-Arr
4 |
5 | Well it expanded more then it was supposed to at the beginning, please pick what to do
6 |
7 |
8 |
9 |
10 |
11 | DynaArr
12 |
13 | This will allow you to automate dynamically changed quality of your media files and which content to monitor
14 |
15 |
16 | Configure
17 |
18 |
19 |
20 |
21 |
22 | SwipeArr
23 |
24 | A Tinder like interface to swipe through your media files and decide what to keep and what to delete
25 |
26 | To use you need to enter Arr server details and optionally media server details in DynaArr
27 |
28 |
29 |
30 | Use
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Why -Arr
39 |
40 | Well it is a third party tool to control the *arr suite but it is not associated with it. They will also most
41 | likely not give support if you are using this
42 |
43 |
44 |
45 | Tools
46 |
47 | Additional tools which might be useful in certain use cases
48 |
49 |
50 | Go to
51 |
52 |
53 |
54 | Changelog
55 |
56 |
57 |
58 |
59 |
60 |
Version {{ changes.version }}
61 |
62 |
Added:
63 |
64 |
65 | {{ addition }}
66 |
67 |
68 |
69 |
70 |
Removed:
71 |
72 |
73 | {{ addition }}
74 |
75 |
76 |
77 |
78 |
Changed:
79 |
80 |
81 | {{ addition }}
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
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 |
32 |
33 |
34 |
38 |
39 |
40 |
42 |
44 |
46 |
47 | mdi-check-bold
48 | mdi-heart
49 |
50 |
51 |
52 |
53 |
54 |
55 |
59 |
60 |
62 |
71 |
72 |
73 |
75 | Size: {{ formatBytes(currentItem.statistics.sizeOnDisk) }}
76 |
77 |
79 | Runtime: {{ formatTimeLength(currentItem.runtime) }}
80 |
81 |
83 | Year: {{ currentItem.year }}
84 |
85 |
88 | Quality: {{ currentItem.qualityProfileIdText }}
89 |
90 |
92 |
93 |
Added: {{ relativeDate(currentItem.added) }}
94 |
95 | {{ formatDate(currentItem.added) }}
96 |
97 |
98 |
99 |
100 |
101 |
102 | IMDB
105 | TVDB
108 | TMDB
111 | Website
113 |
114 |
115 |
116 |
117 | {{ genre }}
118 |
119 |
120 |
121 |
122 |
123 | {{ tag }}
124 |
125 |
126 |
127 |
128 | {{ currentItem.overview }}
129 |
130 |
131 |
132 |
133 |
134 |
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 |
32 |
33 |
34 |
38 |
39 |
40 |
42 |
44 |
46 |
48 |
49 | mdi-check-bold
50 | mdi-heart
51 |
52 |
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | {{ currentItem.title }}
68 |
69 |
71 | Size: {{ formatBytes(currentItem.statistics.sizeOnDisk) }}
72 |
73 |
75 | Runtime: {{ formatTimeLength(currentItem.runtime) }}
76 |
77 |
79 | Year: {{ currentItem.year }}
80 |
81 |
84 | Quality: {{ currentItem.qualityProfileIdText }}
85 |
86 |
88 |
89 |
Added: {{ relativeDate(currentItem.added) }}
90 |
91 | {{ formatDate(currentItem.added) }}
92 |
93 |
94 |
95 |
98 | Seasons: {{ currentItem.statistics.seasonCount }}
99 |
100 |
103 | Episodes: {{ currentItem.statistics.totalEpisodeCount }}
104 |
105 |
106 |
107 |
108 | IMDB
111 | TVDB
114 | TMDB
117 | TvMaze
120 |
121 |
122 |
123 |
124 |
125 | {{ genre }}
126 |
127 |
128 |
129 |
130 |
131 | {{ tag }}
132 |
133 |
134 |
135 |
136 | {{ currentItem.overview }}
137 |
138 |
139 |
140 |
141 |
142 |
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 |
263 |
264 | Music video downloading settings
265 |
266 |
267 |
268 |
269 |
270 |
271 |
272 |
273 |
274 |
275 |
276 | Save
277 |
278 |
279 |
280 |
281 | Spotify
282 |
283 |
284 |
285 |
286 |
287 |
288 |
289 |
290 |
291 | Add
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 | {{ playlist.name }}
300 | Type: {{ playlist.type }}
301 |
302 |
303 |
304 | {{ playlist.id }}
305 |
306 |
307 |
308 |
309 |
310 |
311 |
312 |
313 |
314 |
315 |
316 |
317 | Mediaserver
318 |
319 |
320 |
321 |
322 | Save
323 |
324 |
325 |
326 |
327 |
328 |
331 |
--------------------------------------------------------------------------------
/src/frontend/src/components/settings/dynaarr/radarr.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dry run
4 |
5 | Save
6 |
7 |
8 |
9 |
10 |
11 | {{ filteredItems.length }} items
12 |
13 |
14 |
15 |
16 |
18 |
19 |
20 | {{ currentItem.title }}
21 |
22 |
23 |
24 |
25 | Close
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | Quality Changes:
36 |
37 |
38 |
39 |
40 | {{ getQuality(item.item.qualityProfileId) }}
41 | ->
42 | {{ getQuality(item.new_quality_profile) }}
43 |
44 |
45 | {{ item.item.title }}
46 |
47 |
48 |
49 |
50 |
51 | No items
52 |
53 |
54 |
55 |
56 |
57 | Monitor Changes:
58 |
59 |
60 |
61 |
62 | {{ item.title }}
63 |
64 |
65 |
66 |
67 |
68 | No items
69 |
70 |
71 |
72 |
73 | Deletes:
74 |
75 |
76 |
77 |
78 | {{ item.title }}
79 |
80 |
81 |
82 |
83 |
84 | No items
85 |
86 |
87 |
88 |
89 |
90 | Close
91 |
92 |
93 |
94 |
95 |
96 |
97 |
99 |
100 | Before enabling please first do a dry run of your settings because it can delete files...
101 |
102 |
103 |
104 |
105 | General
106 |
107 |
109 |
111 |
113 |
114 |
115 |
116 | Quality profiles
117 |
118 | {{ qualityNotCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
119 |
120 |
121 |
122 |
124 |
126 |
128 |
130 |
132 |
134 |
136 |
138 |
140 |
142 |
144 |
146 |
148 |
149 |
153 |
155 |
156 |
157 |
158 |
159 | Decay
160 |
161 | {{ decayNotCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
174 |
175 |
176 |
177 |
178 |
179 | Monitored
180 |
181 | {{ monitoredNotCollapsed ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
182 |
183 |
184 |
185 |
187 |
189 |
191 |
193 |
195 |
198 |
201 |
203 |
207 |
208 |
209 |
210 |
211 | Deletion
212 |
213 |
214 |
215 |
216 | This instance is enabled and enabling deletion will permanently delete items
217 |
218 |
220 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 | Add
230 | popularity filter
231 |
232 |
233 |
234 |
235 |
236 |
237 | {{ section }}
238 |
239 |
240 |
241 |
243 |
244 |
245 |
248 |
249 |
250 |
251 | mdi-delete-circle-outline
252 |
253 |
254 |
255 |
256 |
257 | Show preview
258 |
259 |
260 |
261 |
262 |
263 |
264 |
475 |
476 |
479 |
--------------------------------------------------------------------------------