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,356 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import aiohttp
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import io
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from PIL import Image
|
|
9
|
+
|
|
10
|
+
from services.interfaces import VideoServiceInterface, DescriptionBuilderInterface
|
|
11
|
+
from config.host_data import upload_hosts, master_uploaders
|
|
12
|
+
from external.async_http_client_service import AsyncHttpClient
|
|
13
|
+
from config.constants import MediaStatus
|
|
14
|
+
from config.settings import get_settings
|
|
15
|
+
from config.logger import get_logger
|
|
16
|
+
from models.media import Media
|
|
17
|
+
|
|
18
|
+
from pymediainfo import MediaInfo
|
|
19
|
+
from aiohttp import FormData
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
import numpy as np
|
|
22
|
+
import asyncio
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Based on the old code unit3dup 0.8.21
|
|
27
|
+
class VideoFrame:
|
|
28
|
+
def __init__(self, video_path: str, num_screenshots: int, webp_filepath=None):
|
|
29
|
+
self.video_path = Path(video_path)
|
|
30
|
+
self.webp_filepath = Path(webp_filepath) if webp_filepath else None
|
|
31
|
+
self.num_screenshots = num_screenshots
|
|
32
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
33
|
+
|
|
34
|
+
async def create(self):
|
|
35
|
+
"""
|
|
36
|
+
return list of extract screenshots from the video
|
|
37
|
+
"""
|
|
38
|
+
frames = await self._extract()
|
|
39
|
+
frames_in_bytes = []
|
|
40
|
+
is_hd = 0
|
|
41
|
+
|
|
42
|
+
for frame in frames:
|
|
43
|
+
img_bytes = self.image_to_bytes(frame)
|
|
44
|
+
if frame.height >= 720:
|
|
45
|
+
is_hd = 1
|
|
46
|
+
frames_in_bytes.append(img_bytes)
|
|
47
|
+
|
|
48
|
+
webp = []
|
|
49
|
+
if self.webp_filepath:
|
|
50
|
+
webp = await self.create_webp_from_video(video_path=self.video_path, output_path=self.webp_filepath)
|
|
51
|
+
|
|
52
|
+
return frames_in_bytes + (webp or []), is_hd
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def image_to_bytes(frame: Image.Image) -> bytes:
|
|
56
|
+
"""
|
|
57
|
+
Convert frame bytes to Jpeg with no compression
|
|
58
|
+
"""
|
|
59
|
+
buffered = io.BytesIO()
|
|
60
|
+
frame.save(buffered, format="JPEG", optimize=False, compress_level=4)
|
|
61
|
+
return buffered.getvalue()
|
|
62
|
+
|
|
63
|
+
async def _get_video_duration(self) -> float:
|
|
64
|
+
"""
|
|
65
|
+
return the video duration
|
|
66
|
+
|
|
67
|
+
-show_entries format: Get video duration
|
|
68
|
+
|
|
69
|
+
-of default=noprint_wrappers=1:nokey=1: output formatted to get only the number ( duration value)
|
|
70
|
+
"""
|
|
71
|
+
command = [
|
|
72
|
+
"ffprobe",
|
|
73
|
+
"-v", "error",
|
|
74
|
+
"-show_entries", "format=duration",
|
|
75
|
+
"-of", "default=noprint_wrappers=1:nokey=1",
|
|
76
|
+
str(self.video_path),
|
|
77
|
+
]
|
|
78
|
+
proc = await asyncio.create_subprocess_exec(
|
|
79
|
+
*command,
|
|
80
|
+
stdout=asyncio.subprocess.PIPE,
|
|
81
|
+
stderr=asyncio.subprocess.PIPE
|
|
82
|
+
)
|
|
83
|
+
stdout, _ = await proc.communicate()
|
|
84
|
+
return float(stdout.decode().strip())
|
|
85
|
+
|
|
86
|
+
async def _extract(self):
|
|
87
|
+
"""
|
|
88
|
+
FFPROBE
|
|
89
|
+
- "-v", show the errore only
|
|
90
|
+
- "-select_streams", "v:0", extract only the first stream
|
|
91
|
+
- "-show_entries", "stream=width,height", return only Height and Width value
|
|
92
|
+
- "-of", "default=nw=1:nk=1", print only the numeric value ( WxH value)
|
|
93
|
+
|
|
94
|
+
- FFMPEG
|
|
95
|
+
- "-ss", str(t), seek at time t(secondi)
|
|
96
|
+
- "-i", str(self.video_path), video file path
|
|
97
|
+
- "-vframes", "1", extract only one frame
|
|
98
|
+
- "-f", "rawvideo", output format rawvideo
|
|
99
|
+
- "-pix_fmt", "rgb24", 24-bit rgb - todo: problema con formato png?
|
|
100
|
+
- "-" : output to stdout
|
|
101
|
+
|
|
102
|
+
:return:
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
duration = await self._get_video_duration()
|
|
106
|
+
# todo: Hardcoded values
|
|
107
|
+
start_time = duration * 0.35
|
|
108
|
+
end_time = duration * 0.85
|
|
109
|
+
|
|
110
|
+
times = np.linspace(start_time, end_time, self.num_screenshots, endpoint=True)
|
|
111
|
+
|
|
112
|
+
command = [
|
|
113
|
+
"ffprobe",
|
|
114
|
+
"-v", "error",
|
|
115
|
+
"-select_streams", "v:0",
|
|
116
|
+
"-show_entries", "stream=width,height",
|
|
117
|
+
"-of", "default=nw=1:nk=1",
|
|
118
|
+
str(self.video_path)
|
|
119
|
+
|
|
120
|
+
]
|
|
121
|
+
proc = await asyncio.create_subprocess_exec(
|
|
122
|
+
*command,
|
|
123
|
+
stdout=asyncio.subprocess.PIPE,
|
|
124
|
+
stderr=asyncio.subprocess.PIPE
|
|
125
|
+
)
|
|
126
|
+
stdout, _ = await proc.communicate()
|
|
127
|
+
w, h = stdout.decode().strip().splitlines()
|
|
128
|
+
width, height = int(w), int(h)
|
|
129
|
+
|
|
130
|
+
frames = []
|
|
131
|
+
for t in times:
|
|
132
|
+
command = [
|
|
133
|
+
"ffmpeg",
|
|
134
|
+
"-ss", str(t),
|
|
135
|
+
"-i", str(self.video_path),
|
|
136
|
+
"-vframes", "1",
|
|
137
|
+
"-f", "rawvideo",
|
|
138
|
+
"-pix_fmt", "rgb24", # ho avuto un problema con png
|
|
139
|
+
"-"
|
|
140
|
+
]
|
|
141
|
+
proc = await asyncio.create_subprocess_exec(
|
|
142
|
+
*command,
|
|
143
|
+
stdout=asyncio.subprocess.PIPE,
|
|
144
|
+
stderr=asyncio.subprocess.DEVNULL
|
|
145
|
+
)
|
|
146
|
+
stdout, _ = await proc.communicate()
|
|
147
|
+
img = Image.fromarray(np.frombuffer(stdout, dtype=np.uint8).reshape((height, width, 3)))
|
|
148
|
+
frames.append(img)
|
|
149
|
+
|
|
150
|
+
return frames
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def create_webp_from_video(self, video_path, output_path):
|
|
154
|
+
"""
|
|
155
|
+
- "-y", overwrite output if it exists
|
|
156
|
+
- "-ss", '70', start time at 70 seconds
|
|
157
|
+
- "-t", '10', extracts 10 seconds
|
|
158
|
+
- "-i", video_path, video file path
|
|
159
|
+
- "-vf", f"fps=7,scale=650:-1:flags=lanczos", set fps and scale to 650 ( aspect ratio ok)
|
|
160
|
+
- "-c:v", "libwebp", encode video as WebP
|
|
161
|
+
- "-quality", "50", quality
|
|
162
|
+
- "-loop", "0", repeat the animation
|
|
163
|
+
- "-speed", "9", encoding speed (1 = slowest, 10 = fastest)
|
|
164
|
+
- "-threads", "8", number of threads
|
|
165
|
+
- "-f", "webp", webp format
|
|
166
|
+
- "output_path" output file path
|
|
167
|
+
"""
|
|
168
|
+
command = [
|
|
169
|
+
"ffmpeg",
|
|
170
|
+
"-y",
|
|
171
|
+
"-ss", '70',
|
|
172
|
+
"-t", '10',
|
|
173
|
+
"-i", video_path,
|
|
174
|
+
"-vf", f"fps=7,scale=650:-1:flags=lanczos",
|
|
175
|
+
"-c:v", "libwebp",
|
|
176
|
+
"-quality", "50",
|
|
177
|
+
"-loop", "0",
|
|
178
|
+
"-speed", "9",
|
|
179
|
+
"-threads", "8",
|
|
180
|
+
"-f", "webp",
|
|
181
|
+
output_path
|
|
182
|
+
]
|
|
183
|
+
proc = await asyncio.create_subprocess_exec(
|
|
184
|
+
*command,
|
|
185
|
+
stdout=asyncio.subprocess.PIPE,
|
|
186
|
+
stderr=asyncio.subprocess.PIPE
|
|
187
|
+
)
|
|
188
|
+
await proc.communicate()
|
|
189
|
+
|
|
190
|
+
if not os.path.isfile(output_path):
|
|
191
|
+
self.logger.error(f"file {output_path} doesn't exist!")
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
# todo: vorrei non dover scrivere su file ma in memoria ram
|
|
195
|
+
with open(output_path, "rb") as webp_file:
|
|
196
|
+
webp_file_content = webp_file.read()
|
|
197
|
+
os.remove(output_path)
|
|
198
|
+
return [webp_file_content]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class VideoService(VideoServiceInterface):
|
|
202
|
+
""" Build a description for the torrent page: screenshots, mediainfo, trailers, metadata """
|
|
203
|
+
|
|
204
|
+
def __init__(self, media: Media):
|
|
205
|
+
"""
|
|
206
|
+
:param media: The Media object
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
self.media = media
|
|
210
|
+
self.file_name: str = media.file_name
|
|
211
|
+
self.display_name: str = media.display_name
|
|
212
|
+
self.webp_filepath: str | None = None
|
|
213
|
+
settings = get_settings()
|
|
214
|
+
|
|
215
|
+
if settings.prefs.WEBP_ENABLED:
|
|
216
|
+
self.webp_filepath = f"{media.display_name}.webp"
|
|
217
|
+
|
|
218
|
+
# Load the video frames
|
|
219
|
+
# if web_enabled is off set the number of screenshots to an even number
|
|
220
|
+
if not settings.prefs.WEBP_ENABLED:
|
|
221
|
+
if settings.prefs.NUMBER_OF_SCREENSHOTS % 2 != 0:
|
|
222
|
+
settings.prefs.NUMBER_OF_SCREENSHOTS += 1
|
|
223
|
+
|
|
224
|
+
samples_n = max(2, min(settings.prefs.NUMBER_OF_SCREENSHOTS, 10))
|
|
225
|
+
|
|
226
|
+
self.video_frames: VideoFrame = VideoFrame(video_path=self.file_name,
|
|
227
|
+
num_screenshots=samples_n, webp_filepath=self.webp_filepath)
|
|
228
|
+
|
|
229
|
+
async def generate(self):
|
|
230
|
+
"""Build the information to send to the tracker"""
|
|
231
|
+
|
|
232
|
+
# Extract the frames
|
|
233
|
+
self.media.extracted_frames, self.media.is_hd = await self.video_frames.create()
|
|
234
|
+
# Add Media description
|
|
235
|
+
self.media.media_to_string = await asyncio.to_thread(MediaInfo.parse, self.media.file_name,
|
|
236
|
+
output="STRING", full=False)
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class BuildService(DescriptionBuilderInterface):
|
|
241
|
+
"""
|
|
242
|
+
Upload all previously created screenshots to hostimage, get their URLs, build a description for the tracker
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(self, media_list: list[Media], app: FastAPI, session: aiohttp.ClientSession | None = None):
|
|
246
|
+
"""
|
|
247
|
+
:param media_list: a list of Media objects
|
|
248
|
+
:param session: aiohttp.ClientSession | None
|
|
249
|
+
"""
|
|
250
|
+
self.session = session or aiohttp.ClientSession()
|
|
251
|
+
self.http = AsyncHttpClient(self.session)
|
|
252
|
+
self.media_list = media_list
|
|
253
|
+
self.app = app
|
|
254
|
+
self.screenshots = []
|
|
255
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
256
|
+
settings = get_settings()
|
|
257
|
+
self.sign = (
|
|
258
|
+
f"[url=https://github.com/31December99/Unit3DWebUp][code][color=#00BFFF][size=14]Uploaded with Unit3DwebUp"
|
|
259
|
+
f" {settings.unit3DwebUp.VERSION}[/size][/color][/code][/url]")
|
|
260
|
+
|
|
261
|
+
async def description(self):
|
|
262
|
+
|
|
263
|
+
settings = get_settings()
|
|
264
|
+
priority_map = {
|
|
265
|
+
'ImgBB': settings.prefs.IMGBB_PRIORITY,
|
|
266
|
+
'PtScreens': settings.prefs.PTSCREENS_PRIORITY,
|
|
267
|
+
'LensDump': settings.prefs.LENSDUMP_PRIORITY,
|
|
268
|
+
'ImgFi': settings.prefs.IMGFI_PRIORITY,
|
|
269
|
+
'PassIMA': settings.prefs.PASSIMA_PRIORITY,
|
|
270
|
+
'ImaRide': settings.prefs.IMARIDE_PRIORITY,
|
|
271
|
+
'Freeimage': settings.prefs.FREE_IMAGE_PRIORITY,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
user_order = sorted(master_uploaders, key=lambda uploader: priority_map[uploader])
|
|
275
|
+
|
|
276
|
+
async def upload_frame(media: Media, index: int, image_bytes: bytes) -> str:
|
|
277
|
+
"""
|
|
278
|
+
:param media: the Media object
|
|
279
|
+
:param index: enumeration for the screenshot
|
|
280
|
+
:param image_bytes: the image data
|
|
281
|
+
:return:
|
|
282
|
+
|
|
283
|
+
Upload single frame
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
image_name = f"{media.display_name}.id_{index}"
|
|
287
|
+
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
|
288
|
+
timeout = 30
|
|
289
|
+
|
|
290
|
+
host = upload_hosts[user_order[0]]
|
|
291
|
+
form = FormData()
|
|
292
|
+
form.add_field('key', host['data']['key'])
|
|
293
|
+
|
|
294
|
+
# passtheima host
|
|
295
|
+
if host.get('data', {}).get('fieldname', None):
|
|
296
|
+
form.add_field(host['data']['fieldname'], image_name)
|
|
297
|
+
form.add_field('image', image_base64)
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
response = await self.session.post(host['url'], data=form, timeout=timeout)
|
|
301
|
+
response.raise_for_status()
|
|
302
|
+
result = await response.json()
|
|
303
|
+
if result.get('data', None):
|
|
304
|
+
img_url = result["data"]["image"]["url"]
|
|
305
|
+
else:
|
|
306
|
+
img_url = result["image"]["url"]
|
|
307
|
+
self.logger.info(f"[OK] Uploaded {img_url}")
|
|
308
|
+
self.screenshots.append(img_url)
|
|
309
|
+
media.status = MediaStatus.DESCRIPTION_READY
|
|
310
|
+
return f"[url={img_url}][img=650]{img_url}[/img][/url]"
|
|
311
|
+
except Exception as e:
|
|
312
|
+
self.logger.error(f"[ERROR] Upload frame {image_name}: {e}")
|
|
313
|
+
media.status = MediaStatus.DESCRIPTION_ERROR
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
async def upload_media(media: Media) -> str | None:
|
|
317
|
+
"""
|
|
318
|
+
:param media: the Media object
|
|
319
|
+
:return:
|
|
320
|
+
|
|
321
|
+
upload media screenshot to hostimage
|
|
322
|
+
"""
|
|
323
|
+
tasks = [upload_frame(media, idx, img_bytes) for idx, img_bytes in enumerate(media.extracted_frames)]
|
|
324
|
+
uploaded_frames = await asyncio.gather(*tasks)
|
|
325
|
+
|
|
326
|
+
media.screen_shots = self.screenshots
|
|
327
|
+
if media.status == MediaStatus.DESCRIPTION_READY:
|
|
328
|
+
description = "[center]\n" + "".join(uploaded_frames)
|
|
329
|
+
if media.trailer:
|
|
330
|
+
description += (
|
|
331
|
+
f"\n[/center][b][spoiler=Spoiler:"
|
|
332
|
+
f" PLAY TRAILER][center][youtube]{media.trailer}[/youtube]"
|
|
333
|
+
f"[/center][/spoiler][/b]")
|
|
334
|
+
description+=self.sign
|
|
335
|
+
return description
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
# Upload
|
|
339
|
+
all_descriptions = await asyncio.gather(*[upload_media(media) for media in self.media_list])
|
|
340
|
+
# Add description
|
|
341
|
+
for media, desc in zip(self.media_list, all_descriptions):
|
|
342
|
+
if media.status == MediaStatus.DESCRIPTION_READY:
|
|
343
|
+
media.description = desc
|
|
344
|
+
else:
|
|
345
|
+
self.logger.warning("Description error")
|
|
346
|
+
# Send a message to the frontend by ws
|
|
347
|
+
await self.app.state.ws_manager.broadcast({
|
|
348
|
+
"type": "log",
|
|
349
|
+
"level": "error",
|
|
350
|
+
"message": f"Failed to build description for {media.file_name}",
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
async def close(self) -> None:
|
|
356
|
+
await self.session.close()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: Unit3DwebUp
|
|
3
|
+
Version: 0.0.14
|
|
4
|
+
Summary: A Unit3D uploader with a web interface
|
|
5
|
+
Author: Parzival
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/31December99/Unit3DupWeb
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: aiohttp==3.13.3
|
|
11
|
+
Requires-Dist: fastapi==0.129.0
|
|
12
|
+
Requires-Dist: guessit==3.8.0
|
|
13
|
+
Requires-Dist: numpy==2.4.2
|
|
14
|
+
Requires-Dist: pathvalidate==3.3.1
|
|
15
|
+
Requires-Dist: pillow==12.1.1
|
|
16
|
+
Requires-Dist: pydantic==2.12.5
|
|
17
|
+
Requires-Dist: pydantic_core==2.41.5
|
|
18
|
+
Requires-Dist: pymediainfo==7.0.1
|
|
19
|
+
Requires-Dist: pydantic-settings==2.12.0
|
|
20
|
+
Requires-Dist: qbittorrent-api==2025.11.1
|
|
21
|
+
Requires-Dist: python-dotenv==1.2.1
|
|
22
|
+
Requires-Dist: redis==7.1.1
|
|
23
|
+
Requires-Dist: thefuzz==0.22.1
|
|
24
|
+
Requires-Dist: torf==4.3.1
|
|
25
|
+
Requires-Dist: watchdog==6.0.0
|
|
26
|
+
Requires-Dist: uvicorn[standard]==0.40.0
|
|
27
|
+
|
|
28
|
+
# Unit3DupWeb
|
|
29
|
+
|
|
30
|
+

|
|
31
|
+

|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
# Auto Torrent Generator and Uploader
|
|
41
|
+
|
|
42
|
+
Reworked from the original [Unit3Dup](https://github.com/31December99/Unit3Dup)
|
|
43
|
+
|
|
44
|
+
This code is under testing.
|
|
45
|
+
|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Demo video
|
|
51
|
+
|
|
52
|
+
[Here](https://streamable.com/agoizj)
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## What it does
|
|
57
|
+
|
|
58
|
+
* Scan folder and subfolders
|
|
59
|
+
* Compiles metadata to create a torrent
|
|
60
|
+
* Extracts screenshots from video
|
|
61
|
+
* Adds webp images to torrent page description
|
|
62
|
+
* Searches IDs on TMDB, IMDb, TVDB
|
|
63
|
+
* Adds trailer from TMDB or YouTube
|
|
64
|
+
* Seeds in qBittorrent
|
|
65
|
+
* Generates metadata from video
|
|
66
|
+
* Creates and uploads torrents/pages
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## NOT YET TESTED
|
|
71
|
+
|
|
72
|
+
* Extracts cover from PDF documents
|
|
73
|
+
* Reseeding multiple torrents
|
|
74
|
+
* Cross-OS seeding
|
|
75
|
+
* Custom season titles
|
|
76
|
+
* MediaInfo-based metadata generation
|
|
77
|
+
* Extract first page of PDFs via xpdf and upload it
|
|
78
|
+
* Windows support improvements
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## NOT YET IMPLEMENTED
|
|
83
|
+
|
|
84
|
+
* Game metadata generation
|
|
85
|
+
* Transmission / rTorrent seeding
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Install from Docker Hub
|
|
90
|
+
|
|
91
|
+
* Complete `.env.example`
|
|
92
|
+
* Rename to `.env`
|
|
93
|
+
* Run:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
docker-compose pull
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
The backend provides FastAPI endpoints.
|
|
104
|
+
|
|
105
|
+
For each video file, a job_id is generated from the hash of its path.
|
|
106
|
+
|
|
107
|
+
* job_ids form a job_list (page view)
|
|
108
|
+
* each page has a job_list_id based on scan path hash
|
|
109
|
+
|
|
110
|
+
WebSocket is used for:
|
|
111
|
+
|
|
112
|
+
* progress updates
|
|
113
|
+
* logs to frontend
|
|
114
|
+
|
|
115
|
+
### Scan process
|
|
116
|
+
|
|
117
|
+
* Search files/folders
|
|
118
|
+
* Extract title
|
|
119
|
+
* Query TMDB (movie/series)
|
|
120
|
+
* Query TVDB and get IMDb ID
|
|
121
|
+
* Create screenshots
|
|
122
|
+
* Build description with MediaInfo + screenshots
|
|
123
|
+
|
|
124
|
+
### Editing
|
|
125
|
+
|
|
126
|
+
If poster has issues (TMDB/IMDb mismatch):
|
|
127
|
+
|
|
128
|
+
* click poster
|
|
129
|
+
* edit fields
|
|
130
|
+
* create torrent / upload / seed
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## Frontend build
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
flutter pub get
|
|
138
|
+
flutter build web --release --wasm
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Backend build
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
docker-compose -f build.yml build --no-cache
|
|
147
|
+
docker-compose up
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Docker Hub
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
docker login
|
|
156
|
+
|
|
157
|
+
docker tag unit3dwebup-backend:latest parzival2025/backend_app:x.y.z
|
|
158
|
+
docker tag unit3dwebup-frontend:latest parzival2025/frontend_app:x.y.z
|
|
159
|
+
|
|
160
|
+
docker push parzival2025/backend_app:x.y.z
|
|
161
|
+
docker push parzival2025/frontend_app:x.y.z
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Trackers
|
|
167
|
+
|
|
168
|
+
The Italian tracker community: people with technical and social backgrounds
|
|
169
|
+
united by torrents.
|
|
170
|
+
|
|
171
|
+
| Tracker | Description |
|
|
172
|
+
| ------- | ---------------------------------------------------- |
|
|
173
|
+
| ITT | [https://itatorrents.xyz/](https://itatorrents.xyz/) |
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## Discord
|
|
178
|
+
|
|
179
|
+
[Join Discord](https://discord.gg/8RpwN2Khcz)
|
|
180
|
+
|
|
181
|
+

|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## AstraeLabs
|
|
186
|
+
|
|
187
|
+
[GitHub Project](https://github.com/AstraeLabs/VibraVid)
|
|
188
|
+
|
|
189
|
+
Open-source script for downloading movies, TV shows, and anime.
|
|
190
|
+
|
|
191
|
+

|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
config/__init__.py,sha256=38D2YRz9kUehElDnmAjshs4IQNdI9Ueop1ze-lU8wxQ,114
|
|
2
|
+
config/api_data.py,sha256=WPC334sgcl25sqCiLEm61VKG9Hq4jH6QOxykEtjbuS8,822
|
|
3
|
+
config/constants.py,sha256=EginqIQt_TJh-BDvbddCqPN9HW83JfniOHAOkALA-Qc,773
|
|
4
|
+
config/host_data.py,sha256=H3tIMPbKCtbw-wYOsOr1Tg6ZfvD8ZfJq7c5NtahrDSU,1613
|
|
5
|
+
config/itt.py,sha256=V2tUAqE1F8H9CUcb8-44xCefohdRqltrzW9VUAn5HRI,3317
|
|
6
|
+
config/logger.py,sha256=lucl0ySM6fSchu-YMalYTCrgDzZTLYQhQwYUwfO9Uss,1025
|
|
7
|
+
config/settings.py,sha256=-Dm52cukiE7i_oXI-RNVGKvqrgP8zQNJZsefyxhYkYY,6628
|
|
8
|
+
config/sis.py,sha256=_hp5Z6kLc7QLkm5o0NyKp0lZ1L4l3jYK4xTDNSAJqoI,2562
|
|
9
|
+
config/tags.py,sha256=fF5vTugAMQCNpqcIFLGgNd934ZQKWPRDeNDvUfozKQE,11360
|
|
10
|
+
config/trackers.py,sha256=1jZOt4WfYIbP53F5S9kLVCsGDaPmkwBAjUSKILA4fVI,1458
|
|
11
|
+
external/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
external/async_http_client_service.py,sha256=OCrqLzLyqZW4T1PdEPs6W7np9eG5XgttogBaJGSIRL0,1465
|
|
13
|
+
external/websocket.py,sha256=LP_bPb3hnZbR1XtC-k9BVTELnyc0iXyGh0oGZxHq3mY,1082
|
|
14
|
+
models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
models/interfaces.py,sha256=VYYSJ_FdKTBjiGIdSuhCShjRrDLbzVlsCIQpQXe2e60,751
|
|
16
|
+
models/keywords.py,sha256=GzZ-xPZyYLiZlFNb93793Mf8u4_LVulFvd0RzFrpDQg,247
|
|
17
|
+
models/media.py,sha256=gTNwmKd1irHW1julWCLCr4nzvVE-mXLzfXOsE3IBFnE,19724
|
|
18
|
+
models/media_info.py,sha256=WmYHyFCG-A0RP4fQ5XlGJF8NBvpqQotH_1p7sZPrr0Y,5059
|
|
19
|
+
models/movie.py,sha256=G3GM_WEAsfQCOpeUnvHEtcOc3DN10w1FElZWHs2eQqw,7618
|
|
20
|
+
models/tv.py,sha256=Ibo7C6XzFxHq0pmD-qJDspM10DxSQRV6conNnqzGDKQ,5789
|
|
21
|
+
models/tvdb_search.py,sha256=rqxhp6JX2DnFAWEz-KpmhEF7F138h82Crd2ITuukzrk,3034
|
|
22
|
+
models/videos.py,sha256=TaoPt9zIlMweKFmhw5vyWqNuFFfn3gllbEfCC8X-Xvo,413
|
|
23
|
+
repositories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
repositories/db_online.py,sha256=jx1tHwekNRH0f6TYi5ITWskMDflfC1RFIEBrwPDP0uY,2890
|
|
25
|
+
repositories/interfaces.py,sha256=r5UonODzBHUMzItJJjgiFsa1Eu5zfqtrRH_h1DjvO6E,1616
|
|
26
|
+
repositories/job_repos.py,sha256=4vU6_Z9ZxwyzyP-0RNIIPzSvHpSPqf0P2P1sXcJOTBg,4854
|
|
27
|
+
repositories/media_info_factory.py,sha256=_QCx2q4Dd9bUpCwmmYyg0uORLMLIlM5hbw1J1H4DiJM,810
|
|
28
|
+
services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
services/auto_async_service.py,sha256=Y6c8eSCBVo4ugm-TUiT1yJLbqqKoSJhy05XtdS2MSGs,8931
|
|
30
|
+
services/create_torrent_service.py,sha256=VN7bKkoayBHl0k8opGTM2VVCLRY8ruzaLBPV_Guvry0,3584
|
|
31
|
+
services/interfaces.py,sha256=CQHTxugH8vscv0GEC5fjJV5Tu6Bjyn8B9eQlW03uIkE,1832
|
|
32
|
+
services/itt_tracker_helper.py,sha256=U-HhkP_F-P4jYzBRrgbSHQL7TzDIZbS98PRXg_gG6ps,16194
|
|
33
|
+
services/itt_tracker_service.py,sha256=X6CxnLt9hJq5Fm5OqKE_TK_yCfFq1m_z8LiknoNpxtU,3358
|
|
34
|
+
services/lifespan_service.py,sha256=4yjFtSa-37nw8TbhDYd1mQxrGrLRDH6QlFWvX390k5c,2657
|
|
35
|
+
services/media_service.py,sha256=fF-u-jyB3KiJFYs-PnkhxTNyMrQQ4fp4RsX-jjbiPbo,5195
|
|
36
|
+
services/tags_service.py,sha256=k2lFCOc1FOfBj058j3_kq1drm2upo7kObiGBS3lOqzQ,14998
|
|
37
|
+
services/tmdb.py,sha256=sMxP3lGQpWCkcbje0SeR9R4wUarb-ebASl_Ou7ympEg,8834
|
|
38
|
+
services/torrent_client_service.py,sha256=6R8xeHHadfbmViddbi9sDHMCJ-yJS7Y-I3znD1mmiAM,2758
|
|
39
|
+
services/torrent_service.py,sha256=Y6yIZb1FL_lkU7sPi7pp5dCuZO_vw2P8Vv4iG2mQBGE,3841
|
|
40
|
+
services/tvdb.py,sha256=4xU7i5kLz5aMA39hsOOMnedH6gbX53mJFcjfpWI9kE8,2105
|
|
41
|
+
services/utility.py,sha256=jis0CiCMuzO6st08a3CN1GsOY86LRS1dc36OSZ0gwtU,13401
|
|
42
|
+
services/video_service.py,sha256=WwMJNIY5AXe7gemF6zeOS8x-k4twt2lUa4eEtSSo_H8,13560
|
|
43
|
+
use_case/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
44
|
+
use_case/make_torrent_usecase.py,sha256=6iwph0waXix90kBfnFJpRj8fpPZenIMuGMSDw1kdeRo,2227
|
|
45
|
+
use_case/process_all_usecase.py,sha256=MstzTeBqy3dSjEDjYzdVB1l7YRV_-ddVRAMtlj6bh40,1618
|
|
46
|
+
use_case/scan_media_usecase.py,sha256=j1zJIqJ1gxfRDiXQlVhAFvRK2QC9297o3X_oe2dF_8Y,4375
|
|
47
|
+
use_case/seed_usecase.py,sha256=5lDulS0MtAN9LVZ8H8loH_pyLsIYXLq8qZf0k3s61lg,4408
|
|
48
|
+
use_case/upload_usecase.py,sha256=8Eu1q8QFsxlodDeCNaBGpFHDQYdJLJ_FDH8a4aCaLQc,2748
|
|
49
|
+
unit3dwebup-0.0.14.dist-info/METADATA,sha256=xoK_SIL2B-mAU9QUKi3kaYbFpkpHbRe6NzskRWgurLk,4060
|
|
50
|
+
unit3dwebup-0.0.14.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
51
|
+
unit3dwebup-0.0.14.dist-info/entry_points.txt,sha256=09JdfHldw7AOMJA8Dh9Na3pnd6_4bdSKjQsnMQZsFKY,43
|
|
52
|
+
unit3dwebup-0.0.14.dist-info/top_level.txt,sha256=vt_gnbJJYqpYlLBQNYiAh2DtiFVJBh7enVITw-wImV0,54
|
|
53
|
+
unit3dwebup-0.0.14.dist-info/RECORD,,
|
use_case/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from services.torrent_service import TorrentService
|
|
7
|
+
from models.media import Media
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MakeTorrentUseCase:
|
|
12
|
+
def __init__(self, app: FastAPI, job_id: str | None = None):
|
|
13
|
+
"""
|
|
14
|
+
Create one or more torrents
|
|
15
|
+
|
|
16
|
+
:param app: the fastapi app
|
|
17
|
+
:param job_id: the job id
|
|
18
|
+
"""
|
|
19
|
+
self.media_list = None
|
|
20
|
+
self.app = app
|
|
21
|
+
self.job_id = job_id
|
|
22
|
+
|
|
23
|
+
async def execute(self, media_list: list[Media] | None = None) -> bool:
|
|
24
|
+
"""
|
|
25
|
+
Execute the torrent use case: create one or more torrents , notify to frontend
|
|
26
|
+
:param media_list: list of media objects used to create torrents
|
|
27
|
+
:return:
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Create the media list for single torrent based on the job_id
|
|
31
|
+
if self.job_id and not media_list:
|
|
32
|
+
results = await self.app.state.job.get_job(job_id=self.job_id)
|
|
33
|
+
self.media_list = [
|
|
34
|
+
Media.from_dict(item)
|
|
35
|
+
for item in [json.loads(results)]
|
|
36
|
+
]
|
|
37
|
+
else:
|
|
38
|
+
# Receive a list
|
|
39
|
+
self.media_list = media_list
|
|
40
|
+
|
|
41
|
+
# FILTER: filter for existing torrent. Ensures that only new torrents are created
|
|
42
|
+
filtered_torrent_list = []
|
|
43
|
+
for media in self.media_list:
|
|
44
|
+
if Path.exists(media.torrent_file_path):
|
|
45
|
+
# notify the frontend
|
|
46
|
+
await self.send_message(media=media, message=f"Torrent file exists")
|
|
47
|
+
else:
|
|
48
|
+
# Add media objects for the new torrents
|
|
49
|
+
filtered_torrent_list.append(media)
|
|
50
|
+
|
|
51
|
+
if filtered_torrent_list:
|
|
52
|
+
torrent_service = TorrentService(app=self.app)
|
|
53
|
+
await torrent_service.start(media_list=filtered_torrent_list)
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
async def send_message(self, media: Media, message: str):
|
|
59
|
+
"""
|
|
60
|
+
:param media: The current Media object
|
|
61
|
+
:param message: Send the message to single poster
|
|
62
|
+
:return:
|
|
63
|
+
"""
|
|
64
|
+
await self.app.state.ws_manager.broadcast({
|
|
65
|
+
"type": "posterLogMessage",
|
|
66
|
+
"job_id": media.job_id,
|
|
67
|
+
"message": message})
|