Unit3DwebUp 0.0.14__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.
- config/__init__.py +4 -0
- config/api_data.py +28 -0
- config/constants.py +34 -0
- config/host_data.py +47 -0
- config/itt.py +154 -0
- config/logger.py +37 -0
- config/settings.py +212 -0
- config/sis.py +137 -0
- config/tags.py +395 -0
- config/trackers.py +47 -0
- external/__init__.py +0 -0
- external/async_http_client_service.py +48 -0
- external/websocket.py +41 -0
- models/__init__.py +0 -0
- models/interfaces.py +39 -0
- models/keywords.py +13 -0
- models/media.py +597 -0
- models/media_info.py +142 -0
- models/movie.py +287 -0
- models/tv.py +266 -0
- models/tvdb_search.py +102 -0
- models/videos.py +26 -0
- repositories/__init__.py +0 -0
- repositories/db_online.py +82 -0
- repositories/interfaces.py +59 -0
- repositories/job_repos.py +166 -0
- repositories/media_info_factory.py +28 -0
- services/__init__.py +0 -0
- services/auto_async_service.py +237 -0
- services/create_torrent_service.py +94 -0
- services/interfaces.py +72 -0
- services/itt_tracker_helper.py +463 -0
- services/itt_tracker_service.py +85 -0
- services/lifespan_service.py +58 -0
- services/media_service.py +114 -0
- services/tags_service.py +389 -0
- services/tmdb.py +246 -0
- services/torrent_client_service.py +92 -0
- services/torrent_service.py +107 -0
- services/tvdb.py +65 -0
- services/utility.py +433 -0
- services/video_service.py +356 -0
- unit3dwebup-0.0.14.dist-info/METADATA +191 -0
- unit3dwebup-0.0.14.dist-info/RECORD +53 -0
- unit3dwebup-0.0.14.dist-info/WHEEL +5 -0
- unit3dwebup-0.0.14.dist-info/entry_points.txt +2 -0
- unit3dwebup-0.0.14.dist-info/top_level.txt +6 -0
- use_case/__init__.py +0 -0
- use_case/make_torrent_usecase.py +67 -0
- use_case/process_all_usecase.py +43 -0
- use_case/scan_media_usecase.py +133 -0
- use_case/seed_usecase.py +117 -0
- use_case/upload_usecase.py +77 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from repositories.media_info_factory import MediaFileFactory
|
|
9
|
+
from services.utility import ManageTitles
|
|
10
|
+
from services.tags_service import SearchTags
|
|
11
|
+
from config.constants import MediaStatus
|
|
12
|
+
from config.logger import get_logger
|
|
13
|
+
from models.media import Media
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncMediaManager:
|
|
19
|
+
"""
|
|
20
|
+
Process files and folder
|
|
21
|
+
Every file or folder is converted in a Media Object
|
|
22
|
+
Every Media object receives additional values
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: str, app: FastAPI, job_id_list: str, force_category: int = None):
|
|
26
|
+
"""
|
|
27
|
+
:param path: The user path
|
|
28
|
+
:param force_category: force category to movie or series
|
|
29
|
+
"""
|
|
30
|
+
self.path = os.path.normpath(path)
|
|
31
|
+
self.job_id_list = job_id_list
|
|
32
|
+
self.app = app
|
|
33
|
+
self.mode = 'auto'
|
|
34
|
+
self.force_category = force_category
|
|
35
|
+
self.is_dir = os.path.isdir(self.path)
|
|
36
|
+
self.media_list: list[Media] = []
|
|
37
|
+
self.sem = asyncio.Semaphore(60)
|
|
38
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def scan_folder(path: Path):
|
|
42
|
+
with os.scandir(path) as it:
|
|
43
|
+
for entry in it:
|
|
44
|
+
if entry.is_file() and ManageTitles.filter_ext(entry.name):
|
|
45
|
+
yield entry
|
|
46
|
+
|
|
47
|
+
async def process_all(self) -> list[Media]:
|
|
48
|
+
"""
|
|
49
|
+
start method to process all files
|
|
50
|
+
|
|
51
|
+
:returns:
|
|
52
|
+
list[str]: a list of media objects
|
|
53
|
+
"""
|
|
54
|
+
if os.path.isfile(self.path):
|
|
55
|
+
self.mode = "man"
|
|
56
|
+
|
|
57
|
+
if self.mode in ["man", "folder"]:
|
|
58
|
+
self.media_list = await self.upload()
|
|
59
|
+
else: # modalità 'auto'
|
|
60
|
+
self.media_list = await self.scan()
|
|
61
|
+
|
|
62
|
+
tasks = [self.process_media(media) for media in self.media_list]
|
|
63
|
+
|
|
64
|
+
results = await asyncio.gather(*tasks)
|
|
65
|
+
return [m for m in results if m]
|
|
66
|
+
|
|
67
|
+
async def scan(self) -> list[Media]:
|
|
68
|
+
"""
|
|
69
|
+
process all files or folders ( 1 subfolder only) ( auto)
|
|
70
|
+
|
|
71
|
+
:returns:
|
|
72
|
+
list[str]: a list of media objects
|
|
73
|
+
"""
|
|
74
|
+
if not os.path.exists(self.path):
|
|
75
|
+
self.logger.warning(f"Path not found {self.path}")
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
if not self.is_dir:
|
|
79
|
+
self.logger.warning(f"We can't scan a file {self.path}")
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
entries = await asyncio.to_thread(os.listdir, self.path)
|
|
83
|
+
if not entries:
|
|
84
|
+
self.logger.warning(f"Folder {self.path} is empty")
|
|
85
|
+
|
|
86
|
+
files = [Path(self.path) / e for e in entries if
|
|
87
|
+
os.path.isfile(Path(self.path) / e) and ManageTitles.filter_ext(e)]
|
|
88
|
+
subfolders = [Path(self.path) / e for e in entries if os.path.isdir(Path(self.path) / e)]
|
|
89
|
+
|
|
90
|
+
async def safe_create(path):
|
|
91
|
+
async with self.sem:
|
|
92
|
+
return await self._create_media_list([path])
|
|
93
|
+
|
|
94
|
+
tasks = [safe_create(p) for p in files + subfolders]
|
|
95
|
+
media_list = await asyncio.gather(*tasks)
|
|
96
|
+
# Clean the list, remove None object
|
|
97
|
+
return [m for sublist in media_list for m in sublist if m]
|
|
98
|
+
|
|
99
|
+
async def upload(self) -> list[Media]:
|
|
100
|
+
"""
|
|
101
|
+
process only one file or folder
|
|
102
|
+
|
|
103
|
+
:returns:
|
|
104
|
+
list[str]: a list of media objects
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
if self.is_dir:
|
|
108
|
+
files = await asyncio.to_thread(os.listdir, self.path)
|
|
109
|
+
video_files = [Path(self.path) / f for f in files if ManageTitles.filter_ext(f)]
|
|
110
|
+
|
|
111
|
+
if self.mode == "man":
|
|
112
|
+
return await self._create_media_list(video_files)
|
|
113
|
+
if self.mode == "folder":
|
|
114
|
+
return await self._create_media_list([self.path])
|
|
115
|
+
return []
|
|
116
|
+
else:
|
|
117
|
+
return await self._create_media_list([self.path])
|
|
118
|
+
|
|
119
|
+
async def _create_media_list(self, paths: list[str]) -> list[Media]:
|
|
120
|
+
"""
|
|
121
|
+
create a Media object for each single file or single folder encountered
|
|
122
|
+
files inside subfolders are not converted in Media objects
|
|
123
|
+
:param paths: list of file path
|
|
124
|
+
:return: list[Media]: a list of media objects based on user path
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
Media(folder=self.path, subfolder=os.path.basename(_path),
|
|
129
|
+
torrent_archive_path=self.app.state.torrent_archive_path)
|
|
130
|
+
for _path in paths
|
|
131
|
+
if _path is not None
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
async def process_media(self, media: Media) -> Media | None:
|
|
135
|
+
"""
|
|
136
|
+
start method to process media object ( file or folder)
|
|
137
|
+
|
|
138
|
+
:param media: single Media object
|
|
139
|
+
:return:
|
|
140
|
+
"""
|
|
141
|
+
path = media.torrent_path
|
|
142
|
+
if self.force_category:
|
|
143
|
+
media.category = self.force_category
|
|
144
|
+
|
|
145
|
+
if os.path.isdir(path):
|
|
146
|
+
success = await self.process_folder(media)
|
|
147
|
+
elif os.path.isfile(path):
|
|
148
|
+
success = await self.process_file(media)
|
|
149
|
+
else:
|
|
150
|
+
self.logger.warning("Process Media return None")
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
# Add a Mediainfo object
|
|
154
|
+
if success:
|
|
155
|
+
if media.file_name:
|
|
156
|
+
media.mediafile = await MediaFileFactory.from_path(media.file_name)
|
|
157
|
+
assert media.mediafile is not None, "MediaFileFactory ha restituito None"
|
|
158
|
+
await self.app.state.ws_manager.broadcast({
|
|
159
|
+
"type": "log",
|
|
160
|
+
"level": "success",
|
|
161
|
+
"message": f"Process media -> {media.display_name}",
|
|
162
|
+
})
|
|
163
|
+
######################################################
|
|
164
|
+
search_tags = SearchTags(filename=media.title,
|
|
165
|
+
title=media.guess_filename.guessit_title, # guess.get("title", None),
|
|
166
|
+
year=media.guess_filename.guessit_year, # guess.get("year", ""),
|
|
167
|
+
season=media.guess_season,
|
|
168
|
+
episode=media.guess_episode,
|
|
169
|
+
releaser_sign=media.releaser_sign,
|
|
170
|
+
# config_settings.user_preferences.RELEASER_SIGN,
|
|
171
|
+
tags_position=media.tag_position,
|
|
172
|
+
tags_list=media.tags_list,
|
|
173
|
+
signs_list=media.signs_list,
|
|
174
|
+
ban_list=media.ban_list,
|
|
175
|
+
media=media,
|
|
176
|
+
)
|
|
177
|
+
media.display_name = search_tags.process()
|
|
178
|
+
#######################################################
|
|
179
|
+
return media
|
|
180
|
+
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
async def process_file(self, media: Media) -> bool:
|
|
184
|
+
"""
|
|
185
|
+
process media object file
|
|
186
|
+
|
|
187
|
+
:param media: single Media object
|
|
188
|
+
:return: success or failure
|
|
189
|
+
"""
|
|
190
|
+
media.status = MediaStatus.INDEXED
|
|
191
|
+
media.job_id_list = self.job_id_list
|
|
192
|
+
# Convert PosixPath to str
|
|
193
|
+
media.file_name = str(media.torrent_path)
|
|
194
|
+
media.display_name, _ = os.path.splitext(os.path.basename(media.file_name))
|
|
195
|
+
media.display_name = ManageTitles.clean_text(media.display_name)
|
|
196
|
+
media.torrent_name = os.path.basename(media.file_name)
|
|
197
|
+
media.doc_description = media.file_name
|
|
198
|
+
|
|
199
|
+
# Read file size value
|
|
200
|
+
async with self.sem:
|
|
201
|
+
media.size = await asyncio.to_thread(lambda: os.stat(media.file_name).st_size)
|
|
202
|
+
|
|
203
|
+
# Convert PosixPath to str and build info
|
|
204
|
+
media.metainfo = json.dumps([{"length": media.size, "path": [media.file_name]}], indent=4)
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
async def process_folder(self, media: Media) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
process media object folder
|
|
210
|
+
|
|
211
|
+
:param media: single Media object
|
|
212
|
+
:return: success or failure
|
|
213
|
+
"""
|
|
214
|
+
# Read the folder and create a list of files
|
|
215
|
+
# TODO forse ha poco senso asyncio.to_thread qui
|
|
216
|
+
entries = await asyncio.to_thread(os.listdir, media.torrent_path)
|
|
217
|
+
files = [my_file for my_file in entries if ManageTitles.filter_ext(my_file)]
|
|
218
|
+
|
|
219
|
+
if not files:
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
files.sort()
|
|
223
|
+
media.status = MediaStatus.INDEXED
|
|
224
|
+
media.job_id_list = self.job_id_list
|
|
225
|
+
media.file_name = Path(media.torrent_path) / files[0]
|
|
226
|
+
media.display_name = ManageTitles.clean_text(Path(media.torrent_path).name)
|
|
227
|
+
media.torrent_name = Path(media.torrent_path).name
|
|
228
|
+
media.doc_description = "\n".join(files)
|
|
229
|
+
|
|
230
|
+
entries = await asyncio.to_thread(lambda: list(self.scan_folder(media.torrent_path)))
|
|
231
|
+
sizes = [e.stat().st_size for e in entries]
|
|
232
|
+
files = [e.name for e in entries]
|
|
233
|
+
|
|
234
|
+
# Build metainfo
|
|
235
|
+
media.size = sum(sizes)
|
|
236
|
+
media.metainfo = json.dumps([{"length": s, "path": [f]} for f, s in zip(files, sizes)], indent=4)
|
|
237
|
+
return True
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import os
|
|
3
|
+
from multiprocessing import Pool, cpu_count, Manager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from services.interfaces import TorrentServiceInterface
|
|
7
|
+
from config.api_data import trackers_api_data
|
|
8
|
+
from config.constants import MediaStatus
|
|
9
|
+
from config.settings import get_settings
|
|
10
|
+
from models.media import Media
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI
|
|
13
|
+
import torf
|
|
14
|
+
|
|
15
|
+
settings = get_settings()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def worker(args) -> dict:
|
|
19
|
+
torrent_path, torrent_name, torrent_archive, trackers_list, progress_queue, job_id = args
|
|
20
|
+
archive = torrent_archive or '.'
|
|
21
|
+
os.makedirs(archive, exist_ok=True)
|
|
22
|
+
torrent_filepath: Path = (Path(archive) / trackers_list[0] / f"{torrent_name}.torrent")
|
|
23
|
+
|
|
24
|
+
announces = [
|
|
25
|
+
trackers_api_data[t.upper()]['announce']
|
|
26
|
+
for t in (trackers_list or []) if t.upper() in trackers_api_data
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
mytorr = torf.Torrent(path=torrent_path, trackers=announces)
|
|
30
|
+
mytorr.comment = settings.prefs.TORRENT_COMMENT
|
|
31
|
+
mytorr.name = torrent_name
|
|
32
|
+
mytorr.created_by = ""
|
|
33
|
+
mytorr.private = True
|
|
34
|
+
mytorr.source = trackers_list[0] if trackers_list else None
|
|
35
|
+
mytorr.segments = 16 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
# The callback
|
|
38
|
+
def callback_progress(torrent, filepath, pieces_done, pieces_total):
|
|
39
|
+
progress = pieces_done / pieces_total * 100
|
|
40
|
+
progress_queue.put({'torrent': torrent_name, 'progress': progress, 'job_id': job_id})
|
|
41
|
+
|
|
42
|
+
mytorr.generate(callback=callback_progress, interval=1)
|
|
43
|
+
mytorr.write(torrent_filepath)
|
|
44
|
+
return {'status': '200', 'message': torrent_filepath, 'job_id': job_id}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MyTorrentService(TorrentServiceInterface):
|
|
48
|
+
def __init__(self, app: FastAPI):
|
|
49
|
+
self.app = app
|
|
50
|
+
|
|
51
|
+
def start(self, media_list: list[Media], batch_size=16, workers: int = 4, progress_queue=None):
|
|
52
|
+
"""
|
|
53
|
+
:param media_list: list of Media objects to process
|
|
54
|
+
:param batch_size: numer of item per batch
|
|
55
|
+
:param workers: number of workers
|
|
56
|
+
:param progress_queue: a safe queue where put the progress
|
|
57
|
+
:return:
|
|
58
|
+
"""
|
|
59
|
+
results = []
|
|
60
|
+
# Sort by Size ( start with the smallest)
|
|
61
|
+
media_list.sort(key=lambda m: m.size)
|
|
62
|
+
|
|
63
|
+
# Create one or more batch list and call the function
|
|
64
|
+
for i in range(0, len(media_list), batch_size):
|
|
65
|
+
batch = media_list[i:i + batch_size]
|
|
66
|
+
batch_results = self._create_batch(batch, trackers=['ITT'], workers=workers, progress_queue=progress_queue)
|
|
67
|
+
results.extend(batch_results)
|
|
68
|
+
return results
|
|
69
|
+
|
|
70
|
+
def _create_batch(self, media_list: list[Media], trackers: list[str] = None, workers: int = None,
|
|
71
|
+
progress_queue=None):
|
|
72
|
+
|
|
73
|
+
with Manager() as manager:
|
|
74
|
+
if progress_queue is None:
|
|
75
|
+
progress_queue = manager.Queue()
|
|
76
|
+
|
|
77
|
+
# Create a list of task only for Media with description and video status positive
|
|
78
|
+
archive_path = self.app.state.torrent_archive_path or '.'
|
|
79
|
+
jobs = [
|
|
80
|
+
(m.torrent_path, m.torrent_name, archive_path, trackers, progress_queue, m.job_id)
|
|
81
|
+
for m in media_list if m.status not in (MediaStatus.DESCRIPTION_ERROR, MediaStatus.VIDEO_ERROR)
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Go
|
|
85
|
+
workers = workers or cpu_count()
|
|
86
|
+
with Pool(workers) as pool:
|
|
87
|
+
results = pool.map(worker, jobs)
|
|
88
|
+
|
|
89
|
+
# Wait for the end
|
|
90
|
+
while not progress_queue.empty():
|
|
91
|
+
progress_update = progress_queue.get()
|
|
92
|
+
print(f"Torrent {progress_update['torrent']}: {progress_update['progress']:.2f}%")
|
|
93
|
+
|
|
94
|
+
return results
|
services/interfaces.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
from fastapi import FastAPI
|
|
6
|
+
from models.media import Media
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VideoServiceInterface(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def generate(self) -> None:
|
|
12
|
+
"""Extract screenshot"""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DescriptionBuilderInterface(ABC):
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def description(self) -> None:
|
|
19
|
+
"""Build a description for the tracker"""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def close(self) -> None: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TorrentServiceInterface(ABC):
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def _create_batch(self, media_list: Iterable[Media], trackers: list[str] = None,
|
|
30
|
+
workers: int | None = None) -> list[str]:
|
|
31
|
+
"""Process groups of media and create for each a torrent file"""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def start(self, media_list: list[Media], batch_size=16, workers: int = 2):
|
|
36
|
+
""" Build a list of batches """
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TrackerServiceInterface(ABC):
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
async def prepare_payload(self, media: Media) -> dict:
|
|
44
|
+
""" Build the payload for the tracker """
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
async def upload(self, media: Media) -> dict:
|
|
49
|
+
"""Upload the torrent and return a result"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def search(self, query: str) -> dict:
|
|
54
|
+
""" Search for a title """
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TorrentClientServiceInterface(ABC):
|
|
59
|
+
"""
|
|
60
|
+
Interface for torrent clients
|
|
61
|
+
"""
|
|
62
|
+
@abstractmethod
|
|
63
|
+
async def login(self) -> bool: ...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def add_torrents(self, torrent_paths: list[str], save_path: str, app: FastAPI) -> bool: ...
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
async def list_torrents(self) -> list: ...
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
async def remove_torrent(self, torrent_id: str) -> bool: ...
|