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
models/tvdb_search.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from models.interfaces import MediaRepoInterface
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# slots, non avendo la necessità di aggiungere nuovi attributi al volo
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class TvdbRemoteID:
|
|
11
|
+
id: str
|
|
12
|
+
sourceName: str
|
|
13
|
+
type: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class TvdbSearchResult(MediaRepoInterface):
|
|
18
|
+
"""
|
|
19
|
+
Dataclass for manage result from a TVDB search
|
|
20
|
+
TMDB e TVDB share the same interface
|
|
21
|
+
"""
|
|
22
|
+
id: str
|
|
23
|
+
tvdb_id: str
|
|
24
|
+
name: str
|
|
25
|
+
extended_title: str | None
|
|
26
|
+
slug: str | None
|
|
27
|
+
type: str
|
|
28
|
+
primary_type: str
|
|
29
|
+
status: str | None
|
|
30
|
+
year: str | None
|
|
31
|
+
first_air_time: str | None
|
|
32
|
+
country: str | None
|
|
33
|
+
primary_language: str | None
|
|
34
|
+
director: str | None # for movies
|
|
35
|
+
|
|
36
|
+
studios: list[str] = field(default_factory=list)
|
|
37
|
+
genres: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
overview: str | None = None
|
|
40
|
+
overviews: dict[str, str] = field(default_factory=dict)
|
|
41
|
+
translations: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
|
|
43
|
+
image_url: str | None = None
|
|
44
|
+
thumbnail: str | None = None
|
|
45
|
+
|
|
46
|
+
# for imdb id
|
|
47
|
+
remote_ids: list[TvdbRemoteID] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_dict(cls, item: dict) -> TvdbSearchResult:
|
|
51
|
+
remote_ids = [TvdbRemoteID(**r) for r in item.get("remote_ids", [])]
|
|
52
|
+
|
|
53
|
+
return cls(
|
|
54
|
+
id=item.get("id", ""),
|
|
55
|
+
tvdb_id=item.get("tvdb_id", "0"),
|
|
56
|
+
name=item.get("name", ""),
|
|
57
|
+
extended_title=item.get("extended_title"),
|
|
58
|
+
slug=item.get("slug"),
|
|
59
|
+
type=item.get("type", ""),
|
|
60
|
+
primary_type=item.get("primary_type", ""),
|
|
61
|
+
status=item.get("status"),
|
|
62
|
+
year=item.get("year"),
|
|
63
|
+
first_air_time=item.get("first_air_time"),
|
|
64
|
+
country=item.get("country"),
|
|
65
|
+
primary_language=item.get("primary_language"),
|
|
66
|
+
director=item.get("director"),
|
|
67
|
+
studios=item.get("studios", []),
|
|
68
|
+
genres=item.get("genres", []),
|
|
69
|
+
overview=item.get("overview"),
|
|
70
|
+
overviews=item.get("overviews", {}),
|
|
71
|
+
translations=item.get("translations", {}),
|
|
72
|
+
image_url=item.get("image_url"),
|
|
73
|
+
thumbnail=item.get("thumbnail"),
|
|
74
|
+
remote_ids=remote_ids,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def get_title(self) -> str:
|
|
78
|
+
return self.name
|
|
79
|
+
|
|
80
|
+
def get_original(self) -> str:
|
|
81
|
+
return self.extended_title or self.name
|
|
82
|
+
|
|
83
|
+
def get_date(self) -> str:
|
|
84
|
+
return self.first_air_time or ""
|
|
85
|
+
|
|
86
|
+
def get_id(self) -> int:
|
|
87
|
+
try:
|
|
88
|
+
return int(self.tvdb_id)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
def get_poster_path(self) -> str:
|
|
93
|
+
return self.image_url or ""
|
|
94
|
+
|
|
95
|
+
def get_imdb(self) -> str | None:
|
|
96
|
+
for r in self.remote_ids:
|
|
97
|
+
if 'IMDB' in r.sourceName.upper():
|
|
98
|
+
return r.id.lower().replace('tt', '')
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
def get_translations(self) -> dict[str, str] | None:
|
|
102
|
+
return self.translations
|
models/videos.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Videos:
|
|
8
|
+
"""
|
|
9
|
+
Dataclass for manage result from a TMDB search : Trailers
|
|
10
|
+
"""
|
|
11
|
+
id: str
|
|
12
|
+
iso_3166_1: str
|
|
13
|
+
iso_639_1: str
|
|
14
|
+
key: str
|
|
15
|
+
name: str
|
|
16
|
+
official: bool
|
|
17
|
+
published_at: str
|
|
18
|
+
site: str
|
|
19
|
+
size: int
|
|
20
|
+
type: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Data:
|
|
25
|
+
id: int
|
|
26
|
+
results: list[Videos]
|
repositories/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from typing import TypeVar
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from models.interfaces import MediaRepoInterface
|
|
6
|
+
from models.movie import AltTitle, NowPlaying
|
|
7
|
+
from models.tv import OnTheAir, DataResponse
|
|
8
|
+
from models.keywords import Keyword
|
|
9
|
+
from models.videos import Data
|
|
10
|
+
|
|
11
|
+
from repositories.interfaces import MovieRepositoryInterface
|
|
12
|
+
|
|
13
|
+
from services.tmdb import TmdbAsyncAPI
|
|
14
|
+
from services.tvdb import TvdbAsyncAPI
|
|
15
|
+
|
|
16
|
+
T = TypeVar('T')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Tmdb(TmdbAsyncAPI, MovieRepositoryInterface):
|
|
20
|
+
"""
|
|
21
|
+
the purpose is providing a common interface for calling service classes
|
|
22
|
+
while keeping the service implementations separate
|
|
23
|
+
It is an update of the old code unit3dup 0.8.21
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, session: aiohttp.ClientSession | None = None):
|
|
27
|
+
super().__init__(session)
|
|
28
|
+
|
|
29
|
+
async def search(self, query: str, category: str) -> list[T] | None:
|
|
30
|
+
result = await super().search(query=query, category=category)
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
async def alternative(self, movie_id: int, category: str) -> AltTitle | DataResponse | None:
|
|
34
|
+
return await super().alternative(movie_id=movie_id, category=category)
|
|
35
|
+
|
|
36
|
+
async def videos(self, movie_id: int, category: str) -> Data | None:
|
|
37
|
+
return await super().videos(movie_id=movie_id, category=category)
|
|
38
|
+
|
|
39
|
+
async def details(self, video_id: int, category: str) -> Data | None:
|
|
40
|
+
return await super().details(video_id=video_id, category=category)
|
|
41
|
+
|
|
42
|
+
async def keywords(self, movie_id: int, category: str) -> Keyword | None:
|
|
43
|
+
return await super().keywords(movie_id=movie_id, category=category)
|
|
44
|
+
|
|
45
|
+
async def playing(self, category: str) -> list[NowPlaying] | list[OnTheAir] | None:
|
|
46
|
+
return await super().playing(category=category)
|
|
47
|
+
|
|
48
|
+
async def close(self):
|
|
49
|
+
await self.session.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Tvdb(TvdbAsyncAPI, MovieRepositoryInterface):
|
|
53
|
+
"""
|
|
54
|
+
the purpose is providing a common interface for calling service classes
|
|
55
|
+
while keeping the service implementations separate
|
|
56
|
+
It is an update of the old code unit3dup 0.8.21
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, session: aiohttp.ClientSession | None = None):
|
|
60
|
+
super().__init__(session)
|
|
61
|
+
|
|
62
|
+
async def search(self, query: str, category: str = None) -> list[MediaRepoInterface]:
|
|
63
|
+
await super().tvdb_login()
|
|
64
|
+
return await super().search(query=query, category=category)
|
|
65
|
+
|
|
66
|
+
async def alternative(self, movie_id: int, category: str) -> AltTitle | DataResponse | None:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
async def videos(self, movie_id: int, category: str) -> Data | None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
async def details(self, video_id: int, category: str) -> Data | None:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def keywords(self, movie_id: int, category: str) -> Keyword | None:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
async def playing(self, category: str) -> list[NowPlaying] | list[OnTheAir] | None:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
async def close(self):
|
|
82
|
+
await self.session.close()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
from models.interfaces import MediaRepoInterface
|
|
5
|
+
from models.movie import Movie, NowPlaying
|
|
6
|
+
from models.keywords import Keyword
|
|
7
|
+
from models.videos import Data
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MovieRepositoryInterface(ABC):
|
|
13
|
+
"""
|
|
14
|
+
an Interface class for TMDB and TVDB to share the same methods
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def search(self, query: str, category: str = None) -> list[MediaRepoInterface]: ...
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def alternative(self, movie_id: int, category: str) -> list: ...
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def videos(self, movie_id: int, category: str) -> Data: ...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def details(self, movie_id: int, category: str) -> Movie: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
async def keywords(self, movie_id: int, category: str) -> list[Keyword]: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def playing(self, category: str) -> list[NowPlaying]: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
async def close(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class JobRepositoryInterface(ABC):
|
|
40
|
+
"""
|
|
41
|
+
an Interface class for Redis to share the same methods with other repositories that are not yet implemented
|
|
42
|
+
"""
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def connect(self, app: FastAPI): ...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def create_job(self, job_id: str, data: dict): ...
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def create_profile(self, data: dict): ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def update_job(self, job_id: str, new_job: dict): ...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def delete_job_list(self, job_id: str): ...
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
async def close(self): ...
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
from config.logger import get_logger
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from repositories.interfaces import JobRepositoryInterface
|
|
10
|
+
import redis.asyncio as redis
|
|
11
|
+
from redis import exceptions as redis_exceptions, Redis
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JobRedisRepo(JobRepositoryInterface):
|
|
15
|
+
"""
|
|
16
|
+
Manage Redis connections
|
|
17
|
+
|
|
18
|
+
Save Media data to Redis
|
|
19
|
+
|
|
20
|
+
Redis instance is stored in FastAPi app.state
|
|
21
|
+
|
|
22
|
+
The purpose is to avoid waste calls to scanner and share data across endpoints and other..(later)
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, url: str):
|
|
27
|
+
"""
|
|
28
|
+
:param url: Redis server url
|
|
29
|
+
"""
|
|
30
|
+
self.redis = redis.from_url(url, decode_responses=True)
|
|
31
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
32
|
+
|
|
33
|
+
async def connect(self, app: FastAPI) -> Redis | None:
|
|
34
|
+
"""
|
|
35
|
+
:param app: FastAPi app instance
|
|
36
|
+
:return: the redis instance stored in the fastapi app
|
|
37
|
+
"""
|
|
38
|
+
app.state.redis = self.redis
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
if await self.redis.ping(): print("INfO: DB connected to redis://localhost:6379")
|
|
42
|
+
return self.redis
|
|
43
|
+
except redis_exceptions.ConnectionError as e:
|
|
44
|
+
self.logger.error("Database connection error")
|
|
45
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
async def create_job(self, job_id: str, data: dict):
|
|
49
|
+
"""
|
|
50
|
+
:param job_id: Job id is the file or folder path hashed stored in the Media class object
|
|
51
|
+
:param data: a dict che store the data of Media object ( media to dict())
|
|
52
|
+
:return:
|
|
53
|
+
|
|
54
|
+
Create a new job
|
|
55
|
+
Each job represents a Media file or folder
|
|
56
|
+
|
|
57
|
+
Every Media object contains a job_id (hash)
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
await self.redis.hset(job_id, mapping={
|
|
61
|
+
"data": json.dumps(data),
|
|
62
|
+
})
|
|
63
|
+
except Exception as e:
|
|
64
|
+
self.logger.error(e)
|
|
65
|
+
return job_id
|
|
66
|
+
|
|
67
|
+
async def create_job_list(self, job_id: str, job_list: list):
|
|
68
|
+
"""
|
|
69
|
+
:param job_id: Job id is the scan path hashed
|
|
70
|
+
:param job_list: a list of job id
|
|
71
|
+
:return:
|
|
72
|
+
|
|
73
|
+
It represents a page on the frontend that is a list of Poster
|
|
74
|
+
|
|
75
|
+
We use joblist to manage group of posters
|
|
76
|
+
|
|
77
|
+
Each poster is a job id
|
|
78
|
+
"""
|
|
79
|
+
await self._save_list(job_id, job_list)
|
|
80
|
+
|
|
81
|
+
async def get_job_list(self, job_id: str) -> list:
|
|
82
|
+
"""
|
|
83
|
+
:param job_id: the job id list
|
|
84
|
+
:return: a list of job id
|
|
85
|
+
|
|
86
|
+
Get a list of Jobs ID
|
|
87
|
+
|
|
88
|
+
it represents a page on the frontend
|
|
89
|
+
|
|
90
|
+
For example we load the page on start to avoid running a new scan on the ssd and requests to TMDB,TVDB
|
|
91
|
+
"""
|
|
92
|
+
ids = await self.redis.smembers(f"{job_id}:ids")
|
|
93
|
+
return list(set(ids))
|
|
94
|
+
|
|
95
|
+
async def create_profile(self, data: dict):
|
|
96
|
+
"""
|
|
97
|
+
:param data: a dict che store the configuration file read from the server
|
|
98
|
+
:return:
|
|
99
|
+
|
|
100
|
+
Create a new job that includes the user preferences read from the configuration file
|
|
101
|
+
|
|
102
|
+
You can see the preferences on the preferences page
|
|
103
|
+
"""
|
|
104
|
+
await self.create_job(job_id='0', data=data)
|
|
105
|
+
|
|
106
|
+
async def get_job(self, job_id: str):
|
|
107
|
+
"""
|
|
108
|
+
:param job_id: Job id is the file or folder path hashed stored in the Media class object
|
|
109
|
+
:return: a dict that stores data about Media object
|
|
110
|
+
"""
|
|
111
|
+
return await self.redis.hget(job_id, 'data')
|
|
112
|
+
|
|
113
|
+
async def update_job(self, job_id: str, new_data: dict):
|
|
114
|
+
"""
|
|
115
|
+
:param job_id: Job id is the file or folder path hashed stored in the Media class object
|
|
116
|
+
:param new_data: a dict that stores new data
|
|
117
|
+
:return:
|
|
118
|
+
|
|
119
|
+
Update a specific field of a job
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
current = await self.redis.hget(job_id, "data")
|
|
123
|
+
if current:
|
|
124
|
+
data = json.loads(current)
|
|
125
|
+
else:
|
|
126
|
+
data = {}
|
|
127
|
+
|
|
128
|
+
data.update(new_data)
|
|
129
|
+
|
|
130
|
+
await self.redis.hset(job_id, mapping={
|
|
131
|
+
"data": json.dumps(data),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
return job_id
|
|
135
|
+
|
|
136
|
+
async def _save_list(self, job_id: str, job_list_id: list):
|
|
137
|
+
"""
|
|
138
|
+
:param job_id: the job id list
|
|
139
|
+
:param job_list_id: a list of job id
|
|
140
|
+
:return:
|
|
141
|
+
|
|
142
|
+
Replace an old job list with a new one
|
|
143
|
+
|
|
144
|
+
for example if the user moves or deletes or adds new file in the scan folder
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
await self.redis.delete(f"{job_id}:ids")
|
|
148
|
+
await self.redis.sadd(f"{job_id}:ids", *job_list_id)
|
|
149
|
+
|
|
150
|
+
async def delete_job_list(self, job_id: str):
|
|
151
|
+
"""
|
|
152
|
+
:param job_id: the job id list
|
|
153
|
+
:return:
|
|
154
|
+
|
|
155
|
+
Delete an old job list
|
|
156
|
+
|
|
157
|
+
For example if the user wipes the scan folder
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
await self.redis.delete(f"{job_id}:ids")
|
|
161
|
+
|
|
162
|
+
async def close(self):
|
|
163
|
+
"""
|
|
164
|
+
Close connection to Redis server
|
|
165
|
+
"""
|
|
166
|
+
await self.redis.close()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from pymediainfo import MediaInfo
|
|
6
|
+
from models.media_info import MediaFile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MediaFileFactory:
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
async def from_path(path: str) -> MediaFile:
|
|
13
|
+
"""
|
|
14
|
+
Try to run pymediainfo without blocking
|
|
15
|
+
"""
|
|
16
|
+
info = await asyncio.to_thread(MediaInfo.parse, path)
|
|
17
|
+
data = info.to_data().get("tracks", [])
|
|
18
|
+
|
|
19
|
+
video = [track for track in data if track.get("track_type") == "Video"]
|
|
20
|
+
audio = [track for track in data if track.get("track_type") == "Audio"]
|
|
21
|
+
general = next((track for track in data if track.get("track_type") == "General"), {})
|
|
22
|
+
|
|
23
|
+
return MediaFile(
|
|
24
|
+
file_path=path,
|
|
25
|
+
video_tracks=video,
|
|
26
|
+
audio_tracks=audio,
|
|
27
|
+
general_track=general,
|
|
28
|
+
)
|
services/__init__.py
ADDED
|
File without changes
|