backuparr 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backuparr/api_stuff.py ADDED
@@ -0,0 +1,23 @@
1
+ import os
2
+
3
+ from enum import StrEnum
4
+ from dotenv import load_dotenv
5
+
6
+
7
+
8
+ load_dotenv()
9
+
10
+ class radarr(StrEnum):
11
+ apiKey = str(os.getenv("RADARR_APIKEY"))
12
+ baseUrl = str(os.getenv("RADARR_URL"))
13
+ movieListUrl = baseUrl + "/api/v3/movie"
14
+ qualityProfileUrl = baseUrl + "/api/v3/qualityprofile"
15
+ rootFolderUrl = baseUrl + "/api/v3/rootfolder"
16
+
17
+ class sonarr(StrEnum):
18
+ apiKey = str(os.getenv("SONARR_APIKEY"))
19
+ baseUrl = str(os.getenv("SONARR_URL"))
20
+ seriesListUrl = baseUrl + "/api/v3/series"
21
+ episodeFileUrl = baseUrl + "/api/v3/episodefile"
22
+ rootFolderUrl = baseUrl + "/api/v3/rootfolder"
23
+ qualityProfileUrl = baseUrl + "/api/v3/qualityprofile"
backuparr/backup.py ADDED
@@ -0,0 +1,60 @@
1
+ import json
2
+ import sys
3
+ import requests
4
+
5
+ from backuparr import api_stuff as api
6
+ from backuparr import functions
7
+
8
+
9
+ def run(app, filename):
10
+ ## Resolving Filename
11
+ filename = functions.resolveFilename(filename)
12
+
13
+ ## Attempt Connection
14
+ movieData = {}
15
+ match app:
16
+ case "radarr":
17
+ movieData = functions.attemptConnection(api.radarr.movieListUrl, api.radarr.apiKey)
18
+ case "sonarr":
19
+ movieData = functions.attemptConnection(api.sonarr.seriesListUrl, api.sonarr.apiKey)
20
+
21
+ ## Display found movies
22
+ len_data = len(movieData)
23
+ if len_data == 1:
24
+ print(len_data, "movie/series have been found.")
25
+ elif len_data == 0:
26
+ print("No movie/series have been found.")
27
+ print("Exiting...")
28
+ sys.exit("No movies/series found.")
29
+ else:
30
+ print(len_data, "movies/series have been found.")
31
+
32
+
33
+ ## Making and writing data to JSON file
34
+ jsondata = {}
35
+ for i in range(len_data):
36
+ match app:
37
+ case "radarr":
38
+ jsondata[str(movieData[i]["tmdbId"])] = functions.makeJsonData(i, movieData)
39
+ case "sonarr":
40
+ quality = -1
41
+ if int(movieData[i]["statistics"]["episodeFileCount"]) > 0:
42
+ req = requests.get(api.sonarr.episodeFileUrl + "?seriesId=" + str(movieData[i]["id"]), headers={"x-api-key": api.sonarr.apiKey}).json()
43
+ for j in range(len(req)):
44
+ try:
45
+ quality = int(req[j]["quality"]["quality"]["resolution"])
46
+ break
47
+ except Exception as e:
48
+ print("Resolution not found, trying another file...")
49
+ jsondata[str(movieData[i]["tvdbId"])] = functions.makeSonarrData(i, movieData, quality)
50
+
51
+ print("Writing Data for: " + str(movieData[i]["title"]))
52
+
53
+ with open(filename, "w", encoding="utf-8") as f:
54
+ json.dump(jsondata, f, indent=4, ensure_ascii=False, sort_keys=True)
55
+
56
+ print("Data has been writen to:", filename)
57
+ print("Exiting...")
58
+
59
+ if __name__ == "__main__":
60
+ run()
backuparr/functions.py ADDED
@@ -0,0 +1,149 @@
1
+ import json
2
+ import sys
3
+ import requests
4
+
5
+ from pathlib import Path
6
+ from requests.exceptions import HTTPError, Timeout, RequestException
7
+
8
+
9
+ def attemptConnection(connectionUrl, apiKey):
10
+ while True:
11
+ (connected, jsonData) = getJsonDataFromUrl(connectionUrl, apiKey)
12
+ if connected: return jsonData
13
+ while True:
14
+ choice = input("Connection failed, make sure the URL is valid and accessible. (y)Reconnect | (n) Exit: ").lower().strip()
15
+ if choice == "n": sys.exit("User terminated the process")
16
+ elif choice == "y":
17
+ print("Reconnecting...")
18
+ break
19
+ else: print("Invalid input. Use: y/n")
20
+
21
+ def getJsonDataFromUrl(connectionUrl, apiKey):
22
+ noApiUrl = connectionUrl.split("?apiKey=")[0]
23
+ try:
24
+ print("Connecting to " + noApiUrl)
25
+ headers = {"x-api-key" : apiKey}
26
+ response = requests.get(connectionUrl, headers=headers, timeout=10)
27
+ if response.status_code == 401:
28
+ print("Error: 401 Request unauthorized. @:", noApiUrl)
29
+ return False, ""
30
+ print("Connection established.")
31
+ return True, response.json()
32
+ except Timeout:
33
+ print("Error: The request timed out. This URL might be down or slow. @:", noApiUrl)
34
+ return False, ""
35
+ except ConnectionError:
36
+ print("Error: Failed to connect to this URL. Check your URL or network. @:", noApiUrl)
37
+ return False, ""
38
+ except HTTPError as e:
39
+ print(f"HTTP Error: {e}\n@:", noApiUrl)
40
+ return False, ""
41
+ except RequestException as e:
42
+ print(f"An ambiguous error occurred: {e}\n@:", noApiUrl)
43
+ return False, ""
44
+ except ValueError:
45
+ print("Error: Successfully connected, but received invalid JSON. @:", noApiUrl)
46
+ return False, ""
47
+
48
+ def makeJsonData(index, data):
49
+ try:
50
+ if data[index].get("movieFile") is not None: quality = data[index]["movieFile"]["quality"]["quality"]["resolution"]
51
+ else: quality = -1
52
+
53
+ jsonData = {
54
+ "title": str(data[index]["title"]),
55
+ "tmdbId": int(data[index]["tmdbId"]),
56
+ "monitored": bool(data[index]["monitored"]),
57
+ "quality": int(quality)
58
+ }
59
+ return jsonData
60
+ except Exception as e:
61
+ print("KeyError: Important Data not found!")
62
+ print(str(e))
63
+ sys.exit()
64
+
65
+ def makeSonarrData(index, data, quality):
66
+ try:
67
+ jsonData = {
68
+ "title": str(data[index]["title"]),
69
+ "tvdbId": int(data[index]["tvdbId"]),
70
+ "monitored": bool(data[index]["monitored"]),
71
+ "quality": int(quality)
72
+ }
73
+ return jsonData
74
+ except Exception as e:
75
+ print("KeyError: Important Data not found!")
76
+ print(str(e))
77
+ sys.exit()
78
+
79
+ def getNumUserInput(lastnum):
80
+ while True:
81
+ num = input("Enter choice, default [0]: ").strip()
82
+ try:
83
+ if not num: return 0
84
+ num = int(num)
85
+ if int(num) <= lastnum: return int(num)
86
+ else: print(str(num) + " is not a integer, or a valid input...")
87
+
88
+ except ValueError:
89
+ print(str(num) + " is not a valid input...")
90
+
91
+ def resolveFilename(path):
92
+ filename = Path(path).resolve()
93
+ try:
94
+ filename.parent.mkdir(parents=True, exist_ok=True)
95
+ return filename
96
+ except Exception as e:
97
+ print("An Error occurred: " + str(e))
98
+
99
+ def postJsonData(connectionUrl, apiKey, jsonDataList):
100
+ statusDict = {}
101
+ print("Attempting to post data for " + str(len(jsonDataList)) + " entries...")
102
+ for index, item in enumerate(jsonDataList):
103
+ try:
104
+ print("Importing: " + item["title"])
105
+ req_response = requests.post(connectionUrl, headers={"x-api-key" : apiKey}, json=item)
106
+ if not req_response.status_code == 201:
107
+ statusDict[index] = req_response
108
+ print("Code: " + str(req_response.status_code) + " | " + str(req_response.elapsed.total_seconds()) + "s")
109
+ except Exception as e:
110
+ print("An Error occurred: " + str(e))
111
+
112
+ while True:
113
+ if len(statusDict) == 0: return
114
+
115
+ print("Successfully posted data for " + str(len(jsonDataList) - len(statusDict)) + " entries...")
116
+
117
+ choice = input("Do you want to retry all entries that didn't return 201 Created? Retry(r) | List(l) | Default: Exit(e): ").strip().lower()
118
+ if not choice or choice == "e":
119
+ sys.exit("User terminated process.")
120
+ elif choice == "l":
121
+ for j, resp in statusDict.items():
122
+ print(f"----- {jsonDataList[j]["title"]} -----")
123
+ print(f"Code: {resp.status_code}")
124
+ print(f"Response:\n{json.dumps(resp.json(), indent=4)}")
125
+ print("-" * 100)
126
+
127
+ while True:
128
+ choice2 = input("Do you want to retry importing? (y/n): ").strip().lower()
129
+ if choice2 == "y": break
130
+ elif choice2 == "n": sys.exit("User terminated process.")
131
+ else: print("Invalid Choice, retrying...")
132
+ elif choice == "r": print("Retrying...")
133
+ else:
134
+ print("Invalid Choice, retrying...")
135
+ continue
136
+
137
+ toDelete = []
138
+ for k, resp in statusDict.items():
139
+ print("Importing: " + jsonDataList[k]["title"])
140
+ retry_resp = requests.post(connectionUrl, headers={"x-api-key" : apiKey}, json=jsonDataList[k])
141
+ print("Code: " + str(retry_resp.status_code) + " | " + str(retry_resp.elapsed.total_seconds()) + "s")
142
+ if retry_resp.status_code == 201:
143
+ print(f"Successfully posted data for: {jsonDataList[k]["title"]}")
144
+ toDelete.append(k)
145
+ else:
146
+ print(f"Still unable to import: {jsonDataList[k]["title"]}")
147
+
148
+ for l in toDelete:
149
+ del statusDict[l]
backuparr/main.py ADDED
@@ -0,0 +1,26 @@
1
+ import sys
2
+ from backuparr import backup, restore
3
+
4
+
5
+ def run():
6
+ if len(sys.argv) != 4:
7
+ print("Usage: backuparr [backup|restore] [radarr|sonarr] [filename]")
8
+ return
9
+
10
+ mode = str(sys.argv[1].lower().strip())
11
+ app = str(sys.argv[2].lower().strip())
12
+ filename = str(sys.argv[3])
13
+
14
+ if (app != "radarr") and (app != "sonarr"):
15
+ print(app)
16
+ sys.exit("Argument Error, 2. Argument must be 'radarr' or 'sonarr'")
17
+
18
+ if mode == "backup":
19
+ backup.run(app, filename)
20
+ elif mode == "restore":
21
+ restore.run(app, filename)
22
+ else:
23
+ print("Argument Error, Usage: backuparr [backup|restore] [radarr|sonarr] [filename]")
24
+
25
+ if __name__ == "__main__":
26
+ run()
backuparr/restore.py ADDED
@@ -0,0 +1,109 @@
1
+ import json
2
+ import sys
3
+
4
+ from backuparr import api_stuff as api
5
+ from backuparr import functions
6
+
7
+
8
+ def run(app, path):
9
+ ## Resolving Filename
10
+ filename = functions.resolveFilename(path)
11
+
12
+ ## Required API Calls
13
+ rootFolder = ""
14
+ qualityProfiles = ""
15
+ match app:
16
+ case "radarr":
17
+ rootFolder = functions.attemptConnection(api.radarr.rootFolderUrl, api.radarr.apiKey)
18
+ qualityProfiles = functions.attemptConnection(api.radarr.qualityProfileUrl, api.radarr.apiKey)
19
+ case "sonarr":
20
+ rootFolder = functions.attemptConnection(api.sonarr.rootFolderUrl, api.sonarr.apiKey)
21
+ qualityProfiles = functions.attemptConnection(api.sonarr.qualityProfileUrl, api.sonarr.apiKey)
22
+
23
+
24
+ ## Selecting Root Folder
25
+ if len(rootFolder) == 0:
26
+ sys.exit("No root folder found, please add a root folder.")
27
+
28
+ elif len(rootFolder) == 1:
29
+ selectedRootFolderPath = rootFolder[0]["path"]
30
+ print("Only one root folder found, no selection needed.")
31
+
32
+ else:
33
+ print("Choose Root Folder to restore into, default [0]:")
34
+
35
+ for i in range(len(rootFolder)):
36
+ print("[" + str(i) + "] | Radarr-ID: " + str(rootFolder[i]["id"]) + " | Accessible: " + str(rootFolder[i]["accessible"]) + " | Free Space: " + str(rootFolder[i]["freeSpace"]) + " | Path: " + str(rootFolder[i]["path"]))
37
+
38
+ selectedRootFolderPath = rootFolder[functions.getNumUserInput(len(rootFolder) - 1)]["path"]
39
+
40
+
41
+ ## Load file
42
+ try:
43
+ with open(filename, 'r') as f:
44
+ backupdata = json.load(f)
45
+ print("Loaded contents of:", filename)
46
+ except Exception as e:
47
+ sys.exit("An Error occurred: " + str(e))
48
+
49
+
50
+ ## Select Qualities
51
+ qualities_set = set()
52
+ quality_dictionary = {}
53
+
54
+ for tmdbID, details in backupdata.items():
55
+ qualities_set.add(details["quality"])
56
+
57
+ for x in qualities_set:
58
+ if x == -1:
59
+ print("There where was no Quality Data found for these movies, which Profile should be used for these?")
60
+
61
+ for i in range(len(qualityProfiles)):
62
+ print("[" + str(i) + "] | ID: " + str(qualityProfiles[i]["id"]) + " | Name: " + str(qualityProfiles[i]["name"]))
63
+
64
+ quality_dictionary[x] = qualityProfiles[functions.getNumUserInput(len(qualityProfiles) - 1)]["id"]
65
+
66
+ print("Profile-Id: " + str(quality_dictionary[x]) + " is now associated with the movies that had no data found.")
67
+ break
68
+
69
+
70
+ print("Which Quality Profile should be used for importing ==> " + str(x) + "P <== movies?")
71
+
72
+ for i in range(len(qualityProfiles)):
73
+ print("[" + str(i) + "] | ID: " + str(qualityProfiles[i]["id"]) + " | Name: " + str(qualityProfiles[i]["name"]))
74
+
75
+ quality_dictionary[x] = qualityProfiles[functions.getNumUserInput(len(qualityProfiles) - 1)]["id"]
76
+
77
+ print(str(x) + "p associated with Profile-Id: " + str(quality_dictionary[x]))
78
+
79
+
80
+ ## Making data for import
81
+ jsonDataList = []
82
+ for movie, details in backupdata.items():
83
+ match app:
84
+ case "radarr":
85
+ id_text = "tmdbId"
86
+ id_cont = details["tmdbId"]
87
+ case "sonarr":
88
+ id_text = "tvdbId"
89
+ id_cont = details["tvdbId"]
90
+
91
+ jsonDataList.append({
92
+ "title": str(details["title"]),
93
+ str(id_text): int(id_cont),
94
+ "qualityProfileId": quality_dictionary[int(details["quality"])],
95
+ "rootFolderPath": str(selectedRootFolderPath),
96
+ "monitored": bool(details["monitored"]),
97
+ })
98
+
99
+ match app:
100
+ case "radarr":
101
+ functions.postJsonData(api.radarr.movieListUrl, api.radarr.apiKey, jsonDataList)
102
+ case "sonarr":
103
+ functions.postJsonData(api.sonarr.seriesListUrl, api.sonarr.apiKey, jsonDataList)
104
+
105
+ print("Successfully imported all entries.\nExiting...")
106
+ sys.exit()
107
+
108
+ if __name__ == "__main__":
109
+ run()
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: backuparr
3
+ Version: 0.1.0
4
+ Summary: A lightweight, efficient way to backup your media library without duplicating large video files.
5
+ Author: Daniel Mayhan
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 DanielMayhan
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.14
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: requests
32
+ Requires-Dist: python-dotenv
33
+ Dynamic: license-file
34
+
35
+ # BackupArr
36
+
37
+ > [!NOTE]
38
+ > **AI-Generated Documentation:** This README was drafted with the assistance of AI, but none of the Code in this Project was written by AI.
39
+
40
+ Welcome to the **BackupArr** repository. This project is currently in its early development stages (**Pre-Alpha**) and aims to provide a lightweight, efficient way to "back up" your media library without duplicating large video files.
41
+
42
+ ---
43
+
44
+ ## The Concept
45
+ Traditional backups for media libraries are often prohibitively expensive due to massive file sizes. **BackupArr** changes the paradigm: instead of backing up the media files themselves, this tool maps your library metadata back to its source. In the event of data loss, you can restore the library state to your download client to re-acquire the media from the swarm.
46
+
47
+ ---
48
+
49
+ ## Project Status: Alpha
50
+ **Warning:** This software is experimental and **Work in Progress**. It is not recommended for production use. Any and all code are subject to radical changes as development progresses.
51
+
52
+ ---
53
+
54
+ ## Getting Started (Development)
55
+
56
+ 1. **Clone the repository:**
57
+ ```bash
58
+ git clone https://github.com/DanielMayhan/BackupArr.git
59
+ cd BackupArr
60
+ ```
61
+
62
+ 2. **Configure Environment:**
63
+ Copy the sample configuration and add your Radarr API key & Url.
64
+ ```bash
65
+ cp .env.example .env
66
+ ```
67
+
68
+ ---
69
+
70
+ ## Usage
71
+ * **Command Structure:**
72
+ ```bash
73
+ python3 main.py [backup|restore] [radarr|sonarr] [filename].json
74
+ * **Example Command:**
75
+ ```bash
76
+ python3 main.py backup radarr radarr_backup.json
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Roadmap
82
+ These are features and code changes that are either currently being worked on or are planned to be implemented later!
83
+ * Code Cleanup
84
+ * Unification of all text in-/outputs
85
+
86
+
87
+ ---
88
+
89
+ ## Contributing
90
+ We welcome all input during these early stages!
91
+ * Open an **Issue** to discuss new ideas or report bugs.
92
+ * Submit a **Pull Request** to help with early-stage logic.
93
+ * Check the **Discussions** tab to help define the project roadmap.
94
+
95
+ ---
96
+
97
+ ## License
98
+ This project is licensed under the [MIT License](LICENSE).
@@ -0,0 +1,11 @@
1
+ backuparr/api_stuff.py,sha256=3c9nbGqQdBb2NlA-1r7k_TDVB3JWWazmerqUpfCGb80,672
2
+ backuparr/backup.py,sha256=0BobcvkgrG_BZ31VdtV4jxQbzFdf30IB1ndlCSPBuvE,2122
3
+ backuparr/functions.py,sha256=ITWUZ8sqh1R53rKQzanlsV2tQ-2VGSQBMhy3GO4Pp6o,5829
4
+ backuparr/main.py,sha256=tM5XcqsACQi6fieOus1ZmqNow0eeg-azWlWHl-7cUMA,711
5
+ backuparr/restore.py,sha256=Ju5wslj0dYc7HXyuKJUYaZeRvmzcoGbs-2oNmLUMCjE,3955
6
+ backuparr-0.1.0.dist-info/licenses/LICENSE,sha256=DVzZw-LqmvfYKYrA3s8rfstBmVXqcqCjMkH_SY9pCRA,1069
7
+ backuparr-0.1.0.dist-info/METADATA,sha256=Ptrgv9T7e3jdowZffgx771DuzaDfRiJH7W2BxfMHpz0,3651
8
+ backuparr-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ backuparr-0.1.0.dist-info/entry_points.txt,sha256=zxz1ZgnkPWIUYTEv-AjdMQ9Dblmu612NROyliyyX7rg,49
10
+ backuparr-0.1.0.dist-info/top_level.txt,sha256=afh1QB7ZvxyUeEALqhf2HUAlCmRAlNMqfnNFTeOUjQs,10
11
+ backuparr-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ backuparr = backuparr.main:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DanielMayhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ backuparr