Compare commits

..

4 Commits

Author SHA1 Message Date
Chubby Granny Chaser
decacfa1d9 Merge branch 'main' into fix/using-python-http-server 2025-02-01 19:13:34 +00:00
Zamitto
7c7f621d95 Merge pull request #1432 from 7ROBE/patch-7
Update translation.json
2025-01-30 13:44:11 -03:00
7ROBE
032293b339 Update translation.json 2025-01-30 04:45:22 +03:00
Chubby Granny Chaser
954037b826 fix: using python http server 2025-01-18 01:58:42 +00:00
291 changed files with 7229 additions and 7134 deletions

View File

@@ -38,13 +38,6 @@ export default defineConfig(({ mode }) => {
build: { build: {
sourcemap: true, sourcemap: true,
}, },
css: {
preprocessorOptions: {
scss: {
api: "modern",
},
},
},
resolve: { resolve: {
alias: { alias: {
"@renderer": resolve("src/renderer/src"), "@renderer": resolve("src/renderer/src"),

View File

@@ -62,7 +62,6 @@
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"knex": "^3.1.0", "knex": "^3.1.0",
"level": "^9.0.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"parse-torrent": "^11.0.17", "parse-torrent": "^11.0.17",
"piscina": "^4.7.0", "piscina": "^4.7.0",
@@ -75,6 +74,7 @@
"sound-play": "^1.1.0", "sound-play": "^1.1.0",
"sudo-prompt": "^9.2.1", "sudo-prompt": "^9.2.1",
"tar": "^7.4.3", "tar": "^7.4.3",
"typeorm": "^0.3.20",
"user-agents": "^1.1.387", "user-agents": "^1.1.387",
"yaml": "^2.6.1", "yaml": "^2.6.1",
"yup": "^1.5.0", "yup": "^1.5.0",

View File

@@ -1,29 +1,29 @@
from flask import Flask, request, jsonify from http.server import BaseHTTPRequestHandler, HTTPServer
import sys, json, urllib.parse, psutil import json
import urllib.parse
import sys
import psutil
from torrent_downloader import TorrentDownloader from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor from profile_image_processor import ProfileImageProcessor
import libtorrent as lt import libtorrent as lt
app = Flask(__name__)
# Retrieve command line arguments # Retrieve command line arguments
torrent_port = sys.argv[1] torrent_port = sys.argv[1]
http_port = sys.argv[2] http_port = int(sys.argv[2])
rpc_password = sys.argv[3] rpc_password = sys.argv[3]
start_download_payload = sys.argv[4] start_download_payload = sys.argv[4]
start_seeding_payload = sys.argv[5] start_seeding_payload = sys.argv[5]
downloads = {} downloads = {}
# This can be streamed down from Node
downloading_game_id = -1 downloading_game_id = -1
torrent_session = lt.session({'listen_interfaces': '0.0.0.0:{port}'.format(port=torrent_port)}) torrent_session = lt.session({'listen_interfaces': f'0.0.0.0:{torrent_port}'})
if start_download_payload: if start_download_payload:
initial_download = json.loads(urllib.parse.unquote(start_download_payload)) initial_download = json.loads(urllib.parse.unquote(start_download_payload))
downloading_game_id = initial_download['game_id'] downloading_game_id = initial_download['game_id']
if initial_download['url'].startswith('magnet'): if initial_download['url'].startswith('magnet'):
torrent_downloader = TorrentDownloader(torrent_session) torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader downloads[initial_download['game_id']] = torrent_downloader
@@ -49,135 +49,142 @@ if start_seeding_payload:
except Exception as e: except Exception as e:
print("Error starting seeding", e) print("Error starting seeding", e)
def validate_rpc_password(): class RequestHandler(BaseHTTPRequestHandler):
"""Middleware to validate RPC password.""" def validate_rpc_password(self):
header_password = request.headers.get('x-hydra-rpc-password') header_password = self.headers.get('x-hydra-rpc-password')
if header_password != rpc_password: if header_password != rpc_password:
return jsonify({"error": "Unauthorized"}), 401 self.send_response(401)
self.end_headers()
self.wfile.write(json.dumps({"error": "Unauthorized"}).encode('utf-8'))
return False
return True
@app.route("/status", methods=["GET"]) def do_GET(self):
def status(): if self.path == "/status":
auth_error = validate_rpc_password() if not self.validate_rpc_password():
if auth_error: return
return auth_error
downloader = downloads.get(downloading_game_id) downloader = downloads.get(downloading_game_id)
if downloader: if downloader:
status = downloads.get(downloading_game_id).get_download_status() status = downloader.get_download_status()
return jsonify(status), 200 self.send_response(200)
else: self.end_headers()
return jsonify(None) self.wfile.write(json.dumps(status).encode('utf-8'))
@app.route("/seed-status", methods=["GET"])
def seed_status():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
seed_status = []
for game_id, downloader in downloads.items():
if not downloader:
continue
response = downloader.get_download_status()
if response is None:
continue
if response.get('status') == 5:
seed_status.append({
'gameId': game_id,
**response,
})
return jsonify(seed_status), 200
@app.route("/healthcheck", methods=["GET"])
def healthcheck():
return "", 200
@app.route("/process-list", methods=["GET"])
def process_list():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'name'])]
return jsonify(process_list), 200
@app.route("/profile-image", methods=["POST"])
def profile_image():
auth_error = validate_rpc_password()
if auth_error:
return auth_error
data = request.get_json()
image_path = data.get('image_path')
try:
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
return jsonify({'imagePath': processed_image_path, 'mimeType': mime_type}), 200
except Exception as e:
return jsonify({"error": str(e)}), 400
@app.route("/action", methods=["POST"])
def action():
global torrent_session
global downloading_game_id
auth_error = validate_rpc_password()
if auth_error:
return auth_error
data = request.get_json()
action = data.get('action')
game_id = data.get('game_id')
if action == 'start':
url = data.get('url')
existing_downloader = downloads.get(game_id)
if url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'], "")
else: else:
torrent_downloader = TorrentDownloader(torrent_session) self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(None).encode('utf-8'))
elif self.path == "/seed-status":
if not self.validate_rpc_password():
return
seed_status = []
for game_id, downloader in downloads.items():
if not downloader:
continue
response = downloader.get_download_status()
if response is None:
continue
if response.get('status') == 5:
seed_status.append({
'gameId': game_id,
**response,
})
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(seed_status).encode('utf-8'))
elif self.path == "/healthcheck":
self.send_response(200)
self.end_headers()
elif self.path == "/process-list":
if not self.validate_rpc_password():
return
process_list = [proc.info for proc in psutil.process_iter(['exe', 'pid', 'name'])]
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(process_list).encode('utf-8'))
def do_POST(self):
if not self.validate_rpc_password():
return
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
data = json.loads(post_data.decode('utf-8'))
if self.path == "/profile-image":
image_path = data.get('image_path')
try:
processed_image_path, mime_type = ProfileImageProcessor.process_image(image_path)
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps({'imagePath': processed_image_path, 'mimeType': mime_type}).encode('utf-8'))
except Exception as e:
self.send_response(400)
self.end_headers()
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
elif self.path == "/action":
global downloading_game_id
action = data.get('action')
game_id = data.get('game_id')
if action == 'start':
url = data.get('url')
existing_downloader = downloads.get(game_id)
if url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'], "")
else:
torrent_downloader = TorrentDownloader(torrent_session)
downloads[game_id] = torrent_downloader
torrent_downloader.start_download(url, data['save_path'], "")
else:
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'))
else:
http_downloader = HttpDownloader()
downloads[game_id] = http_downloader
http_downloader.start_download(url, data['save_path'], data.get('header'))
downloading_game_id = game_id
elif action == 'pause':
downloader = downloads.get(game_id)
if downloader:
downloader.pause_download()
downloading_game_id = -1
elif action == 'cancel':
downloader = downloads.get(game_id)
if downloader:
downloader.cancel_download()
elif action == 'resume_seeding':
torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode)
downloads[game_id] = torrent_downloader downloads[game_id] = torrent_downloader
torrent_downloader.start_download(url, data['save_path'], "") torrent_downloader.start_download(data['url'], data['save_path'], "")
else: elif action == 'pause_seeding':
if existing_downloader and isinstance(existing_downloader, HttpDownloader): downloader = downloads.get(game_id)
existing_downloader.start_download(url, data['save_path'], data.get('header')) if downloader:
downloader.cancel_download()
else: else:
http_downloader = HttpDownloader() self.send_response(400)
downloads[game_id] = http_downloader self.end_headers()
http_downloader.start_download(url, data['save_path'], data.get('header')) self.wfile.write(json.dumps({"error": "Invalid action"}).encode('utf-8'))
return
downloading_game_id = game_id
elif action == 'pause': self.send_response(200)
downloader = downloads.get(game_id) self.end_headers()
if downloader:
downloader.pause_download()
downloading_game_id = -1
elif action == 'cancel':
downloader = downloads.get(game_id)
if downloader:
downloader.cancel_download()
elif action == 'resume_seeding':
torrent_downloader = TorrentDownloader(torrent_session, lt.torrent_flags.upload_mode)
downloads[game_id] = torrent_downloader
torrent_downloader.start_download(data['url'], data['save_path'], "")
elif action == 'pause_seeding':
downloader = downloads.get(game_id)
if downloader:
downloader.cancel_download()
else:
return jsonify({"error": "Invalid action"}), 400
return "", 200
if __name__ == "__main__": if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(http_port)) server = HTTPServer(('0.0.0.0', http_port), RequestHandler)
print(f"Server running on port {http_port}")
server.serve_forever()

View File

@@ -4,5 +4,4 @@ cx_Logging; sys_platform == 'win32'
pywin32; sys_platform == 'win32' pywin32; sys_platform == 'win32'
psutil psutil
Pillow Pillow
flask
aria2p aria2p

View File

@@ -1,417 +1,439 @@
{ {
"language_name": "اَلْعَرَبِيَّةُ", "language_name": "العربية",
"app": { "app": {
"successfully_signed_in": "تم تسجيل الدخول بنجاح" "successfully_signed_in": "تم تسجيل الدخول بنجاح"
}, },
"home": { "home": {
"featured": ُتَمَيِّز", "featured": ميز",
"surprise_me": "فَاجِئْنِي", "surprise_me": "مفاجئني",
"no_results": َمْ يُعْثَرْ عَلَى نَتائِج", "no_results": م يتم العثور على نتائج",
"start_typing": "اِبْدَأْ بِالْكِتَابَةِ لِلْبَحْثِ...", "start_typing": "ابدأ الكتابة للبحث...",
"hot": "اَلْأَكْثَرُ شُيُوعًا الْآن", "hot": "الأكثر شيوعًا الآن",
"weekly": "📅 أَفْضَلُ أَلْعَابِ الْأُسْبُوعِ", "weekly": "📅 أفضل ألعاب الأسبوع",
"achievements": "🏆 أَلْعَابٌ لِلتَّغَلُّبِ عَلَيْهَا" "achievements": "🏆 ألعاب للتغلب عليها"
}, },
"sidebar": { "sidebar": {
"catalogue": "الْفِهْرِسُ", "catalogue": "الكـتالوج",
"downloads": "التَّنْزِيلَاتُ", "downloads": "التنزيلات",
"settings": "الإعْدَادَاتُ", "settings": "الإعدادات",
"my_library": َكْتَبَتِي", "my_library": كتبتي",
"downloading_metadata": "{{title}} (جَارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...)", "downloading_metadata": "{{title}} (جارٍ تنزيل البيانات الوصفية...)",
"paused": "{{title}} (مُوْقَفٌ)", "paused": "{{title}} (معلّق)",
"downloading": "{{title}} ({{percentage}} - جَارٍ التَّنْزِيلُ...)", "downloading": "{{title}} ({{percentage}} - جاري التنزيل...)",
"filter": َصْفِيَةُ الْمَكْتَبَةِ", "filter": صفية المكتبة",
"home": "الرَّئِيسِيَّةُ", "home": "الرئيسية",
"queued": "{{title}} (فِي الْانْتِظَارِ)", "queued": "{{title}} (في قائمة الانتظار)",
"game_has_no_executable": "اللُّعْبَةُ لَيْسَ لَدَيْهَا مِلَفٌّ تَنْفِيذِيٌّ مُحَدَّدٌ", "game_has_no_executable": "اللعبة لا تحتوي على ملف تشغيل",
"sign_in": َسْجِيلُ الدُّخُولِ", "sign_in": سجيل الدخول",
"friends": "الْأَصْدِقَاءُ", "friends": "الأصدقاء",
"need_help": "هَلْ تَحْتَاجُ إِلَى مُسَاعَدَةٍ؟" "need_help": "تحتاج مساعدة؟"
}, },
"header": { "header": {
"search": "بَحْثُ الْأَلْعَابِ", "search": "ابحث عن الألعاب",
"home": "الرَّئِيسِيَّةُ", "home": "الرئيسية",
"catalogue": "الْفِهْرِسُ", "catalogue": "الكـتالوج",
"downloads": "التَّنْزِيلَاتُ", "downloads": "التنزيلات",
"search_results": َتائِجُ الْبَحْثِ", "search_results": "نتائج البحث",
"settings": "الإعْدَادَاتُ", "settings": "الإعدادات",
"version_available_install": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِإِعَادَةِ التَّشْغِيلِ وَالتَّثْبِيتِ.", "version_available_install": "الإصدار {{version}} متوفر. انقر هنا لإعادة التشغيل والتثبيت.",
"version_available_download": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ. انْقُرْ هُنَا لِلتَّنْزِيلِ." "version_available_download": "الإصدار {{version}} متوفر. انقر هنا للتنزيل."
}, },
"bottom_panel": { "bottom_panel": {
"no_downloads_in_progress": َا تَوْجَدُ تَنْزِيلَاتٌ جَارِيَةٌ", "no_downloads_in_progress": "لا توجد تنزيلات قيد التقدم",
"downloading_metadata": َارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ لِـ {{title}}...", "downloading_metadata": "جارٍ تنزيل البيانات الوصفية لـ {{title}}...",
"downloading": َارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - الِاكْتِمَالُ {{eta}} - {{speed}}", "downloading": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - الوقت المتبقي {{eta}} - السرعة {{speed}}",
"calculating_eta": َارٍ تَنْزِيلُ {{title}}... ({{percentage}} مَكْتُومٌ) - جَارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", "calculating_eta": "جارٍ تنزيل {{title}}... ({{percentage}} اكتمال) - جاري حساب الوقت المتبقي...",
"checking_files": َارٍ التَّحَقُّقُ مِنْ مَلَفَّاتِ {{title}}... ({{percentage}} مَكْتُومٌ)" "checking_files": "جارٍ فحص ملفات {{title}}... ({{percentage}} اكتمال)"
}, },
"catalogue": { "catalogue": {
"search": َصْفِيَةٌ...", "search": صفية...",
"developers": "الْمُطَوِّرُونَ", "developers": "المطورون",
"genres": "الْأَنْوَاعُ", "genres": "الأنواع",
"tags": "الْعَلَامَاتُ", "tags": "العلامات",
"publishers": "النَّاشِرُونَ", "publishers": "الناشرون",
"download_sources": َصَادِرُ التَّنْزِيلِ", "download_sources": صادر التنزيل",
"result_count": "{{resultCount}} نَتائِجُ", "result_count": "{{resultCount}} نتيجة",
"filter_count": "{{filterCount}} مَتَوَفِّرٌ", "filter_count": "{{filterCount}} متاح",
"clear_filters": َسْحُ {{filterCount}} الْمُخْتَارَةِ" "clear_filters": سح {{filterCount}} المحددة"
}, },
"game_details": { "game_details": {
"open_download_options": َتْحُ خِيَارَاتِ التَّنْزِيلِ", "open_download_options": تح خيارات التنزيل",
"download_options_zero": َا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", "download_options_zero": "لا توجد خيارات تنزيل",
"download_options_one": "{{count}} خِيَارُ تَنْزِيلٍ", "download_options_one": "خيار تنزيل واحد",
"download_options_other": "{{count}} خِيَارَاتُ تَنْزِيلٍ", "download_options_other": "{{count}} خيارات تنزيل",
"updated_at": َمَّ التَّحْدِيثُ فِي {{updated_at}}", "updated_at": م التحديث في {{updated_at}}",
"install": َثْبِيتٌ", "install": ثبيت",
"resume": "اسْتِئْنَافٌ", "resume": "استئناف",
"pause": ِيقَافٌ", "pause": "إيقاف مؤقت",
"cancel": ِلْغَاءٌ", "cancel": لغاء",
"remove": ِزَالَةٌ", "remove": زالة",
"space_left_on_disk": "{{space}} مُتَبَقٍّ عَلَى الْقُرْصِ", "space_left_on_disk": "{{space}} متبقي على القرص",
"eta": "الِاكْتِمَالُ {{eta}}", "eta": "الانتهاء {{eta}}",
"calculating_eta": َارٍ حِسَابُ الْوَقْتِ الْمُتَبَقِّي...", "calculating_eta": "جارٍ حساب الوقت المتبقي...",
"downloading_metadata": َارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", "downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"filter": َصْفِيَةُ الْإِصْدَارَاتِ الْمُعَادِ تَغْلِيفُهَا", "filter": صفية الحزم المعاد تعبئتها",
"requirements": ُتَطَلَّبَاتُ النِّظَامِ", "requirements": تطلبات النظام",
"minimum": "الْأَدْنَى", "minimum": "الحد الأدنى",
"recommended": "الْمُوَصَّى بِهِ", "recommended": "مُوصى به",
"paused": ُوْقَفٌ", "paused": علّق",
"release_date": َمَّ الْإِصْدَارُ فِي {{date}}", "release_date": اريخ الإصدار {{date}}",
"publisher": ُشِرَ بِوَاسِطَةِ {{publisher}}", "publisher": شر بواسطة {{publisher}}",
"hours": َاعَاتٌ", "hours": "ساعات",
"minutes": َقَائِقُ", "minutes": قائق",
"amount_hours": "{{amount}} سَاعَاتٌ", "amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دَقَائِقُ", "amount_minutes": "{{amount}} دقائق",
"accuracy": ِقَّةٌ {{accuracy}}%", "accuracy": قة {{accuracy}}%",
"add_to_library": ِضَافَةٌ إِلَى الْمَكْتَبَةِ", "add_to_library": ضافة إلى المكتبة",
"remove_from_library": ِزَالَةٌ مِنَ الْمَكْتَبَةِ", "remove_from_library": زالة من المكتبة",
"no_downloads": َا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ", "no_downloads": "لا توجد تنزيلات متاحة",
"play_time": ُعِبَ لِمُدَّةِ {{amount}}", "play_time": عب لمدة {{amount}}",
"last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", "last_time_played": "آخر تشغيل {{period}}",
"not_played_yet": َمْ تَلْعَبْ {{title}} بَعْدُ", "not_played_yet": م تلعب {{title}} بعد",
"next_suggestion": "الِاقْتِرَاحُ التَّالِي", "next_suggestion": "الاقتراح التالي",
"play": "لَعِبٌ", "play": "تشغيل",
"deleting": َارٍ حَذْفُ الْمُثَبِّتِ...", "deleting": "جارٍ حذف المثبت...",
"close": ِغْلَاقٌ", "close": غلاق",
"playing_now": "جَارِي اللَّعِبُ الْآن", "playing_now": "يتم التشغيل الآن",
"change": َغْيِيرٌ", "change": غيير",
"repacks_modal_description": "اخْتَرِ الْإِصْدَارَ الْمُعَادَ تَغْلِيفُهُ الَّذِي تُرِيدُ تَنْزِيلَهُ", "repacks_modal_description": "اختر الحزمة المعاد تعبئتها التي تريد تنزيلها",
"select_folder_hint": ِتَغْيِيرِ الْمَجَلَّدِ الافْتِرَاضِيِّ، اذْهَبْ إِلَى <0>الإعْدَادَاتِ</0>", "select_folder_hint": تغيير المجلد الافتراضي، انتقل إلى <0>الإعدادات</0>",
"download_now": َنْزِيلٌ الْآن", "download_now": نزيل الآن",
"no_shop_details": "لَمْ يَتَمَكَّنْ مِنْ اسْتِرْدَادِ تَفَاصِيلِ الْمَتْجَرِ.", "no_shop_details": "تعذر الحصول على تفاصيل المتجر.",
"download_options": ِيَارَاتُ التَّنْزِيلِ", "download_options": يارات التنزيل",
"download_path": َسَارُ التَّنْزِيلِ", "download_path": سار التنزيل",
"previous_screenshot": َقْطَةُ الشَّاشَةِ السَّابِقَةُ", "previous_screenshot": قطة الشاشة السابقة",
"next_screenshot": َقْطَةُ الشَّاشَةِ التَّالِيَةُ", "next_screenshot": قطة الشاشة التالية",
"screenshot": َقْطَةُ الشَّاشَةِ {{number}}", "screenshot": قطة الشاشة {{number}}",
"open_screenshot": َتْحُ لَقْطَةِ الشَّاشَةِ {{number}}", "open_screenshot": تح لقطة الشاشة {{number}}",
"download_settings": "إعْدَادَاتُ التَّنْزِيلِ", "download_settings": "إعدادات التنزيل",
"downloader": "الْمُنَزِّلُ", "downloader": "أداة التنزيل",
"select_executable": َحْدِيدٌ", "select_executable": حديد",
"no_executable_selected": َمْ يُحَدَّدْ مِلَفٌّ تَنْفِيذِيٌّ", "no_executable_selected": م يتم تحديد ملف تشغيل",
"open_folder": َتْحُ الْمَجَلَّدِ", "open_folder": تح المجلد",
"open_download_location": "مُشَاهَدَةُ الْمَلَفَّاتِ الْمُنَزَّلَةِ", "open_download_location": "عرض الملفات المحملة",
"create_shortcut": ِنْشَاءُ طَرِيقٍ مُخْتَصَرٍ عَلَى سَطْحِ الْمَكْتَبِ", "create_shortcut": نشاء اختصار على سطح المكتب",
"clear": َسْحٌ", "clear": سح",
"remove_files": ِزَالَةُ الْمَلَفَّاتِ", "remove_files": زالة الملفات",
"remove_from_library_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "remove_from_library_title": "هل أنت متأكد؟",
"remove_from_library_description": َيُؤَدِّي هَذَا إِلَى إِزَالَةِ {{game}} مِنْ مَكْتَبَتِكَ", "remove_from_library_description": يؤدي هذا إلى إزالة {{game}} من مكتبتك",
"options": ِيَارَاتٌ", "options": يارات",
"executable_section_title": "الْمِلَفُّ التَّنْفِيذِيُّ", "executable_section_title": "ملف التشغيل",
"executable_section_description": َسَارُ الْمِلَفِّ الَّذِي سَيَتِمُّ تَنْفِيذُهُ عِنْدَ النَّقْرِ عَلَى \"لَعِبٌ\"", "executable_section_description": سار الملف الذي سيتم تشغيله عند النقر على \"تشغيل\"",
"downloads_secion_title": "التَّنْزِيلَاتُ", "downloads_secion_title": "التنزيلات",
"downloads_section_description": َحَقَّقْ مِنَ التَّحْدِيثَاتِ أَوِ الْإِصْدَارَاتِ الْأُخْرَى لِهَذِهِ اللُّعْبَةِ", "downloads_section_description": حقق من التحديثات أو الإصدارات الأخرى لهذه اللعبة",
"danger_zone_section_title": ِنْطَقَةُ الْخَطَرِ", "danger_zone_section_title": نطقة الخطر",
"danger_zone_section_description": ِزَالَةُ هَذِهِ اللُّعْبَةِ مِنْ مَكْتَبَتِكَ أَوِ الْمَلَفَّاتِ الْمُنَزَّلَةِ بِوَاسِطَةِ Hydra", "danger_zone_section_description": زالة هذه اللعبة من مكتبتك أو الملفات التي تم تنزيلها بواسطة Hydra",
"download_in_progress": "جَارٍ التَّنْزِيلُ", "download_in_progress": "تنزيل قيد التقدم",
"download_paused": "التَّنْزِيلُ مُوْقَفٌ", "download_paused": "التنزيل معلق",
"last_downloaded_option": ِيَارُ التَّنْزِيلِ الْأَخِيرُ", "last_downloaded_option": يار التنزيل الأخير",
"create_shortcut_success": َمَّ إِنْشَاءُ الطَّرِيقِ الْمُخْتَصَرِ بِنَجَاحٍ", "create_shortcut_success": م إنشاء الاختصار بنجاح",
"create_shortcut_error": َطَأٌ فِي إِنْشَاءِ الطَّرِيقِ الْمُخْتَصَرِ", "create_shortcut_error": طأ في إنشاء الاختصار",
"nsfw_content_title": "هَذِهِ اللُّعْبَةُ تَحْتَوِي عَلَى مُحْتَوًى غَيْرِ لَائِقٍ", "nsfw_content_title": "هذه اللعبة تحتوي على محتوى غير لائق",
"nsfw_content_description": "{{title}} تَحْتَوِي عَلَى مُحْتَوًى قَدْ لَا يَكُونُ مُنَاسِبًا لِجَمِيعِ الْأَعْمَارِ. هَلْ أَنْتَ مُتَأَكِّدٌ مِنْ أَنَّكَ تُرِيدُ الْمُتَابَعَةَ؟", "nsfw_content_description": "{{title}} يحتوي على محتوى قد لا يناسب جميع الأعمار. هل تريد المتابعة؟",
"allow_nsfw_content": "الْمُتَابَعَةُ", "allow_nsfw_content": "متابعة",
"refuse_nsfw_content": "الرُّجُوعُ", "refuse_nsfw_content": "رجوع",
"stats": "الإحْصَائِيَّاتُ", "stats": "الإحصائيات",
"download_count": "التَّنْزِيلَاتُ", "download_count": "مرات التنزيل",
"player_count": "اللَّاعِبُونَ النَّشِطُونَ", "player_count": "اللاعبون النشطون",
"download_error": "هَذَا خِيَارُ التَّنْزِيلِ غَيْرُ مَتَوَفِّرٍ", "download_error": "خيار التنزيل هذا غير متاح",
"download": َنْزِيلٌ", "download": نزيل",
"executable_path_in_use": "الْمِلَفُّ التَّنْفِيذِيُّ مُسْتَخْدَمٌ بِوَاسِطَةِ \"{{game}}\"", "executable_path_in_use": "مسار التشغيل مستخدم بالفعل بواسطة \"{{game}}\"",
"warning": َنْبِيهٌ:", "warning": حذير:",
"hydra_needs_to_remain_open": ِهَذَا التَّنْزِيلِ، يَجِبُ أَنْ يَبْقَى Hydra مَفْتُوحًا حَتَّى يَتِمَّ الِاكْتِمَالُ. إِذَا أُغْلِقَ Hydra قَبْلَ الِاكْتِمَالِ، سَتَفْقِدُ تَقَدُّمَكَ.", "hydra_needs_to_remain_open": هذا التنزيل، يجب أن يبقى Hydra مفتوحًا حتى اكتماله. إذا أغلق Hydra قبل الاكتمال، ستفقد تقدمك.",
"achievements": "الإِنْجَازَاتُ", "achievements": "الإنجازات",
"achievements_count": "الإِنْجَازَاتُ {{unlockedCount}}/{{achievementsCount}}", "achievements_count": "الإنجازات {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": ِفْظٌ سَحَابِيٌّ", "cloud_save": فظ سحابي",
"cloud_save_description": "احْفَظْ تَقَدُّمَكَ فِي السَّحَابَةِ وَاسْتَمِرَّ فِي اللَّعِبِ عَلَى أَيِّ جِهَازٍ", "cloud_save_description": "احفظ تقدمك على السحابة واستمر في اللعب من أي جهاز",
"backups": "الْنُسَخُ الِاحْتِيَاطِيَّةُ", "backups": "النسخ الاحتياطية",
"install_backup": َثْبِيتٌ", "install_backup": ثبيت",
"delete_backup": َذْفٌ", "delete_backup": ذف",
"create_backup": ُسْخَةٌ احْتِيَاطِيَّةٌ جَدِيدَةٌ", "create_backup": سخة احتياطية جديدة",
"last_backup_date": "آخِرُ نُسْخَةٍ احْتِيَاطِيَّةٍ فِي {{date}}", "last_backup_date": "آخر نسخة احتياطية في {{date}}",
"no_backup_preview": َمْ يُعْثَرْ عَلَى أَيِّ أَلْعَابٍ مَحْفُوظَةٍ لِهَذَا الْعُنْوَانِ", "no_backup_preview": م يتم العثور على حفظات لهذا العنوان",
"restoring_backup": َارٍ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ ({{progress}} مَكْتُومٌ)...", "restoring_backup": "جارٍ استعادة النسخة الاحتياطية ({{progress}} اكتمال)...",
"uploading_backup": َارٍ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ...", "uploading_backup": "جارٍ رفع النسخة الاحتياطية...",
"no_backups": َمْ تَقُمْ بِإِنْشَاءِ أَيِّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ بَعْدُ", "no_backups": م تقم بإنشاء أي نسخ احتياطية لهذه اللعبة بعد",
"backup_uploaded": َمَّ رَفْعُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_uploaded": م رفع النسخة الاحتياطية",
"backup_deleted": َمَّ حَذْفُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_deleted": م حذف النسخة الاحتياطية",
"backup_restored": َمَّ اسْتِعَادَةُ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_restored": م استعادة النسخة الاحتياطية",
"see_all_achievements": َرْضُ جَمِيعِ الإِنْجَازَاتِ", "see_all_achievements": رض جميع الإنجازات",
"sign_in_to_see_achievements": َجِّلِ الدُّخُولَ لِعَرْضِ الإِنْجَازَاتِ", "sign_in_to_see_achievements": جل الدخول لعرض الإنجازات",
"mapping_method_automatic": "آلِيٌّ", "mapping_method_automatic": "تلقائي",
"mapping_method_manual": َدَوِيٌّ", "mapping_method_manual": دوي",
"mapping_method_label": َرِيقَةُ التَّحْدِيدِ", "mapping_method_label": ريقة التعيين",
"files_automatically_mapped": َمَّ تَحْدِيدُ الْمَلَفَّاتِ تِلْقَائِيًّا", "files_automatically_mapped": م تعيين الملفات تلقائيًا",
"no_backups_created": َمْ تُنْشَأْ أَيُّ نُسَخٍ احْتِيَاطِيَّةٍ لِهَذِهِ اللُّعْبَةِ", "no_backups_created": م يتم إنشاء نسخ احتياطية لهذه اللعبة",
"manage_files": ِدَارَةُ الْمَلَفَّاتِ", "manage_files": دارة الملفات",
"loading_save_preview": َارٍ الْبَحْثُ عَنْ أَلْعَابٍ مَحْفُوظَةٍ...", "loading_save_preview": "جارٍ البحث عن حفظات الألعاب...",
"wine_prefix": َادِئَةُ Wine", "wine_prefix": "بادئة Wine",
"wine_prefix_description": َادِئَةُ Wine الْمُسْتَخْدَمَةُ لِتَشْغِيلِ هَذِهِ اللُّعْبَةِ", "wine_prefix_description": "بادئة Wine المستخدمة لتشغيل هذه اللعبة",
"launch_options": ِيَارَاتُ الْإِطْلَاقِ", "launch_options": يارات التشغيل",
"launch_options_description": ُمْكِنُ لِلْمُسْتَخْدِمِينَ الْمُتَقَدِّمِينَ إِدْخَالُ تَعْدِيلَاتٍ عَلَى خِيَارَاتِ الْإِطْلَاقِ", "launch_options_description": مكن للمستخدمين المتقدمين إدخال تعديلات على خيارات التشغيل (ميزة تجريبية)",
"launch_options_placeholder": َمْ يُحَدَّدْ أَيُّ مُعَامِلٍ", "launch_options_placeholder": م يتم تحديد أي معاملات",
"no_download_option_info": َا تَوْجَدُ مَعْلُومَاتٌ مَتَوَفِّرَةٌ", "no_download_option_info": "لا توجد معلومات متاحة",
"backup_deletion_failed": َشَلَ فِي حَذْفِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ", "backup_deletion_failed": شل حذف النسخة الاحتياطية",
"max_number_of_artifacts_reached": َمَّ بَلُوغُ الْعَدَدِ الْأَقْصَى لِلنُّسَخِ الِاحْتِيَاطِيَّةِ لِهَذِهِ اللُّعْبَةِ", "max_number_of_artifacts_reached": م الوصول إلى الحد الأقصى لعدد النسخ الاحتياطية لهذه اللعبة",
"achievements_not_sync": َعَرَّفْ عَلَى كَيْفِيَّةِ مَزْجِ إِنْجَازَاتِكَ", "achievements_not_sync": عرف على كيفية مزامنة إنجازاتك",
"manage_files_description": ِدَارَةُ الْمَلَفَّاتِ الَّتِي سَيَتِمُّ نَسْخُهَا احْتِيَاطِيًّا وَاسْتِعَادَتُهَا", "manage_files_description": دارة الملفات التي سيتم نسخها احتياطيًا واستعادتها",
"select_folder": "تَحْدِيدُ الْمَجَلَّدِ", "select_folder": "حدد المجلد",
"backup_from": ُسْخَةٌ احْتِيَاطِيَّةٌ مِنْ {{date}}", "backup_from": سخة احتياطية من {{date}}",
"custom_backup_location_set": َمَّ تَحْدِيدُ مَوْقِعِ النُّسْخَةِ الِاحْتِيَاطِيَّةِ الْمُخَصَّصِ", "custom_backup_location_set": م تعيين موقع نسخ احتياطي مخصص",
"no_directory_selected": َمْ يُحَدَّدْ أَيُّ دَلِيلٍ" "no_directory_selected": م يتم تحديد مجلد",
"no_write_permission": "لا يمكن التنزيل إلى هذا المجلد. انقر هنا لمعرفة المزيد.",
"reset_achievements": "إعادة تعيين الإنجازات",
"reset_achievements_description": "سيؤدي هذا إلى إعادة تعيين جميع إنجازات {{game}}",
"reset_achievements_title": "هل أنت متأكد؟",
"reset_achievements_success": "تم إعادة تعيين الإنجازات بنجاح",
"reset_achievements_error": "فشل إعادة تعيين الإنجازات"
}, },
"activation": { "activation": {
"title": َفْعِيلُ Hydra", "title": فعيل Hydra",
"installation_id": ُعَرِّفُ التَّثْبِيتِ:", "installation_id": عرف التثبيت:",
"enter_activation_code": َدْخِلْ رَمْزَ التَّفْعِيلِ", "enter_activation_code": دخل رمز التفعيل الخاص بك",
"message": ِذَا كُنْتَ لَا تَعْرِفُ أَيْنَ تَطْلُبُ هَذَا، فَلا يَجِبُ أَنْ تَكُونَ لَدَيْكَ.", "message": ذا كنت لا تعرف أين تطلب هذا، فلا يجب أن يكون لديك هذا.",
"activate": َفْعِيلٌ", "activate": فعيل",
"loading": َارٍ التَّحْمِيلُ..." "loading": "جارٍ التحميل..."
}, },
"downloads": { "downloads": {
"resume": "اسْتِئْنَافٌ", "resume": "استئناف",
"pause": ِيقَافٌ", "pause": "إيقاف مؤقت",
"eta": "الِاكْتِمَالُ {{eta}}", "eta": "الانتهاء {{eta}}",
"paused": ُوْقَفٌ", "paused": علّق",
"verifying": َارٍ التَّحَقُّقُ...", "verifying": "جارٍ التحقق...",
"completed": َكْتُومٌ", "completed": كتمل",
"removed": "لَمْ يُنَزَّلْ", "removed": "غير محمل",
"cancel": ِلْغَاءٌ", "cancel": لغاء",
"filter": َصْفِيَةُ الْأَلْعَابِ الْمُنَزَّلَةِ", "filter": صفية الألعاب المحملة",
"remove": ِزَالَةٌ", "remove": زالة",
"downloading_metadata": َارٍ تَنْزِيلُ الْبَيَانَاتِ الْوَصْفِيَّةِ...", "downloading_metadata": "جارٍ تنزيل البيانات الوصفية...",
"deleting": َارٍ حَذْفُ الْمُثَبِّتِ...", "deleting": "جارٍ حذف المثبت...",
"delete": "حَذْفُ الْمُثَبِّتِ", "delete": "إزالة المثبت",
"delete_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "delete_modal_title": "هل أنت متأكد؟",
"delete_modal_description": َيُؤَدِّي هَذَا إِلَى إِزَالَةِ جَمِيعِ مَلَفَّاتِ التَّثْبِيتِ مِنْ حَاسُوبِكَ", "delete_modal_description": يؤدي هذا إلى إزالة جميع ملفات التثبيت من جهازك",
"install": َثْبِيتٌ", "install": ثبيت",
"download_in_progress": "جَارٍ التَّنْفِيذُ", "download_in_progress": "قيد التقدم",
"queued_downloads": "التَّنْزِيلَاتُ فِي الْانْتِظَارِ", "queued_downloads": "التنزيلات في قائمة الانتظار",
"downloads_completed": َكْتُومٌ", "downloads_completed": كتمل",
"queued": ِي الْانْتِظَارِ", "queued": "في قائمة الانتظار",
"no_downloads_title": َرَاغٌ تَامٌ", "no_downloads_title": ارغ جدًا",
"no_downloads_description": َمْ تَقُمْ بِتَنْزِيلِ أَيِّ شَيْءٍ بِاسْتِخْدَامِ Hydra بَعْدُ، لَكِنَّهُ لَيْسَ مُتَأَخِّرًا لِلْبَدْءِ.", "no_downloads_description": م تقم بتنزيل أي شيء باستخدام Hydra بعد، ولكن لم يفت الأوان للبدء.",
"checking_files": َارٍ التَّحَقُّقُ مِنَ الْمَلَفَّاتِ...", "checking_files": "جارٍ فحص الملفات...",
"seeding": "الْبَذْرُ", "seeding": "التوزيع",
"stop_seeding": ِيقَافُ الْبَذْرِ", "stop_seeding": "إيقاف التوزيع",
"resume_seeding": "اسْتِئْنَافُ الْبَذْرِ", "resume_seeding": "استئناف التوزيع",
"options": ِدَارَةٌ" "options": دارة"
}, },
"settings": { "settings": {
"downloads_path": َسَارُ التَّنْزِيلَاتِ", "downloads_path": سار التنزيلات",
"change": َحْدِيثٌ", "change": حديث",
"notifications": "الإِشْعَارَاتُ", "notifications": "الإشعارات",
"enable_download_notifications": ِنْدَ اكْتِمَالِ التَّنْزِيلِ", "enable_download_notifications": ند اكتمال التنزيل",
"enable_repack_list_notifications": ِنْدَ إِضَافَةِ إِصْدَارٍ مُعَادٍ تَغْلِيفِهِ جَدِيدٍ", "enable_repack_list_notifications": ند إضافة حزمة معاد تعبئتها جديدة",
"real_debrid_api_token_label": َمْزُ واجهة برمجة التطبيقات Real-Debrid", "real_debrid_api_token_label": مز واجهة برمجة تطبيقات Real-Debrid",
"quit_app_instead_hiding": "لا تُخْفِ Hydra عِنْدَ الإِغْلَاقِ", "quit_app_instead_hiding": "لا تخفي Hydra عند الإغلاق",
"launch_with_system": َشْغِيلُ Hydra عِنْدَ بَدْءِ النِّظَامِ", "launch_with_system": شغيل Hydra مع بدء النظام",
"general": َامٌ", "general": "عام",
"behavior": "سُلُوكٌ", "behavior": "السلوك",
"download_sources": َصَادِرُ التَّنْزِيلِ", "download_sources": صادر التنزيل",
"language": "اللُّغَةُ", "language": "اللغة",
"real_debrid_api_token": َمْزُ واجهة برمجة التطبيقات", "real_debrid_api_token": مز API",
"enable_real_debrid": َمْكِينُ Real-Debrid", "enable_real_debrid": فعيل Real-Debrid",
"real_debrid_description": "Real-Debrid هُوَ مُنَزِّلٌ غَيْرُ مَقْيُودٍ يَتِيحُ لَكَ تَنْزِيلَ الْمَلَفَّاتِ بِسُرْعَةٍ، مَحْدُودٌ فَقَطْ بِسُرْعَةِ الْإِنْتَرْنِتِ لَدَيْكَ.", "real_debrid_description": "Real-Debrid هو أداة تنزيل غير مقيدة تتيح لك تنزيل الملفات بسرعة، مقيدة فقط بسرعة الإنترنت لديك.",
"real_debrid_invalid_token": َمْزُ واجهة برمجة التطبيقات غَيْرُ صَالِحٍ", "real_debrid_invalid_token": مز API غير صالح",
"real_debrid_api_token_hint": ُمْكِنُكَ الْحُصُولُ عَلَى رَمْزِ واجهة برمجة التطبيقات <0>هُنَا</0>", "real_debrid_api_token_hint": مكنك الحصول على رمز API الخاص بك <0>هنا</0>",
"real_debrid_free_account_error": "الْحِسَابُ \"{{username}}\" هُوَ حِسَابٌ مَجَّانِيٌّ. يَرْجَى الِاشْتِرَاكُ فِي Real-Debrid", "real_debrid_free_account_error": "الحساب \"{{username}}\" هو حساب مجاني. يرجى الاشتراك في Real-Debrid",
"real_debrid_linked_message": َمَّ رَبْطُ الْحِسَابِ \"{{username}}\"", "real_debrid_linked_message": م ربط الحساب \"{{username}}\"",
"save_changes": ِفْظُ التَّغْيِيرَاتِ", "save_changes": فظ التغييرات",
"changes_saved": َمَّ حِفْظُ التَّغْيِيرَاتِ بِنَجَاحٍ", "changes_saved": م حفظ التغييرات بنجاح",
"download_sources_description": َتَقُومُ Hydra بِجَلْبِ رَوَابِطِ التَّنْزِيلِ مِنْ هَذِهِ الْمَصَادِرِ. يَجِبُ أَنْ يَكُونَ عُنْوَانُ URL لِلْمَصْدَرِ رَابِطًا مُبَاشِرًا إِلَى مِلَفٍّ .json يَحْتَوِي عَلَى رَوَابِطِ التَّنْزِيلِ.", "download_sources_description": يقوم Hydra بجلب روابط التنزيل من هذه المصادر. يجب أن يكون عنوان URL المصدر رابطًا مباشرًا لملف .json يحتوي على روابط التنزيل.",
"validate_download_source": َصْدِيقٌ", "validate_download_source": حقق",
"remove_download_source": ِزَالَةٌ", "remove_download_source": زالة",
"add_download_source": ِضَافَةُ مَصْدَرٍ", "add_download_source": ضافة مصدر",
"download_count_zero": َا تَوْجَدُ خِيَارَاتُ تَنْزِيلٍ", "download_count_zero": "لا توجد خيارات تنزيل",
"download_count_one": "{{countFormatted}} خِيَارُ تَنْزِيلٍ", "download_count_one": "{{countFormatted}} خيار تنزيل",
"download_count_other": "{{countFormatted}} خِيَارَاتُ تَنْزِيلٍ", "download_count_other": "{{countFormatted}} خيارات تنزيل",
"download_source_url": ُنْوَانُ مَصْدَرِ التَّنْزِيلِ", "download_source_url": نوان URL لمصدر التنزيل",
"add_download_source_description": َدْخِلْ عُنْوَانَ URL لِمِلَفٍّ .json", "add_download_source_description": دخل عنوان URL لملف .json",
"download_source_up_to_date": ُحَدَّثٌ", "download_source_up_to_date": حدث",
"download_source_errored": َطَأٌ", "download_source_errored": طأ",
"sync_download_sources": َزْجُ الْمَصَادِرِ", "sync_download_sources": زامنة المصادر",
"removed_download_source": َمَّ إِزَالَةُ مَصْدَرِ التَّنْزِيلِ", "removed_download_source": مت إزالة مصدر التنزيل",
"added_download_source": َمَّتْ إِضَافَةُ مَصْدَرِ التَّنْزِيلِ", "added_download_source": مت إضافة مصدر التنزيل",
"download_sources_synced": َمَّ مَزْجُ جَمِيعِ مَصَادِرِ التَّنْزِيلِ", "download_sources_synced": مت مزامنة جميع مصادر التنزيل",
"insert_valid_json_url": َدْخِلْ عُنْوَانَ JSON صَالِحًا", "insert_valid_json_url": دخل عنوان JSON صالح",
"found_download_option_zero": َمْ يُعْثَرْ عَلَى خِيَارِ تَنْزِيلٍ", "found_download_option_zero": م يتم العثور على خيارات تنزيل",
"found_download_option_one": "عُثِرَ عَلَى {{countFormatted}} خِيَارِ تَنْزِيلٍ", "found_download_option_one": "تم العثور على {{countFormatted}} خيار تنزيل",
"found_download_option_other": "عُثِرَ عَلَى {{countFormatted}} خِيَارَاتِ تَنْزِيلٍ", "found_download_option_other": "تم العثور على {{countFormatted}} خيارات تنزيل",
"import": "اسْتِيرَادٌ", "import": "استيراد",
"public": َامٌ", "public": "عام",
"private": َاصٌ", "private": "خاص",
"friends_only": "الْأَصْدِقَاءُ فَقَطْ", "friends_only": "الأصدقاء فقط",
"privacy": "الْخُصُوصِيَّةُ", "privacy": "الخصوصية",
"profile_visibility": ُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", "profile_visibility": ؤية الملف الشخصي",
"profile_visibility_description": "اخْتَرْ مَنْ يُمْكِنُهُ رُؤْيَةُ مَلَفِّكَ الشَّخْصِيِّ وَمَكْتَبَتِكَ", "profile_visibility_description": "اختر من يمكنه رؤية ملفك الشخصي ومكتبتك",
"required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", "required_field": "هذا الحقل مطلوب",
"source_already_exists": َمَّتْ إِضَافَةُ هَذَا الْمَصْدَرِ مِنْ قَبْلُ", "source_already_exists": مت إضافة هذا المصدر مسبقًا",
"must_be_valid_url": َجِبُ أَنْ يَكُونَ الْمَصْدَرُ عُنْوَانَ URL صَالِحًا", "must_be_valid_url": جب أن يكون المصدر عنوان URL صالحًا",
"blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", "blocked_users": "المستخدمون المحظورون",
"user_unblocked": َمَّ إِزَالَةُ حَظْرِ الْمُسْتَخْدِمِ", "user_unblocked": م إلغاء حظر المستخدم",
"enable_achievement_notifications": ِنْدَ فَتْحِ إِنْجَازٍ", "enable_achievement_notifications": ند فتح إنجاز",
"launch_minimized": َشْغِيلُ Hydra مُصَغَّرًا", "launch_minimized": شغيل Hydra مصغرًا",
"disable_nsfw_alert": َعْطِيلُ تَنْبِيهِ الْمُحْتَوَى غَيْرِ اللَّائِقِ", "disable_nsfw_alert": عطيل تنبيه المحتوى غير اللائق",
"seed_after_download_complete": "الْبَذْرُ بَعْدَ اكْتِمَالِ التَّنْزِيلِ", "seed_after_download_complete": "التوزيع بعد اكتمال التنزيل",
"show_hidden_achievement_description": "إِظْهَارُ وَصْفِ الإِنْجَازَاتِ الْمَخْفِيَّةِ قَبْلَ فَتْحِهَا" "show_hidden_achievement_description": "عرض وصف الإنجازات المخفية قبل فتحها",
"account": "الحساب",
"no_users_blocked": "لا يوجد مستخدمون محظورون",
"subscription_active_until": "اشتراك Hydra Cloud نشط حتى {{date}}",
"manage_subscription": "إدارة الاشتراك",
"update_email": "تحديث البريد الإلكتروني",
"update_password": "تحديث كلمة المرور",
"current_email": "البريد الإلكتروني الحالي:",
"no_email_account": "لم تقم بتعيين بريد إلكتروني بعد",
"account_data_updated_successfully": "تم تحديث بيانات الحساب بنجاح",
"renew_subscription": "تجديد اشتراك Hydra Cloud",
"subscription_expired_at": "انتهى اشتراكك في {{date}}",
"no_subscription": "استمتع بـ Hydra بأفضل طريقة ممكنة",
"become_subscriber": "كن مشتركًا في Hydra Cloud",
"subscription_renew_cancelled": "تم تعطيل التجديد التلقائي",
"subscription_renews_on": "سيتم تجديد اشتراكك في {{date}}",
"bill_sent_until": "سيتم إرسال فاتورتك التالية حتى هذا اليوم"
}, },
"notifications": { "notifications": {
"download_complete": "اكْتِمَالُ التَّنْزِيلِ", "download_complete": "اكتمل التنزيل",
"game_ready_to_install": "{{title}} جَاهِزٌ لِلتَّثْبِيتِ", "game_ready_to_install": "{{title}} جاهز للتثبيت",
"repack_list_updated": َمَّ تَحْدِيثُ قَائِمَةِ الإِصْدَارَاتِ الْمُعَادَةِ تَغْلِيفُهَا", "repack_list_updated": م تحديث قائمة الحزم المعاد تعبئتها",
"repack_count_one": "{{count}} إِصْدَارٌ مُعَادٌ تَغْلِيفُهُ أُضِيفَ", "repack_count_one": "تمت إضافة {{count}} حزمة معاد تعبئتها",
"repack_count_other": "{{count}} إِصْدَارَاتٌ مُعَادَةٌ تَغْلِيفُهَا أُضِيفَتْ", "repack_count_other": "تمت إضافة {{count}} حزم معاد تعبئتها",
"new_update_available": "الْإِصْدَارُ {{version}} مَتَوَفِّرٌ", "new_update_available": "الإصدار {{version}} متوفر",
"restart_to_install_update": َعِدْ تَشْغِيلَ Hydra لِتَثْبِيتِ التَّحْدِيثِ", "restart_to_install_update": عد تشغيل Hydra لتثبيت التحديث",
"notification_achievement_unlocked_title": َمَّ فَتْحُ إِنْجَازٍ لِـ {{game}}", "notification_achievement_unlocked_title": م فتح إنجاز لـ {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} وَ{{count}} أُخْرَى تَمَّ فَتْحُهَا" "notification_achievement_unlocked_body": "{{achievement}} و {{count}} آخرين تم فتحهم"
}, },
"system_tray": { "system_tray": {
"open": َتْحُ Hydra", "open": تح Hydra",
"quit": "الْخُرُوجُ" "quit": "خروج"
}, },
"game_card": { "game_card": {
"no_downloads": َا تَوْجَدُ تَنْزِيلَاتٌ مَتَوَفِّرَةٌ" "no_downloads": "لا توجد تنزيلات متاحة"
}, },
"binary_not_found_modal": { "binary_not_found_modal": {
"title": "الْبَرَامِجُ غَيْرُ مُثَبَّتَةٍ", "title": "البرامج غير مثبتة",
"description": َمْ يُعْثَرْ عَلَى مَلَفَّاتٍ تَنْفِيذِيَّةٍ لِـ Wine أَوْ Lutris عَلَى نِظَامِكَ", "description": م يتم العثور على ملفات تشغيل Wine أو Lutris على نظامك",
"instructions": َحَقَّقْ مِنَ الطَّرِيقَةِ الصَّحِيحَةِ لِتَثْبِيتِ أَيٍّ مِنْهُمَا عَلَى تَوْزِيعَةِ Linux لَدَيْكَ لِتَعْمَلَ اللُّعْبَةُ بِشَكْلٍ طَبِيعِيٍّ" "instructions": حقق من الطريقة الصحيحة لتثبيت أي منها على توزيعة لينكس الخاصة بك حتى تعمل اللعبة بشكل طبيعي"
}, },
"modal": { "modal": {
"close": ِرُّ الإِغْلَاقِ" "close": ر الإغلاق"
}, },
"forms": { "forms": {
"toggle_password_visibility": َبْدِيلُ رُؤْيَةِ كَلِمَةِ الْمَرُورِ" "toggle_password_visibility": بديل رؤية كلمة المرور"
}, },
"user_profile": { "user_profile": {
"amount_hours": "{{amount}} سَاعَاتٌ", "amount_hours": "{{amount}} ساعات",
"amount_minutes": "{{amount}} دَقَائِقُ", "amount_minutes": "{{amount}} دقائق",
"last_time_played": "آخِرُ مَرَّةٍ لُعِبَتْ {{period}}", "last_time_played": "آخر تشغيل {{period}}",
"activity": "النَّشَاطُ الْأَخِيرُ", "activity": "النشاط الأخير",
"library": "الْمَكْتَبَةُ", "library": "المكتبة",
"total_play_time": ِجْمَالِيُّ وَقْتِ اللَّعِبِ", "total_play_time": جمالي وقت اللعب",
"no_recent_activity_title": "هَمَمْ... لَا شَيْءَ هُنَا", "no_recent_activity_title": "همم... لا شيء هنا",
"no_recent_activity_description": َمْ تَلْعَبْ أَيَّ أَلْعَابٍ مُؤَخَّرًا. حَانَ الْوَقْتُ لِتَغْيِيرِ ذَلِكَ!", "no_recent_activity_description": م تلعب أي ألعاب مؤخرًا. حان الوقت لتغيير ذلك!",
"display_name": "اسْمُ الْعَرْضِ", "display_name": "اسم العرض",
"saving": َارٍ الْحِفْظُ", "saving": "جارٍ الحفظ",
"save": ِفْظٌ", "save": فظ",
"edit_profile": َحْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", "edit_profile": عديل الملف الشخصي",
"saved_successfully": َمَّ الْحِفْظُ بِنَجَاحٍ", "saved_successfully": م الحفظ بنجاح",
"try_again": "الرَّجَاءُ الْمُحَاوَلَةُ مَرَّةً أُخْرَى", "try_again": "يرجى المحاولة مرة أخرى",
"sign_out_modal_title": "هَلْ أَنْتَ مُتَأَكِّدٌ؟", "sign_out_modal_title": "هل أنت متأكد؟",
"cancel": ِلْغَاءٌ", "cancel": لغاء",
"successfully_signed_out": َمَّ تَسْجِيلُ الْخُرُوجِ بِنَجَاحٍ", "successfully_signed_out": م تسجيل الخروج بنجاح",
"sign_out": َسْجِيلُ الْخُرُوجِ", "sign_out": سجيل الخروج",
"playing_for": "جَارِي اللَّعِبُ لِمُدَّةِ {{amount}}", "playing_for": "يلعب لمدة {{amount}}",
"sign_out_modal_text": َكْتَبَتُكَ مُرْتَبِطَةٌ بِحِسَابِكَ الْحَالِيِّ. عِنْدَ تَسْجِيلِ الْخُرُوجِ، لَنْ تَكُونَ مَكْتَبَتُكَ مَرْئِيَّةً بَعْدَ الْآنِ، وَلَنْ يَتِمَّ حِفْظُ أَيِّ تَقَدُّمٍ. هَلْ تُرِيدُ الْمُتَابَعَةَ مَعَ تَسْجِيلِ الْخُرُوجِ؟", "sign_out_modal_text": كتبتك مرتبطة بحسابك الحالي. عند تسجيل الخروج، لن تكون مكتبتك مرئية بعد الآن، ولن يتم حفظ أي تقدم. هل تتابع تسجيل الخروج؟",
"add_friends": ِضَافَةُ الْأَصْدِقَاءِ", "add_friends": ضافة أصدقاء",
"add": ِضَافَةٌ", "add": ضافة",
"friend_code": َمْزُ الصَّدِيقِ", "friend_code": مز الصديق",
"see_profile": "رُؤْيَةُ الْمَلَفِّ الشَّخْصِيِّ", "see_profile": "عرض الملف الشخصي",
"sending": َارٍ الْإِرْسَالُ", "sending": "جارٍ الإرسال",
"friend_request_sent": َمَّ إِرْسَالُ طَلَبِ الصَّدَاقَةِ", "friend_request_sent": م إرسال طلب الصداقة",
"friends": "الْأَصْدِقَاءُ", "friends": "الأصدقاء",
"friends_list": َائِمَةُ الْأَصْدِقَاءِ", "friends_list": "قائمة الأصدقاء",
"user_not_found": "الْمُسْتَخْدِمُ غَيْرُ مَوْجُودٍ", "user_not_found": "المستخدم غير موجود",
"block_user": َظْرُ الْمُسْتَخْدِمِ", "block_user": ظر المستخدم",
"add_friend": ِضَافَةُ صَدِيقٍ", "add_friend": ضافة صديق",
"request_sent": َمَّ إِرْسَالُ الطَّلَبِ", "request_sent": م إرسال الطلب",
"request_received": َمَّ اسْتِقْبَالُ الطَّلَبِ", "request_received": م استلام الطلب",
"accept_request": َبُولُ الطَّلَبِ", "accept_request": بول الطلب",
"ignore_request": َجَاهُلُ الطَّلَبِ", "ignore_request": جاهل الطلب",
"cancel_request": ِلْغَاءُ الطَّلَبِ", "cancel_request": لغاء الطلب",
"undo_friendship": ِلْغَاءُ الصَّدَاقَةِ", "undo_friendship": لغاء الصداقة",
"request_accepted": َمَّ قَبُولُ الطَّلَبِ", "request_accepted": م قبول الطلب",
"user_blocked_successfully": َمَّ حَظْرُ الْمُسْتَخْدِمِ بِنَجَاحٍ", "user_blocked_successfully": م حظر المستخدم بنجاح",
"user_block_modal_text": َيُؤَدِّي هَذَا إِلَى حَظْرِ {{displayName}}", "user_block_modal_text": يؤدي هذا إلى حظر {{displayName}}",
"blocked_users": "الْمُسْتَخْدِمُونَ الْمَحْظُورُونَ", "blocked_users": "المستخدمون المحظورون",
"unblock": ِزَالَةُ الْحَظْرِ", "unblock": لغاء الحظر",
"no_friends_added": َيْسَ لَدَيْكَ أَصْدِقَاءٌ مُضَافُونَ", "no_friends_added": يس لديك أصدقاء مضافون",
"pending": َيْدُ الْانْتِظَارِ", "pending": يد الانتظار",
"no_pending_invites": َيْسَ لَدَيْكَ دَعَوَاتٌ قَيْدُ الْانْتِظَارِ", "no_pending_invites": يس لديك دعوات معلقة",
"no_blocked_users": َيْسَ لَدَيْكَ مُسْتَخْدِمُونَ مَحْظُورُونَ", "no_blocked_users": يس لديك مستخدمون محظورون",
"friend_code_copied": َمَّ نَسْخُ رَمْزِ الصَّدِيقِ", "friend_code_copied": م نسخ رمز الصديق",
"undo_friendship_modal_text": َيُؤَدِّي هَذَا إِلَى إِلْغَاءِ صَدَاقَتِكَ مَعَ {{displayName}}", "undo_friendship_modal_text": يؤدي هذا إلى إلغاء صداقتك مع {{displayName}}",
"privacy_hint": ِتَعْدِيلِ مَنْ يُمْكِنُهُ رُؤْيَةُ هَذَا، اذْهَبْ إِلَى <0>الإعْدَادَاتِ</0>", "privacy_hint": ضبط من يمكنه رؤية هذا، انتقل إلى <0>الإعدادات</0>",
"locked_profile": "هَذَا الْمَلَفُّ الشَّخْصِيُّ خَاصٌّ", "locked_profile": "هذا الملف الشخصي خاص",
"image_process_failure": َشَلَ أَثْنَاءَ مُعَالَجَةِ الصُّورَةِ", "image_process_failure": شل معالجة الصورة",
"required_field": "هَذَا الْحَقْلُ مَطْلُوبٌ", "required_field": "هذا الحقل مطلوب",
"displayname_min_length": َجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَقَلِّ 3 أَحْرُفٍ", "displayname_min_length": جب أن يكون اسم العرض على الأقل 3 أحرف",
"displayname_max_length": َجِبُ أَنْ يَكُونَ اسْمُ الْعَرْضِ عَلَى الْأَكْثَرِ 50 حَرْفًا", "displayname_max_length": جب ألا يتجاوز اسم العرض 50 حرفًا",
"report_profile": "تَقْرِيرٌ عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ", "report_profile": "الإبلاغ عن هذا الملف الشخصي",
"report_reason": ِمَاذَا تُقَدِّمُ تَقْرِيرًا عَنْ هَذَا الْمَلَفِّ الشَّخْصِيِّ؟", "report_reason": ماذا تقوم بالإبلاغ عن هذا الملف الشخصي؟",
"report_description": َعْلُومَاتٌ إِضَافِيَّةٌ", "report_description": علومات إضافية",
"report_description_placeholder": َعْلُومَاتٌ إِضَافِيَّةٌ", "report_description_placeholder": علومات إضافية",
"report": "تَقْرِيرٌ", "report": "الإبلاغ",
"report_reason_hate": ِطَابُ الْكُرْهِ", "report_reason_hate": طاب كراهية",
"report_reason_sexual_content": ُحْتَوًى جِنْسِيٌّ", "report_reason_sexual_content": حتوى جنسي",
"report_reason_violence": ُنْفٌ", "report_reason_violence": نف",
"report_reason_spam": "رَاسِلَةٌ عَشْوَائِيَّةٌ", "report_reason_spam": "بريد عشوائي",
"report_reason_other": "آخَرُ", "report_reason_other": "أخرى",
"profile_reported": َمَّ تَقْرِيرُ الْمَلَفِّ الشَّخْصِيِّ", "profile_reported": م الإبلاغ عن الملف الشخصي",
"your_friend_code": َمْزُ صَدِيقِكَ:", "your_friend_code": مز صديقك:",
"upload_banner": "رَفْعُ لَافِتَةٍ", "upload_banner": "تحميل بانر",
"uploading_banner": َارٍ رَفْعُ اللَّافِتَةِ...", "uploading_banner": "جارٍ تحميل البانر...",
"background_image_updated": َمَّ تَحْدِيثُ صُورَةِ الْخَلْفِيَّةِ", "background_image_updated": م تحديث صورة الخلفية",
"stats": "الإحْصَائِيَّاتُ", "stats": "الإحصائيات",
"achievements": "الإِنْجَازَاتُ", "achievements": "إنجازات",
"games": "الْأَلْعَابُ", "games": "الألعاب",
"top_percentile": "الْأَفْضَلُ {{percentile}}%", "top_percentile": "ال{{percentile}}% الأعلى",
"ranking_updated_weekly": "التَّرْتِيبُ يُحَدَّثُ أُسْبُوعِيًّا", "ranking_updated_weekly": "يتم تحديث التصنيف أسبوعيًا",
"playing": "جَارِي اللَّعِبُ {{game}}", "playing": "يلعب {{game}}",
"achievements_unlocked": "الإِنْجَازَاتُ الْمَفْتُوحَةُ", "achievements_unlocked": "الإنجازات المفتوحة",
"earned_points": "النَّقَاطُ الْمَكْسُوبَةُ", "earned_points": "النقاط المكتسبة",
"show_achievements_on_profile": َرْضُ إِنْجَازَاتِكَ عَلَى مَلَفِّكَ الشَّخْصِيِّ", "show_achievements_on_profile": رض إنجازاتك على ملفك الشخصي",
"show_points_on_profile": َرْضُ النَّقَاطِ الْمَكْسُوبَةِ عَلَى مَلَفِّكَ الشَّخْصِيِّ" "show_points_on_profile": رض نقاطك المكتسبة على ملفك الشخصي"
}, },
"achievement": { "achievement": {
"achievement_unlocked": "إِنْجَازٌ مَفْتُوحٌ", "achievement_unlocked": "تم فتح الإنجاز",
"user_achievements": ِنْجَازَاتُ {{displayName}}", "user_achievements": نجازات {{displayName}}",
"your_achievements": ِنْجَازَاتُكَ", "your_achievements": نجازاتك",
"unlocked_at": َمَّ الْفَتْحُ فِي: {{date}}", "unlocked_at": م الفتح في: {{date}}",
"subscription_needed": َحْتَاجُ اشْتِرَاكُ Hydra Cloud لِرُؤْيَةِ هَذَا الْمُحْتَوَى", "subscription_needed": حتاج إلى اشتراك Hydra Cloud لرؤية هذا المحتوى",
"new_achievements_unlocked": َمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ مِنْ {{gameCount}} أَلْعَابٍ", "new_achievements_unlocked": م فتح {{achievementCount}} إنجازات جديدة من {{gameCount}} ألعاب",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} إِنْجَازَاتٍ", "achievement_progress": "{{unlockedCount}}/{{totalCount}} إنجازات",
"achievements_unlocked_for_game": َمَّ فَتْحُ {{achievementCount}} إِنْجَازَاتٍ جَدِيدَةٍ لِـ {{gameTitle}}", "achievements_unlocked_for_game": م فتح {{achievementCount}} إنجازات جديدة لـ {{gameTitle}}",
"hidden_achievement_tooltip": "هَذَا إِنْجَازٌ مَخْفِيٌّ", "hidden_achievement_tooltip": "هذا إنجاز مخفي",
"achievement_earn_points": "اكْسِبْ {{points}} نَقَاطًا بِهَذَا الإِنْجَازِ", "achievement_earn_points": "اكسب {{points}} نقطة مع هذا الإنجاز",
"earned_points": "النَّقَاطُ الْمَكْسُوبَةُ:", "earned_points": "النقاط المكتسبة:",
"available_points": "النَّقَاطُ الْمُتَوَفِّرَةُ:", "available_points": "النقاط المتاحة:",
"how_to_earn_achievements_points": َيْفَ تَكْسِبُ نَقَاطَ الإِنْجَازَاتِ؟" "how_to_earn_achievements_points": يفية كسب نقاط الإنجازات؟"
}, },
"hydra_cloud": { "hydra_cloud": {
"subscription_tour_title": "اشْتِرَاكُ Hydra Cloud", "subscription_tour_title": "اشتراك Hydra Cloud",
"subscribe_now": "اشْتَرِكِ الْآنَ", "subscribe_now": "اشترك الآن",
"cloud_saving": ِفْظٌ سَحَابِيٌّ", "cloud_saving": فظ سحابي",
"cloud_achievements": "حِفْظُ إِنْجَازَاتِكَ فِي السَّحَابَةِ", "cloud_achievements": "احفظ إنجازاتك على السحابة",
"animated_profile_picture": ُورُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", "animated_profile_picture": "صورة ملف شخصي متحركة",
"premium_support": "الدَّعْمُ الْمُتَقَدِّمُ", "premium_support": "دعم ممتاز",
"show_and_compare_achievements": "عَرْضٌ وَمُقَارَنَةُ إِنْجَازَاتِكَ مَعَ مُسْتَخْدِمِينَ آخَرِينَ", "show_and_compare_achievements": "اعرض وقارن إنجازاتك مع المستخدمين الآخرين",
"animated_profile_banner": "لَافِتَةُ الْمَلَفِّ الشَّخْصِيِّ الْمُتَحَرِّكَةِ", "animated_profile_banner": "بانر ملف شخصي متحرك",
"hydra_cloud": "Hydra Cloud", "hydra_cloud": "Hydra Cloud",
"hydra_cloud_feature_found": َقَدْ اكْتَشَفْتَ مِيزَةً مِنْ Hydra Cloud!", "hydra_cloud_feature_found": قد اكتشفت ميزة Hydra Cloud!",
"learn_more": "تَعَلَّمْ أَكْثَرَ" "learn_more": "معرفة المزيد"
} }
} }

View File

@@ -7,18 +7,13 @@ export const defaultDownloadsPath = app.getPath("downloads");
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging"); export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
export const levelDatabasePath = path.join(
app.getPath("userData"),
`hydra-db${isStaging ? "-staging" : ""}`
);
export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
export const databasePath = path.join( export const databasePath = path.join(
databaseDirectory, databaseDirectory,
isStaging ? "hydra_test.db" : "hydra.db" isStaging ? "hydra_test.db" : "hydra.db"
); );
export const logsPath = path.join(app.getPath("userData"), "hydra", "logs"); export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
export const seedsPath = app.isPackaged export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds") ? path.join(process.resourcesPath, "seeds")

27
src/main/data-source.ts Normal file
View File

@@ -0,0 +1,27 @@
import { DataSource } from "typeorm";
import {
DownloadQueue,
Game,
GameShopCache,
UserPreferences,
UserAuth,
GameAchievement,
UserSubscription,
} from "@main/entity";
import { databasePath } from "./constants";
export const dataSource = new DataSource({
type: "better-sqlite3",
entities: [
Game,
UserAuth,
UserPreferences,
UserSubscription,
GameShopCache,
DownloadQueue,
GameAchievement,
],
synchronize: false,
database: databasePath,
});

View File

@@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { Game } from "./game.entity";
@Entity("download_queue")
export class DownloadQueue {
@PrimaryGeneratedColumn()
id: number;
@OneToOne("Game", "downloadQueue")
@JoinColumn()
game: Game;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,19 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("game_achievement")
export class GameAchievement {
@PrimaryGeneratedColumn()
id: number;
@Column("text")
objectId: string;
@Column("text")
shop: string;
@Column("text", { nullable: true })
unlockedAchievements: string | null;
@Column("text", { nullable: true })
achievements: string | null;
}

View File

@@ -0,0 +1,35 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
import type { GameShop } from "@types";
@Entity("game_shop_cache")
export class GameShopCache {
@PrimaryColumn("text", { unique: true })
objectID: string;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
serializedData: string;
/**
* @deprecated Use IndexedDB's `howLongToBeatEntries` instead
*/
@Column("text", { nullable: true })
howLongToBeatSerializedData: string;
@Column("text", { nullable: true })
language: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,90 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
} from "typeorm";
import type { GameShop, GameStatus } from "@types";
import { Downloader } from "@shared";
import type { DownloadQueue } from "./download-queue.entity";
@Entity("game")
export class Game {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
objectID: string;
@Column("text", { unique: true, nullable: true })
remoteId: string | null;
@Column("text")
title: string;
@Column("text", { nullable: true })
iconUrl: string | null;
@Column("text", { nullable: true })
folderName: string | null;
@Column("text", { nullable: true })
downloadPath: string | null;
@Column("text", { nullable: true })
executablePath: string | null;
@Column("text", { nullable: true })
launchOptions: string | null;
@Column("text", { nullable: true })
winePrefixPath: string | null;
@Column("int", { default: 0 })
playTimeInMilliseconds: number;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
status: GameStatus | null;
@Column("int", { default: Downloader.Torrent })
downloader: Downloader;
/**
* Progress is a float between 0 and 1
*/
@Column("float", { default: 0 })
progress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;
@Column("datetime", { nullable: true })
lastTimePlayed: Date | null;
@Column("float", { default: 0 })
fileSize: number;
@Column("text", { nullable: true })
uri: string | null;
@OneToOne("DownloadQueue", "game")
downloadQueue: DownloadQueue;
@Column("boolean", { default: false })
isDeleted: boolean;
@Column("boolean", { default: false })
shouldSeed: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

8
src/main/entity/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from "./game.entity";
export * from "./user-auth.entity";
export * from "./user-preferences.entity";
export * from "./user-subscription.entity";
export * from "./game-shop-cache.entity";
export * from "./game.entity";
export * from "./game-achievements.entity";
export * from "./download-queue.entity";

View File

@@ -0,0 +1,45 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
} from "typeorm";
import { UserSubscription } from "./user-subscription.entity";
@Entity("user_auth")
export class UserAuth {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { default: "" })
userId: string;
@Column("text", { default: "" })
displayName: string;
@Column("text", { nullable: true })
profileImageUrl: string | null;
@Column("text", { nullable: true })
backgroundImageUrl: string | null;
@Column("text", { default: "" })
accessToken: string;
@Column("text", { default: "" })
refreshToken: string;
@Column("int", { default: 0 })
tokenExpirationTimestamp: number;
@OneToOne("UserSubscription", "user")
subscription: UserSubscription | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("user_preferences")
export class UserPreferences {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true })
downloadsPath: string | null;
@Column("text", { default: "en" })
language: string;
@Column("text", { nullable: true })
realDebridApiToken: string | null;
@Column("boolean", { default: false })
downloadNotificationsEnabled: boolean;
@Column("boolean", { default: false })
repackUpdatesNotificationsEnabled: boolean;
@Column("boolean", { default: true })
achievementNotificationsEnabled: boolean;
@Column("boolean", { default: false })
preferQuitInsteadOfHiding: boolean;
@Column("boolean", { default: false })
runAtStartup: boolean;
@Column("boolean", { default: false })
startMinimized: boolean;
@Column("boolean", { default: false })
disableNsfwAlert: boolean;
@Column("boolean", { default: true })
seedAfterDownloadComplete: boolean;
@Column("boolean", { default: false })
showHiddenAchievementsDescription: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -0,0 +1,42 @@
import type { SubscriptionStatus } from "@types";
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import { UserAuth } from "./user-auth.entity";
@Entity("user_subscription")
export class UserSubscription {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { default: "" })
subscriptionId: string;
@OneToOne("UserAuth", "subscription")
@JoinColumn()
user: UserAuth;
@Column("text", { default: "" })
status: SubscriptionStatus;
@Column("text", { default: "" })
planId: string;
@Column("text", { default: "" })
planName: string;
@Column("datetime", { nullable: true })
expiresAt: Date | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -1,19 +1,13 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { Auth } from "@types";
import { Crypto } from "@main/services";
const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => { const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, { const auth = await userAuthRepository.findOne({ where: { id: 1 } });
valueEncoding: "json",
});
if (!auth) return null; if (!auth) return null;
const payload = jwt.decode( const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
Crypto.decrypt(auth.accessToken)
) as jwt.JwtPayload;
if (!payload) return null; if (!payload) return null;

View File

@@ -1,25 +1,27 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services"; import { DownloadManager, HydraApi, gamesPlaytime } from "@main/services";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game, UserAuth, UserSubscription } from "@main/entity";
import { PythonRPC } from "@main/services/python-rpc"; import { PythonRPC } from "@main/services/python-rpc";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const signOut = async (_event: Electron.IpcMainInvokeEvent) => { const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
const databaseOperations = db const databaseOperations = dataSource
.batch([ .transaction(async (transactionalEntityManager) => {
{ await transactionalEntityManager.getRepository(DownloadQueue).delete({});
type: "del",
key: levelKeys.auth, await transactionalEntityManager.getRepository(Game).delete({});
},
{ await transactionalEntityManager
type: "del", .getRepository(UserAuth)
key: levelKeys.user, .delete({ id: 1 });
},
]) await transactionalEntityManager
.getRepository(UserSubscription)
.delete({ id: 1 });
})
.then(() => { .then(() => {
/* Removes all games being played */ /* Removes all games being played */
gamesPlaytime.clear(); gamesPlaytime.clear();
return Promise.all([gamesSublevel.clear(), downloadsSublevel.clear()]);
}); });
/* Cancels any ongoing downloads */ /* Cancels any ongoing downloads */

View File

@@ -1,10 +1,10 @@
import { getSteamAppDetails, logger } from "@main/services"; import { gameShopCacheRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop } from "@types"; import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { gamesShopCacheSublevel, levelKeys } from "@main/level";
const getLocalizedSteamAppDetails = async ( const getLocalizedSteamAppDetails = async (
objectId: string, objectId: string,
@@ -39,27 +39,35 @@ const getGameShopDetails = async (
language: string language: string
): Promise<ShopDetails | null> => { ): Promise<ShopDetails | null> => {
if (shop === "steam") { if (shop === "steam") {
const cachedData = await gamesShopCacheSublevel.get( const cachedData = await gameShopCacheRepository.findOne({
levelKeys.gameShopCacheItem(shop, objectId, language) where: { objectID: objectId, language },
); });
const appDetails = getLocalizedSteamAppDetails(objectId, language).then( const appDetails = getLocalizedSteamAppDetails(objectId, language).then(
(result) => { (result) => {
if (result) { if (result) {
gamesShopCacheSublevel gameShopCacheRepository.upsert(
.put(levelKeys.gameShopCacheItem(shop, objectId, language), result) {
.catch((err) => { objectID: objectId,
logger.error("Could not cache game details", err); shop: "steam",
}); language,
serializedData: JSON.stringify(result),
},
["objectID"]
);
} }
return result; return result;
} }
); );
if (cachedData) { const cachedGame = cachedData?.serializedData
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
: null;
if (cachedGame) {
return { return {
...cachedData, ...cachedGame,
objectId, objectId,
} as ShopDetails; } as ShopDetails;
} }

View File

@@ -1,14 +1,14 @@
import { db, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { userPreferencesRepository } from "@main/repository";
import type { TrendingGame } from "@types"; import type { TrendingGame } from "@types";
const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => { const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
const language = await db const userPreferences = await userPreferencesRepository.findOne({
.get<string, string>(levelKeys.language, { where: { id: 1 },
valueEncoding: "utf-8", });
})
.then((language) => language || "en"); const language = userPreferences?.language || "en";
const trendingGames = await HydraApi.get<TrendingGame[]>( const trendingGames = await HydraApi.get<TrendingGame[]>(
"/games/trending", "/games/trending",

View File

@@ -1,14 +1,19 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { GameShop } from "@types"; import type { GameShop } from "@types";
import { Ludusavi } from "@main/services"; import { Ludusavi } from "@main/services";
import { gamesSublevel, levelKeys } from "@main/level"; import { gameRepository } from "@main/repository";
const getGameBackupPreview = async ( const getGameBackupPreview = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
objectId: string, objectId: string,
shop: GameShop shop: GameShop
) => { ) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: {
objectID: objectId,
shop,
},
});
return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath); return Ludusavi.getBackupPreview(shop, objectId, game?.winePrefixPath);
}; };

View File

@@ -10,7 +10,7 @@ import os from "node:os";
import { backupsPath } from "@main/constants"; import { backupsPath } from "@main/constants";
import { app } from "electron"; import { app } from "electron";
import { normalizePath } from "@main/helpers"; import { normalizePath } from "@main/helpers";
import { gamesSublevel, levelKeys } from "@main/level"; import { gameRepository } from "@main/repository";
const bundleBackup = async ( const bundleBackup = async (
shop: GameShop, shop: GameShop,
@@ -46,7 +46,12 @@ const uploadSaveGame = async (
shop: GameShop, shop: GameShop,
downloadOptionTitle: string | null downloadOptionTitle: string | null
) => { ) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: {
objectID: objectId,
shop,
},
});
const bundleLocation = await bundleBackup( const bundleLocation = await bundleBackup(
shop, shop,

View File

@@ -0,0 +1,44 @@
import { Document as YMLDocument } from "yaml";
import { Game } from "@main/entity";
import path from "node:path";
export const generateYML = (game: Game) => {
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
const doc = new YMLDocument({
name: game.title,
game_slug: slugifiedGameTitle,
slug: `${slugifiedGameTitle}-installer`,
version: "Installer",
runner: "wine",
script: {
game: {
prefix: "$GAMEDIR",
arch: "win64",
working_dir: "$GAMEDIR",
},
installer: [
{
task: {
name: "create_prefix",
arch: "win64",
prefix: "$GAMEDIR",
},
},
{
task: {
executable: path.join(
game.downloadPath!,
game.folderName!,
"setup.exe"
),
name: "wineexec",
prefix: "$GAMEDIR",
},
},
],
},
});
return doc.toString();
};

View File

@@ -1,14 +1,12 @@
import { userPreferencesRepository } from "@main/repository";
import { defaultDownloadsPath } from "@main/constants"; import { defaultDownloadsPath } from "@main/constants";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
export const getDownloadsPath = async () => { export const getDownloadsPath = async () => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await userPreferencesRepository.findOne({
levelKeys.userPreferences, where: {
{ id: 1,
valueEncoding: "json", },
} });
);
if (userPreferences && userPreferences.downloadsPath) if (userPreferences && userPreferences.downloadsPath)
return userPreferences.downloadsPath; return userPreferences.downloadsPath;

View File

@@ -1,55 +1,57 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { Game, GameShop } from "@types"; import type { GameShop } from "@types";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements"; import { updateLocalUnlockedAchivements } from "@main/services/achievements/update-local-unlocked-achivements";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
const addGameToLibrary = async ( const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string, objectId: string,
title: string title: string,
shop: GameShop
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); return gameRepository
const game = await gamesSublevel.get(gameKey); .update(
{
objectID: objectId,
},
{
shop,
status: null,
isDeleted: false,
}
)
.then(async ({ affected }) => {
if (!affected) {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
if (game) { const iconUrl = steamGame?.clientIcon
await downloadsSublevel.del(gameKey); ? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(gameKey, { await gameRepository.insert({
...game, title,
isDeleted: false, iconUrl,
objectID: objectId,
shop,
});
}
const game = await gameRepository.findOne({
where: { objectID: objectId },
});
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
}); });
} else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
const game: Game = {
title,
iconUrl,
objectId,
shop,
remoteId: null,
isDeleted: false,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
};
await gamesSublevel.put(levelKeys.game(shop, objectId), game);
updateLocalUnlockedAchivements(game!);
createGame(game!).catch(() => {});
}
}; };
registerEvent("addGameToLibrary", addGameToLibrary); registerEvent("addGameToLibrary", addGameToLibrary);

View File

@@ -1,11 +1,10 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { logger } from "@main/services"; import { logger } from "@main/services";
import sudo from "sudo-prompt"; import sudo from "sudo-prompt";
import { app } from "electron"; import { app } from "electron";
import { PythonRPC } from "@main/services/python-rpc"; import { PythonRPC } from "@main/services/python-rpc";
import { ProcessPayload } from "@main/services/download/types"; import { ProcessPayload } from "@main/services/download/types";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const getKillCommand = (pid: number) => { const getKillCommand = (pid: number) => {
if (process.platform == "win32") { if (process.platform == "win32") {
@@ -17,14 +16,15 @@ const getKillCommand = (pid: number) => {
const closeGame = async ( const closeGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const processes = const processes =
(await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data || (await PythonRPC.rpc.get<ProcessPayload[] | null>("/process-list")).data ||
[]; [];
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game) return; if (!game) return;

View File

@@ -1,18 +1,18 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { IsNull, Not } from "typeorm";
import createDesktopShortcut from "create-desktop-shortcuts"; import createDesktopShortcut from "create-desktop-shortcuts";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { removeSymbolsFromName } from "@shared"; import { removeSymbolsFromName } from "@shared";
import { GameShop } from "@types";
import { gamesSublevel, levelKeys } from "@main/level";
const createGameShortcut = async ( const createGameShortcut = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, id: number
objectId: string
): Promise<boolean> => { ): Promise<boolean> => {
const gameKey = levelKeys.game(shop, objectId); const game = await gameRepository.findOne({
const game = await gamesSublevel.get(gameKey); where: { id, executablePath: Not(IsNull()) },
});
if (game) { if (game) {
const filePath = game.executablePath; const filePath = game.executablePath;

View File

@@ -1,27 +1,37 @@
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path"; import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services"; import { logger } from "@main/services";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
const deleteGameFolder = async ( const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
): Promise<void> => { ): Promise<void> => {
const downloadKey = levelKeys.game(shop, objectId); const game = await gameRepository.findOne({
where: [
{
id: gameId,
isDeleted: false,
status: "removed",
},
{
id: gameId,
progress: 1,
isDeleted: false,
},
],
});
const download = await downloadsSublevel.get(downloadKey); if (!game) return;
if (!download) return; if (game.folderName) {
if (download.folderName) {
const folderPath = path.join( const folderPath = path.join(
download.downloadPath ?? (await getDownloadsPath()), game.downloadPath ?? (await getDownloadsPath()),
download.folderName game.folderName
); );
if (fs.existsSync(folderPath)) { if (fs.existsSync(folderPath)) {
@@ -42,7 +52,10 @@ const deleteGameFolder = async (
} }
} }
await downloadsSublevel.del(downloadKey); await gameRepository.update(
{ id: gameId },
{ downloadPath: null, folderName: null, status: null, progress: 0 }
);
}; };
registerEvent("deleteGameFolder", deleteGameFolder); registerEvent("deleteGameFolder", deleteGameFolder);

View File

@@ -1,21 +1,16 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, downloadsSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const getGameByObjectId = async ( const getGameByObjectId = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop,
objectId: string objectId: string
) => { ) =>
const gameKey = levelKeys.game(shop, objectId); gameRepository.findOne({
const [game, download] = await Promise.all([ where: {
gamesSublevel.get(gameKey), objectID: objectId,
downloadsSublevel.get(gameKey), isDeleted: false,
]); },
});
if (!game) return null;
return { id: gameKey, ...game, download };
};
registerEvent("getGameByObjectId", getGameByObjectId); registerEvent("getGameByObjectId", getGameByObjectId);

View File

@@ -1,26 +1,17 @@
import type { LibraryGame } from "@types"; import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, gamesSublevel } from "@main/level";
const getLibrary = async (): Promise<LibraryGame[]> => { const getLibrary = async () =>
return gamesSublevel gameRepository.find({
.iterator() where: {
.all() isDeleted: false,
.then((results) => { },
return Promise.all( relations: {
results downloadQueue: true,
.filter(([_key, game]) => game.isDeleted === false) },
.map(async ([key, game]) => { order: {
const download = await downloadsSublevel.get(key); createdAt: "desc",
},
return { });
id: key,
...game,
download: download ?? null,
};
})
);
});
};
registerEvent("getLibrary", getLibrary); registerEvent("getLibrary", getLibrary);

View File

@@ -1,14 +1,14 @@
import { shell } from "electron"; import { shell } from "electron";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const openGameExecutablePath = async ( const openGameExecutablePath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!game || !game.executablePath) return; if (!game || !game.executablePath) return;

View File

@@ -1,22 +1,22 @@
import { shell } from "electron"; import { shell } from "electron";
import path from "node:path"; import path from "node:path";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path"; import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { GameShop } from "@types";
import { downloadsSublevel, levelKeys } from "@main/level";
const openGameInstallerPath = async ( const openGameInstallerPath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
if (!download || !download.folderName || !download.downloadPath) return true; if (!game || !game.folderName || !game.downloadPath) return true;
const gamePath = path.join( const gamePath = path.join(
download.downloadPath ?? (await getDownloadsPath()), game.downloadPath ?? (await getDownloadsPath()),
download.folderName! game.folderName!
); );
shell.showItemInFolder(gamePath); shell.showItemInFolder(gamePath);

View File

@@ -1,12 +1,14 @@
import { shell } from "electron"; import { shell } from "electron";
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { writeFile } from "node:fs/promises";
import { spawnSync, exec } from "node:child_process"; import { spawnSync, exec } from "node:child_process";
import { gameRepository } from "@main/repository";
import { generateYML } from "../helpers/generate-lutris-yaml";
import { getDownloadsPath } from "../helpers/get-downloads-path"; import { getDownloadsPath } from "../helpers/get-downloads-path";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { downloadsSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const executeGameInstaller = (filePath: string) => { const executeGameInstaller = (filePath: string) => {
if (process.platform === "win32") { if (process.platform === "win32") {
@@ -24,21 +26,21 @@ const executeGameInstaller = (filePath: string) => {
const openGameInstaller = async ( const openGameInstaller = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const downloadKey = levelKeys.game(shop, objectId); const game = await gameRepository.findOne({
const download = await downloadsSublevel.get(downloadKey); where: { id: gameId, isDeleted: false },
});
if (!download || !download.folderName) return true; if (!game || !game.folderName) return true;
const gamePath = path.join( const gamePath = path.join(
download.downloadPath ?? (await getDownloadsPath()), game.downloadPath ?? (await getDownloadsPath()),
download.folderName! game.folderName!
); );
if (!fs.existsSync(gamePath)) { if (!fs.existsSync(gamePath)) {
await downloadsSublevel.del(downloadKey); await gameRepository.update({ id: gameId }, { status: null });
return true; return true;
} }
@@ -68,6 +70,13 @@ const openGameInstaller = async (
); );
} }
if (spawnSync("which", ["lutris"]).status === 0) {
const ymlPath = path.join(gamePath, "setup.yml");
await writeFile(ymlPath, generateYML(game));
exec(`lutris --install "${ymlPath}"`);
return true;
}
shell.openPath(gamePath); shell.openPath(gamePath);
return true; return true;
}; };

View File

@@ -1,30 +1,22 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { shell } from "electron"; import { shell } from "electron";
import { parseExecutablePath } from "../helpers/parse-executable-path"; import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const openGame = async ( const openGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number,
objectId: string,
executablePath: string, executablePath: string,
launchOptions?: string | null launchOptions: string | null
) => { ) => {
// TODO: revisit this for launchOptions // TODO: revisit this for launchOptions
const parsedPath = parseExecutablePath(executablePath); const parsedPath = parseExecutablePath(executablePath);
const gameKey = levelKeys.game(shop, objectId); await gameRepository.update(
{ id: gameId },
const game = await gamesSublevel.get(gameKey); { executablePath: parsedPath, launchOptions }
);
if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
launchOptions,
});
shell.openPath(parsedPath); shell.openPath(parsedPath);
}; };

View File

@@ -1,26 +1,26 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { gameRepository } from "../../repository";
import { gamesSublevel, levelKeys } from "@main/level"; import { HydraApi, logger } from "@main/services";
import type { GameShop } from "@types";
const removeGameFromLibrary = async ( const removeGameFromLibrary = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); gameRepository.update(
const game = await gamesSublevel.get(gameKey); { id: gameId },
{ isDeleted: true, executablePath: null }
);
if (game) { removeRemoveGameFromLibrary(gameId).catch((err) => {
await gamesSublevel.put(gameKey, { logger.error("removeRemoveGameFromLibrary", err);
...game, });
isDeleted: true, };
executablePath: null,
});
if (game?.remoteId) { const removeRemoveGameFromLibrary = async (gameId: number) => {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {}); const game = await gameRepository.findOne({ where: { id: gameId } });
}
if (game?.remoteId) {
HydraApi.delete(`/profile/games/${game.remoteId}`).catch(() => {});
} }
}; };

View File

@@ -1,14 +1,21 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { levelKeys, downloadsSublevel } from "@main/level"; import { gameRepository } from "../../repository";
import { GameShop } from "@types";
const removeGame = async ( const removeGame = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const downloadKey = levelKeys.game(shop, objectId); await gameRepository.update(
await downloadsSublevel.del(downloadKey); {
id: gameId,
},
{
status: "removed",
downloadPath: null,
bytesDownloaded: 0,
progress: 0,
}
);
}; };
registerEvent("removeGame", removeGame); registerEvent("removeGame", removeGame);

View File

@@ -1,22 +1,16 @@
import { gameAchievementRepository, gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { findAchievementFiles } from "@main/services/achievements/find-achivement-files"; import { findAchievementFiles } from "@main/services/achievements/find-achivement-files";
import fs from "fs"; import fs from "fs";
import { achievementsLogger, HydraApi, WindowManager } from "@main/services"; import { achievementsLogger, HydraApi, WindowManager } from "@main/services";
import { getUnlockedAchievements } from "../user/get-unlocked-achievements"; import { getUnlockedAchievements } from "../user/get-unlocked-achievements";
import {
gameAchievementsSublevel,
gamesSublevel,
levelKeys,
} from "@main/level";
import type { GameShop } from "@types";
const resetGameAchievements = async ( const resetGameAchievements = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
try { try {
const game = await gamesSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({ where: { id: gameId } });
if (!game) return; if (!game) return;
@@ -29,34 +23,28 @@ const resetGameAchievements = async (
} }
} }
const levelKey = levelKeys.game(game.shop, game.objectId); await gameAchievementRepository.update(
{ objectId: game.objectID },
await gameAchievementsSublevel {
.get(levelKey) unlockedAchievements: null,
.then(async (gameAchievements) => { }
if (gameAchievements) { );
await gameAchievementsSublevel.put(levelKey, {
...gameAchievements,
unlockedAchievements: [],
});
}
});
await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then( await HydraApi.delete(`/profile/games/achievements/${game.remoteId}`).then(
() => () =>
achievementsLogger.log( achievementsLogger.log(
`Deleted achievements from ${game.remoteId} - ${game.objectId} - ${game.title}` `Deleted achievements from ${game.remoteId} - ${game.objectID} - ${game.title}`
) )
); );
const gameAchievements = await getUnlockedAchievements( const gameAchievements = await getUnlockedAchievements(
game.objectId, game.objectID,
game.shop, game.shop,
true true
); );
WindowManager.mainWindow?.webContents.send( WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${game.objectId}-${game.shop}`, `on-update-achievements-${game.objectID}-${game.shop}`,
gameAchievements gameAchievements
); );
} catch (error) { } catch (error) {

View File

@@ -1,23 +1,13 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { levelKeys, gamesSublevel } from "@main/level";
import type { GameShop } from "@types";
const selectGameWinePrefix = async ( const selectGameWinePrefix = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, id: number,
objectId: string,
winePrefixPath: string | null winePrefixPath: string | null
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); return gameRepository.update({ id }, { winePrefixPath: winePrefixPath });
const game = await gamesSublevel.get(gameKey);
if (!game) return;
await gamesSublevel.put(gameKey, {
...game,
winePrefixPath: winePrefixPath,
});
}; };
registerEvent("selectGameWinePrefix", selectGameWinePrefix); registerEvent("selectGameWinePrefix", selectGameWinePrefix);

View File

@@ -1,27 +1,25 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { parseExecutablePath } from "../helpers/parse-executable-path"; import { parseExecutablePath } from "../helpers/parse-executable-path";
import { gamesSublevel, levelKeys } from "@main/level";
import type { GameShop } from "@types";
const updateExecutablePath = async ( const updateExecutablePath = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, id: number,
objectId: string,
executablePath: string | null executablePath: string | null
) => { ) => {
const parsedPath = executablePath const parsedPath = executablePath
? parseExecutablePath(executablePath) ? parseExecutablePath(executablePath)
: null; : null;
const gameKey = levelKeys.game(shop, objectId); return gameRepository.update(
{
const game = await gamesSublevel.get(gameKey); id,
if (!game) return; },
{
await gamesSublevel.put(gameKey, { executablePath: parsedPath,
...game, }
executablePath: parsedPath, );
});
}; };
registerEvent("updateExecutablePath", updateExecutablePath); registerEvent("updateExecutablePath", updateExecutablePath);

View File

@@ -1,23 +1,19 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel, levelKeys } from "@main/level";
import { GameShop } from "@types";
const updateLaunchOptions = async ( const updateLaunchOptions = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, id: number,
objectId: string,
launchOptions: string | null launchOptions: string | null
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); return gameRepository.update(
{
const game = await gamesSublevel.get(gameKey); id,
},
if (game) { {
await gamesSublevel.put(gameKey, {
...game,
launchOptions: launchOptions?.trim() != "" ? launchOptions : null, launchOptions: launchOptions?.trim() != "" ? launchOptions : null,
}); }
} );
}; };
registerEvent("updateLaunchOptions", updateLaunchOptions); registerEvent("updateLaunchOptions", updateLaunchOptions);

View File

@@ -1,17 +1,13 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gamesSublevel } from "@main/level";
const verifyExecutablePathInUse = async ( const verifyExecutablePathInUse = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
executablePath: string executablePath: string
) => { ) => {
for await (const game of gamesSublevel.values()) { return gameRepository.findOne({
if (game.executablePath === executablePath) { where: { executablePath },
return true; });
}
}
return false;
}; };
registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse); registerEvent("verifyExecutablePathInUse", verifyExecutablePathInUse);

View File

@@ -1,20 +1,17 @@
import { shell } from "electron"; import { shell } from "electron";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { Crypto, HydraApi } from "@main/services"; import { userAuthRepository } from "@main/repository";
import { db, levelKeys } from "@main/level"; import { HydraApi } from "@main/services";
import type { Auth } from "@types";
const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => { const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
const auth = await db.get<string, Auth>(levelKeys.auth, { const userAuth = await userAuthRepository.findOne({ where: { id: 1 } });
valueEncoding: "json",
});
if (!auth) { if (!userAuth) {
return; return;
} }
const paymentToken = await HydraApi.post("/auth/payment", { const paymentToken = await HydraApi.post("/auth/payment", {
refreshToken: Crypto.decrypt(auth.refreshToken), refreshToken: userAuth.refreshToken,
}).then((response) => response.accessToken); }).then((response) => response.accessToken);
const params = new URLSearchParams({ const params = new URLSearchParams({

View File

@@ -1,8 +1,7 @@
import { Notification } from "electron"; import { Notification } from "electron";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { t } from "i18next"; import { t } from "i18next";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const publishNewRepacksNotification = async ( const publishNewRepacksNotification = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -10,12 +9,9 @@ const publishNewRepacksNotification = async (
) => { ) => {
if (newRepacksCount < 1) return; if (newRepacksCount < 1) return;
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await userPreferencesRepository.findOne({
levelKeys.userPreferences, where: { id: 1 },
{ });
valueEncoding: "json",
}
);
if (userPreferences?.repackUpdatesNotificationsEnabled) { if (userPreferences?.repackUpdatesNotificationsEnabled) {
new Notification({ new Notification({

View File

@@ -1,19 +1,31 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { GameShop } from "@types"; import { dataSource } from "@main/data-source";
import { downloadsSublevel, levelKeys } from "@main/level"; import { DownloadQueue, Game } from "@main/entity";
const cancelGameDownload = async ( const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const downloadKey = levelKeys.game(shop, objectId); await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.cancelDownload(gameId);
await DownloadManager.cancelDownload(downloadKey); await transactionalEntityManager.getRepository(DownloadQueue).delete({
game: { id: gameId },
});
await downloadsSublevel.del(downloadKey); await transactionalEntityManager.getRepository(Game).update(
{
id: gameId,
},
{
status: "removed",
bytesDownloaded: 0,
progress: 0,
}
);
});
}; };
registerEvent("cancelGameDownload", cancelGameDownload); registerEvent("cancelGameDownload", cancelGameDownload);

View File

@@ -1,26 +1,24 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { GameShop } from "@types"; import { dataSource } from "@main/data-source";
import { downloadsSublevel, levelKeys } from "@main/level"; import { DownloadQueue, Game } from "@main/entity";
const pauseGameDownload = async ( const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); await dataSource.transaction(async (transactionalEntityManager) => {
const download = await downloadsSublevel.get(gameKey);
if (download) {
await DownloadManager.pauseDownload(); await DownloadManager.pauseDownload();
await downloadsSublevel.put(gameKey, { await transactionalEntityManager.getRepository(DownloadQueue).delete({
...download, game: { id: gameId },
status: "paused",
}); });
}
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "paused" });
});
}; };
registerEvent("pauseGameDownload", pauseGameDownload); registerEvent("pauseGameDownload", pauseGameDownload);

View File

@@ -1,24 +1,17 @@
import { downloadsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import type { GameShop } from "@types"; import { gameRepository } from "@main/repository";
const pauseGameSeed = async ( const pauseGameSeed = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const downloadKey = levelKeys.game(shop, objectId); await gameRepository.update(gameId, {
const download = await downloadsSublevel.get(downloadKey); status: "complete",
if (!download) return;
await downloadsSublevel.put(downloadKey, {
...download,
shouldSeed: false, shouldSeed: false,
}); });
await DownloadManager.pauseSeeding(downloadKey); await DownloadManager.pauseSeeding(gameId);
}; };
registerEvent("pauseGameSeed", pauseGameSeed); registerEvent("pauseGameSeed", pauseGameSeed);

View File

@@ -1,36 +1,46 @@
import { Not } from "typeorm";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import { downloadsSublevel, levelKeys } from "@main/level"; import { dataSource } from "@main/data-source";
import { GameShop } from "@types"; import { DownloadQueue, Game } from "@main/entity";
const resumeGameDownload = async ( const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const gameKey = levelKeys.game(shop, objectId); const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
},
});
const download = await downloadsSublevel.get(gameKey); if (!game) return;
if (download?.status === "paused") { if (game.status === "paused") {
await DownloadManager.pauseDownload(); await dataSource.transaction(async (transactionalEntityManager) => {
await DownloadManager.pauseDownload();
for await (const [key, value] of downloadsSublevel.iterator()) { await transactionalEntityManager
if (value.status === "active" && value.progress !== 1) { .getRepository(Game)
await downloadsSublevel.put(key, { .update({ status: "active", progress: Not(1) }, { status: "paused" });
...value,
status: "paused",
});
}
}
await DownloadManager.resumeDownload(download); await DownloadManager.resumeDownload(game);
await downloadsSublevel.put(gameKey, { await transactionalEntityManager
...download, .getRepository(DownloadQueue)
status: "active", .delete({ game: { id: gameId } });
timestamp: Date.now(),
await transactionalEntityManager
.getRepository(DownloadQueue)
.insert({ game: { id: gameId } });
await transactionalEntityManager
.getRepository(Game)
.update({ id: gameId }, { status: "active" });
}); });
} }
}; };

View File

@@ -1,23 +1,29 @@
import { downloadsSublevel, levelKeys } from "@main/level";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
import { DownloadManager } from "@main/services"; import { DownloadManager } from "@main/services";
import type { GameShop } from "@types"; import { Downloader } from "@shared";
const resumeGameSeed = async ( const resumeGameSeed = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
shop: GameShop, gameId: number
objectId: string
) => { ) => {
const download = await downloadsSublevel.get(levelKeys.game(shop, objectId)); const game = await gameRepository.findOne({
where: {
id: gameId,
isDeleted: false,
downloader: Downloader.Torrent,
progress: 1,
},
});
if (!download) return; if (!game) return;
await downloadsSublevel.put(levelKeys.game(shop, objectId), { await gameRepository.update(gameId, {
...download, status: "seeding",
shouldSeed: true, shouldSeed: true,
}); });
await DownloadManager.resumeSeeding(download); await DownloadManager.resumeSeeding(game);
}; };
registerEvent("resumeGameSeed", resumeGameSeed); registerEvent("resumeGameSeed", resumeGameSeed);

View File

@@ -1,11 +1,13 @@
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { Download, StartGameDownloadPayload } from "@types"; import type { StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi } from "@main/services"; import { DownloadManager, HydraApi } from "@main/services";
import { Not } from "typeorm";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { createGame } from "@main/services/library-sync"; import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
const startGameDownload = async ( const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -13,84 +15,85 @@ const startGameDownload = async (
) => { ) => {
const { objectId, title, shop, downloadPath, downloader, uri } = payload; const { objectId, title, shop, downloadPath, downloader, uri } = payload;
const gameKey = levelKeys.game(shop, objectId); return dataSource.transaction(async (transactionalEntityManager) => {
const gameRepository = transactionalEntityManager.getRepository(Game);
const downloadQueueRepository =
transactionalEntityManager.getRepository(DownloadQueue);
await DownloadManager.pauseDownload(); const game = await gameRepository.findOne({
where: {
for await (const [key, value] of downloadsSublevel.iterator()) { objectID: objectId,
if (value.status === "active" && value.progress !== 1) {
await downloadsSublevel.put(key, {
...value,
status: "paused",
});
}
}
const game = await gamesSublevel.get(gameKey);
/* Delete any previous download */
await downloadsSublevel.del(gameKey);
if (game?.isDeleted) {
await gamesSublevel.put(gameKey, {
...game,
isDeleted: false,
});
} else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gamesSublevel.put(gameKey, {
title,
iconUrl,
objectId,
shop,
remoteId: null,
playTimeInMilliseconds: 0,
lastTimePlayed: null,
isDeleted: false,
});
}
await DownloadManager.cancelDownload(gameKey);
const download: Download = {
shop,
objectId,
status: "active",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
folderName: null,
fileSize: null,
shouldSeed: false,
timestamp: Date.now(),
};
await downloadsSublevel.put(gameKey, download);
await DownloadManager.startDownload(download);
const updatedGame = await gamesSublevel.get(gameKey);
await Promise.all([
createGame(updatedGame!).catch(() => {}),
HydraApi.post(
"/games/download",
{
objectId,
shop, shop,
}, },
{ needsAuth: false } });
).catch(() => {}),
]); await DownloadManager.pauseDownload();
await gameRepository.update(
{ status: "active", progress: Not(1) },
{ status: "paused" }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: "active",
progress: 0,
bytesDownloaded: 0,
downloadPath,
downloader,
uri,
isDeleted: false,
}
);
} else {
const steamGame = await steamGamesWorker.run(Number(objectId), {
name: "getById",
});
const iconUrl = steamGame?.clientIcon
? steamUrlBuilder.icon(objectId, steamGame.clientIcon)
: null;
await gameRepository.insert({
title,
iconUrl,
objectID: objectId,
downloader,
shop,
status: "active",
downloadPath,
uri,
});
}
const updatedGame = await gameRepository.findOne({
where: {
objectID: objectId,
},
});
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!);
await downloadQueueRepository.delete({ game: { id: updatedGame!.id } });
await downloadQueueRepository.insert({ game: { id: updatedGame!.id } });
await Promise.all([
createGame(updatedGame!).catch(() => {}),
HydraApi.post(
"/games/download",
{
objectId: updatedGame!.objectID,
shop: updatedGame!.shop,
},
{ needsAuth: false }
).catch(() => {}),
]);
});
}; };
registerEvent("startGameDownload", startGameDownload); registerEvent("startGameDownload", startGameDownload);

View File

@@ -1,10 +1,9 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { db, levelKeys } from "@main/level";
import type { UserPreferences } from "@types";
const getUserPreferences = async () => const getUserPreferences = async () =>
db.get<string, UserPreferences>(levelKeys.userPreferences, { userPreferencesRepository.findOne({
valueEncoding: "json", where: { id: 1 },
}); });
registerEvent("getUserPreferences", getUserPreferences); registerEvent("getUserPreferences", getUserPreferences);

View File

@@ -1,35 +1,23 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types"; import type { UserPreferences } from "@types";
import i18next from "i18next"; import i18next from "i18next";
import { db, levelKeys } from "@main/level";
const updateUserPreferences = async ( const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences> preferences: Partial<UserPreferences>
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{ valueEncoding: "json" }
);
if (preferences.language) { if (preferences.language) {
await db.put<string, string>(levelKeys.language, preferences.language, {
valueEncoding: "utf-8",
});
i18next.changeLanguage(preferences.language); i18next.changeLanguage(preferences.language);
} }
await db.put<string, UserPreferences>( return userPreferencesRepository.upsert(
levelKeys.userPreferences,
{ {
...userPreferences, id: 1,
...preferences, ...preferences,
}, },
{ ["id"]
valueEncoding: "json",
}
); );
}; };

View File

@@ -1,8 +1,7 @@
import type { ComparedAchievements, GameShop, UserPreferences } from "@types"; import type { ComparedAchievements, GameShop } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { userPreferencesRepository } from "@main/repository";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import { db, levelKeys } from "@main/level";
const getComparedUnlockedAchievements = async ( const getComparedUnlockedAchievements = async (
_event: Electron.IpcMainInvokeEvent, _event: Electron.IpcMainInvokeEvent,
@@ -10,12 +9,9 @@ const getComparedUnlockedAchievements = async (
shop: GameShop, shop: GameShop,
userId: string userId: string
) => { ) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await userPreferencesRepository.findOne({
levelKeys.userPreferences, where: { id: 1 },
{ });
valueEncoding: "json",
}
);
const showHiddenAchievementsDescription = const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false; userPreferences?.showHiddenAchievementsDescription || false;

View File

@@ -1,23 +1,23 @@
import type { GameShop, UserAchievement, UserPreferences } from "@types"; import type { GameShop, UnlockedAchievement, UserAchievement } from "@types";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data"; import { getGameAchievementData } from "@main/services/achievements/get-game-achievement-data";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
export const getUnlockedAchievements = async ( export const getUnlockedAchievements = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
useCachedData: boolean useCachedData: boolean
): Promise<UserAchievement[]> => { ): Promise<UserAchievement[]> => {
const cachedAchievements = await gameAchievementsSublevel.get( const cachedAchievements = await gameAchievementRepository.findOne({
levelKeys.game(shop, objectId) where: { objectId, shop },
); });
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await userPreferencesRepository.findOne({
levelKeys.userPreferences, where: { id: 1 },
{ });
valueEncoding: "json",
}
);
const showHiddenAchievementsDescription = const showHiddenAchievementsDescription =
userPreferences?.showHiddenAchievementsDescription || false; userPreferences?.showHiddenAchievementsDescription || false;
@@ -25,10 +25,12 @@ export const getUnlockedAchievements = async (
const achievementsData = await getGameAchievementData( const achievementsData = await getGameAchievementData(
objectId, objectId,
shop, shop,
useCachedData useCachedData ? cachedAchievements : null
); );
const unlockedAchievements = cachedAchievements?.unlockedAchievements ?? []; const unlockedAchievements = JSON.parse(
cachedAchievements?.unlockedAchievements || "[]"
) as UnlockedAchievement[];
return achievementsData return achievementsData
.map((achievementData) => { .map((achievementData) => {

View File

@@ -1,19 +1,16 @@
import { db } from "@main/level"; import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event"; import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services"; import { HydraApi } from "@main/services";
import type { User, UserFriends } from "@types"; import type { UserFriends } from "@types";
import { levelKeys } from "@main/level/sublevels";
export const getUserFriends = async ( export const getUserFriends = async (
userId: string, userId: string,
take: number, take: number,
skip: number skip: number
): Promise<UserFriends> => { ): Promise<UserFriends> => {
const user = await db.get<string, User>(levelKeys.user, { const loggedUser = await userAuthRepository.findOne({ where: { id: 1 } });
valueEncoding: "json",
});
if (user?.id === userId) { if (loggedUser?.userId === userId) {
return HydraApi.get(`/profile/friends`, { take, skip }); return HydraApi.get(`/profile/friends`, { take, skip });
} }

View File

@@ -3,12 +3,16 @@ import updater from "electron-updater";
import i18n from "i18next"; import i18n from "i18next";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import fs from "node:fs";
import { electronApp, optimizer } from "@electron-toolkit/utils"; import { electronApp, optimizer } from "@electron-toolkit/utils";
import { logger, WindowManager } from "@main/services"; import { logger, WindowManager } from "@main/services";
import { dataSource } from "@main/data-source";
import resources from "@locales"; import resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
import { knexClient, migrationConfig } from "./knex-client";
import { databaseDirectory } from "./constants";
import { PythonRPC } from "./services/python-rpc"; import { PythonRPC } from "./services/python-rpc";
import { Aria2 } from "./services/aria2"; import { Aria2 } from "./services/aria2";
import { db, levelKeys } from "./level";
const { autoUpdater } = updater; const { autoUpdater } = updater;
@@ -46,6 +50,21 @@ if (process.defaultApp) {
app.setAsDefaultProtocolClient(PROTOCOL); app.setAsDefaultProtocolClient(PROTOCOL);
} }
const runMigrations = async () => {
if (!fs.existsSync(databaseDirectory)) {
fs.mkdirSync(databaseDirectory, { recursive: true });
}
await knexClient.migrate.list(migrationConfig).then((result) => {
logger.log(
"Migrations to run:",
result[1].map((migration) => migration.name)
);
});
await knexClient.migrate.latest(migrationConfig);
};
// This method will be called when Electron has finished // This method will be called when Electron has finished
// initialization and is ready to create browser windows. // initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs. // Some APIs can only be used after this event occurs.
@@ -57,19 +76,31 @@ app.whenReady().then(async () => {
return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString()); return net.fetch(url.pathToFileURL(decodeURI(filePath)).toString());
}); });
await runMigrations()
.then(() => {
logger.log("Migrations executed successfully");
})
.catch((err) => {
logger.log("Migrations failed to run:", err);
});
await dataSource.initialize();
await import("./main"); await import("./main");
const language = await db.get<string, string>(levelKeys.language, { const userPreferences = await userPreferencesRepository.findOne({
valueEncoding: "utf-8", where: { id: 1 },
}); });
if (language) i18n.changeLanguage(language); if (userPreferences?.language) {
i18n.changeLanguage(userPreferences.language);
}
if (!process.argv.includes("--hidden")) { if (!process.argv.includes("--hidden")) {
WindowManager.createMainWindow(); WindowManager.createMainWindow();
} }
WindowManager.createSystemTray(language || "en"); WindowManager.createSystemTray(userPreferences?.language || "en");
}); });
app.on("browser-window-created", (_, window) => { app.on("browser-window-created", (_, window) => {

View File

@@ -1,6 +1,53 @@
import knex from "knex"; import knex, { Knex } from "knex";
import { databasePath } from "./constants"; import { databasePath } from "./constants";
import { Hydra2_0_3 } from "./migrations/20240830143811_Hydra_2_0_3";
import { RepackUris } from "./migrations/20240830143906_RepackUris";
import { UpdateUserLanguage } from "./migrations/20240913213944_update_user_language";
import { EnsureRepackUris } from "./migrations/20240915035339_ensure_repack_uris";
import { app } from "electron"; import { app } from "electron";
import { FixMissingColumns } from "./migrations/20240918001920_FixMissingColumns";
import { CreateGameAchievement } from "./migrations/20240919030940_create_game_achievement";
import { AddAchievementNotificationPreference } from "./migrations/20241013012900_add_achievement_notification_preference";
import { CreateUserSubscription } from "./migrations/20241015235142_create_user_subscription";
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
import { AddShouldSeedColumn } from "./migrations/20241108200154_add_should_seed_colum";
import { AddSeedAfterDownloadColumn } from "./migrations/20241108201806_add_seed_after_download";
import { AddHiddenAchievementDescriptionColumn } from "./migrations/20241216140633_add_hidden_achievement_description_column ";
import { AddLaunchOptionsColumnToGame } from "./migrations/20241226044022_add_launch_options_column_to_game";
export type HydraMigration = Knex.Migration & { name: string };
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
getMigrations(): Promise<HydraMigration[]> {
return Promise.resolve([
Hydra2_0_3,
RepackUris,
UpdateUserLanguage,
EnsureRepackUris,
FixMissingColumns,
CreateGameAchievement,
AddAchievementNotificationPreference,
CreateUserSubscription,
AddBackgroundImageUrl,
AddWinePrefixToGame,
AddStartMinimizedColumn,
AddDisableNsfwAlertColumn,
AddShouldSeedColumn,
AddSeedAfterDownloadColumn,
AddHiddenAchievementDescriptionColumn,
AddLaunchOptionsColumnToGame,
]);
}
getMigrationName(migration: HydraMigration): string {
return migration.name;
}
getMigration(migration: HydraMigration): Promise<Knex.Migration> {
return Promise.resolve(migration);
}
}
export const knexClient = knex({ export const knexClient = knex({
debug: !app.isPackaged, debug: !app.isPackaged,
@@ -9,3 +56,7 @@ export const knexClient = knex({
filename: databasePath, filename: databasePath,
}, },
}); });
export const migrationConfig: Knex.MigratorConfig = {
migrationSource: new MigrationSource(),
};

View File

@@ -1,3 +0,0 @@
export { db } from "./level";
export * from "./sublevels";

View File

@@ -1,4 +0,0 @@
import { levelDatabasePath } from "@main/constants";
import { Level } from "level";
export const db = new Level(levelDatabasePath, { valueEncoding: "json" });

View File

@@ -1,11 +0,0 @@
import type { Download } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const downloadsSublevel = db.sublevel<string, Download>(
levelKeys.downloads,
{
valueEncoding: "json",
}
);

View File

@@ -1,11 +0,0 @@
import type { GameAchievement } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gameAchievementsSublevel = db.sublevel<string, GameAchievement>(
levelKeys.gameAchievements,
{
valueEncoding: "json",
}
);

View File

@@ -1,11 +0,0 @@
import type { ShopDetails } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesShopCacheSublevel = db.sublevel<string, ShopDetails>(
levelKeys.gameShopCache,
{
valueEncoding: "json",
}
);

View File

@@ -1,8 +0,0 @@
import type { Game } from "@types";
import { db } from "../level";
import { levelKeys } from "./keys";
export const gamesSublevel = db.sublevel<string, Game>(levelKeys.games, {
valueEncoding: "json",
});

View File

@@ -1,6 +0,0 @@
export * from "./downloads";
export * from "./games";
export * from "./game-shop-cache";
export * from "./game-achievements";
export * from "./keys";

View File

@@ -1,16 +0,0 @@
import type { GameShop } from "@types";
export const levelKeys = {
games: "games",
game: (shop: GameShop, objectId: string) => `${shop}:${objectId}`,
user: "user",
auth: "auth",
gameShopCache: "gameShopCache",
gameShopCacheItem: (shop: GameShop, objectId: string, language: string) =>
`${shop}:${objectId}:${language}`,
gameAchievements: "gameAchievements",
downloads: "downloads",
userPreferences: "userPreferences",
language: "language",
sqliteMigrationDone: "sqliteMigrationDone",
};

View File

@@ -1,19 +1,16 @@
import { DownloadManager, logger, Ludusavi, startMainLoop } from "./services"; import { DownloadManager, Ludusavi, startMainLoop } from "./services";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "./repository";
import { UserPreferences } from "./entity";
import { RealDebridClient } from "./services/download/real-debrid"; import { RealDebridClient } from "./services/download/real-debrid";
import { HydraApi } from "./services/hydra-api"; import { HydraApi } from "./services/hydra-api";
import { uploadGamesBatch } from "./services/library-sync"; import { uploadGamesBatch } from "./services/library-sync";
import { Aria2 } from "./services/aria2"; import { Aria2 } from "./services/aria2";
import { downloadsSublevel } from "./level/sublevels/downloads";
import { sortBy } from "lodash-es";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { import { IsNull, Not } from "typeorm";
gameAchievementsSublevel,
gamesSublevel,
levelKeys,
db,
} from "./level";
import { Auth, User, type UserPreferences } from "@types";
import { knexClient } from "./knex-client";
const loadState = async (userPreferences: UserPreferences | null) => { const loadState = async (userPreferences: UserPreferences | null) => {
import("./events"); import("./events");
@@ -30,143 +27,33 @@ const loadState = async (userPreferences: UserPreferences | null) => {
uploadGamesBatch(); uploadGamesBatch();
}); });
const downloads = await downloadsSublevel const [nextQueueItem] = await downloadQueueRepository.find({
.values() order: {
.all() id: "DESC",
.then((games) => { },
return sortBy(games, "timestamp", "DESC"); relations: {
}); game: true,
},
});
const [nextItemOnQueue] = downloads; const seedList = await gameRepository.find({
where: {
shouldSeed: true,
downloader: Downloader.Torrent,
progress: 1,
uri: Not(IsNull()),
},
});
const downloadsToSeed = downloads.filter( await DownloadManager.startRPC(nextQueueItem?.game, seedList);
(download) =>
download.shouldSeed &&
download.downloader === Downloader.Torrent &&
download.progress === 1 &&
download.uri !== null
);
await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed);
startMainLoop(); startMainLoop();
}; };
const migrateFromSqlite = async () => { userPreferencesRepository
const sqliteMigrationDone = await db.get(levelKeys.sqliteMigrationDone); .findOne({
where: { id: 1 },
if (sqliteMigrationDone) { })
return; .then((userPreferences) => {
}
const migrateGames = knexClient("game")
.select("*")
.then((games) => {
return gamesSublevel.batch(
games.map((game) => ({
type: "put",
key: levelKeys.game(game.shop, game.objectID),
value: {
objectId: game.objectID,
shop: game.shop,
title: game.title,
iconUrl: game.iconUrl,
playTimeInMilliseconds: game.playTimeInMilliseconds,
lastTimePlayed: game.lastTimePlayed,
remoteId: game.remoteId,
isDeleted: game.isDeleted,
},
}))
);
})
.then(() => {
logger.info("Games migrated successfully");
});
const migrateUserPreferences = knexClient("user_preferences")
.select("*")
.then(async (userPreferences) => {
if (userPreferences.length > 0) {
await db.put(levelKeys.userPreferences, userPreferences[0]);
if (userPreferences[0].language) {
await db.put(levelKeys.language, userPreferences[0].language);
}
}
})
.then(() => {
logger.info("User preferences migrated successfully");
});
const migrateAchievements = knexClient("game_achievement")
.select("*")
.then((achievements) => {
return gameAchievementsSublevel.batch(
achievements.map((achievement) => ({
type: "put",
key: levelKeys.game(achievement.shop, achievement.objectId),
value: {
achievements: JSON.parse(achievement.achievements),
unlockedAchievements: JSON.parse(achievement.unlockedAchievements),
},
}))
);
})
.then(() => {
logger.info("Achievements migrated successfully");
});
const migrateUser = knexClient("user_auth")
.select("*")
.then(async (users) => {
if (users.length > 0) {
await db.put<string, User>(
levelKeys.user,
{
id: users[0].userId,
displayName: users[0].displayName,
profileImageUrl: users[0].profileImageUrl,
backgroundImageUrl: users[0].backgroundImageUrl,
subscription: users[0].subscription,
},
{
valueEncoding: "json",
}
);
await db.put<string, Auth>(
levelKeys.auth,
{
accessToken: users[0].accessToken,
refreshToken: users[0].refreshToken,
tokenExpirationTimestamp: users[0].tokenExpirationTimestamp,
},
{
valueEncoding: "json",
}
);
}
})
.then(() => {
logger.info("User data migrated successfully");
});
return Promise.all([
migrateGames,
migrateUserPreferences,
migrateAchievements,
migrateUser,
]);
};
migrateFromSqlite().then(async () => {
await db.put<string, boolean>(levelKeys.sqliteMigrationDone, true, {
valueEncoding: "json",
});
db.get<string, UserPreferences>(levelKeys.userPreferences, {
valueEncoding: "json",
}).then((userPreferences) => {
loadState(userPreferences); loadState(userPreferences);
}); });
});

View File

@@ -0,0 +1,171 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const Hydra2_0_3: HydraMigration = {
name: "Hydra_2_0_3",
up: async (knex: Knex) => {
const timestamp = new Date().getTime();
await knex.schema.hasTable("migrations").then(async (exists) => {
if (exists) {
await knex.schema.dropTable("migrations");
}
});
await knex.schema.hasTable("download_source").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("download_source", (table) => {
table.increments("id").primary();
table
.text("url")
.unique({ indexName: "download_source_url_unique_" + timestamp });
table.text("name").notNullable();
table.text("etag");
table.integer("downloadCount").notNullable().defaultTo(0);
table.text("status").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("repack").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("repack", (table) => {
table.increments("id").primary();
table
.text("title")
.notNullable()
.unique({ indexName: "repack_title_unique_" + timestamp });
table
.text("magnet")
.notNullable()
.unique({ indexName: "repack_magnet_unique_" + timestamp });
table.integer("page");
table.text("repacker").notNullable();
table.text("fileSize").notNullable();
table.datetime("uploadDate").notNullable();
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
table
.integer("downloadSourceId")
.references("download_source.id")
.onDelete("CASCADE");
});
}
});
await knex.schema.hasTable("game").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("game", (table) => {
table.increments("id").primary();
table
.text("objectID")
.notNullable()
.unique({ indexName: "game_objectID_unique_" + timestamp });
table
.text("remoteId")
.unique({ indexName: "game_remoteId_unique_" + timestamp });
table.text("title").notNullable();
table.text("iconUrl");
table.text("folderName");
table.text("downloadPath");
table.text("executablePath");
table.integer("playTimeInMilliseconds").notNullable().defaultTo(0);
table.text("shop").notNullable();
table.text("status");
table.integer("downloader").notNullable().defaultTo(1);
table.float("progress").notNullable().defaultTo(0);
table.integer("bytesDownloaded").notNullable().defaultTo(0);
table.datetime("lastTimePlayed");
table.float("fileSize").notNullable().defaultTo(0);
table.text("uri");
table.boolean("isDeleted").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
table
.integer("repackId")
.references("repack.id")
.unique("repack_repackId_unique_" + timestamp);
});
}
});
await knex.schema.hasTable("user_preferences").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("user_preferences", (table) => {
table.increments("id").primary();
table.text("downloadsPath");
table.text("language").notNullable().defaultTo("en");
table.text("realDebridApiToken");
table
.boolean("downloadNotificationsEnabled")
.notNullable()
.defaultTo(0);
table
.boolean("repackUpdatesNotificationsEnabled")
.notNullable()
.defaultTo(0);
table.boolean("preferQuitInsteadOfHiding").notNullable().defaultTo(0);
table.boolean("runAtStartup").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("game_shop_cache").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("game_shop_cache", (table) => {
table.text("objectID").primary().notNullable();
table.text("shop").notNullable();
table.text("serializedData");
table.text("howLongToBeatSerializedData");
table.text("language");
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("download_queue").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("download_queue", (table) => {
table.increments("id").primary();
table
.integer("gameId")
.references("game.id")
.unique("download_queue_gameId_unique_" + timestamp);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
await knex.schema.hasTable("user_auth").then(async (exists) => {
if (!exists) {
await knex.schema.createTable("user_auth", (table) => {
table.increments("id").primary();
table.text("userId").notNullable().defaultTo("");
table.text("displayName").notNullable().defaultTo("");
table.text("profileImageUrl");
table.text("accessToken").notNullable().defaultTo("");
table.text("refreshToken").notNullable().defaultTo("");
table.integer("tokenExpirationTimestamp").notNullable().defaultTo(0);
table.datetime("createdAt").notNullable().defaultTo(knex.fn.now());
table.datetime("updatedAt").notNullable().defaultTo(knex.fn.now());
});
}
});
},
down: async (knex: Knex) => {
await knex.schema.dropTableIfExists("game");
await knex.schema.dropTableIfExists("repack");
await knex.schema.dropTableIfExists("download_queue");
await knex.schema.dropTableIfExists("user_auth");
await knex.schema.dropTableIfExists("game_shop_cache");
await knex.schema.dropTableIfExists("user_preferences");
await knex.schema.dropTableIfExists("download_source");
},
};

View File

@@ -0,0 +1,18 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const RepackUris: HydraMigration = {
name: "RepackUris",
up: async (knex: Knex) => {
await knex.schema.alterTable("repack", (table) => {
table.text("uris").notNullable().defaultTo("[]");
});
},
down: async (knex: Knex) => {
await knex.schema.alterTable("repack", (table) => {
table.integer("page");
table.dropColumn("uris");
});
},
};

View File

@@ -0,0 +1,13 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const UpdateUserLanguage: HydraMigration = {
name: "UpdateUserLanguage",
up: async (knex: Knex) => {
await knex("user_preferences")
.update("language", "pt-BR")
.where("language", "pt");
},
down: async (_knex: Knex) => {},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const EnsureRepackUris: HydraMigration = {
name: "EnsureRepackUris",
up: async (knex: Knex) => {
await knex.schema.hasColumn("repack", "uris").then(async (exists) => {
if (!exists) {
await knex.schema.table("repack", (table) => {
table.text("uris").notNullable().defaultTo("[]");
});
}
});
},
down: async (_knex: Knex) => {},
};

View File

@@ -0,0 +1,41 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const FixMissingColumns: HydraMigration = {
name: "FixMissingColumns",
up: async (knex: Knex) => {
const timestamp = new Date().getTime();
await knex.schema
.hasColumn("repack", "downloadSourceId")
.then(async (exists) => {
if (!exists) {
await knex.schema.table("repack", (table) => {
table
.integer("downloadSourceId")
.references("download_source.id")
.onDelete("CASCADE");
});
}
});
await knex.schema.hasColumn("game", "remoteId").then(async (exists) => {
if (!exists) {
await knex.schema.table("game", (table) => {
table
.text("remoteId")
.unique({ indexName: "game_remoteId_unique_" + timestamp });
});
}
});
await knex.schema.hasColumn("game", "uri").then(async (exists) => {
if (!exists) {
await knex.schema.table("game", (table) => {
table.text("uri");
});
}
});
},
down: async (_knex: Knex) => {},
};

View File

@@ -0,0 +1,20 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateGameAchievement: HydraMigration = {
name: "CreateGameAchievement",
up: (knex: Knex) => {
return knex.schema.createTable("game_achievement", (table) => {
table.increments("id").primary();
table.text("objectId").notNullable();
table.text("shop").notNullable();
table.text("achievements");
table.text("unlockedAchievements");
table.unique(["objectId", "shop"]);
});
},
down: (knex: Knex) => {
return knex.schema.dropTable("game_achievement");
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddAchievementNotificationPreference: HydraMigration = {
name: "AddAchievementNotificationPreference",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("achievementNotificationsEnabled").defaultTo(true);
});
},
down: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("achievementNotificationsEnabled");
});
},
};

View File

@@ -0,0 +1,27 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const CreateUserSubscription: HydraMigration = {
name: "CreateUserSubscription",
up: async (knex: Knex) => {
return knex.schema.createTable("user_subscription", (table) => {
table.increments("id").primary();
table.string("subscriptionId").defaultTo("");
table
.text("userId")
.notNullable()
.references("user_auth.id")
.onDelete("CASCADE");
table.string("status").defaultTo("");
table.string("planId").defaultTo("");
table.string("planName").defaultTo("");
table.dateTime("expiresAt").nullable();
table.dateTime("createdAt").defaultTo(knex.fn.now());
table.dateTime("updatedAt").defaultTo(knex.fn.now());
});
},
down: async (knex: Knex) => {
return knex.schema.dropTable("user_subscription");
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddBackgroundImageUrl: HydraMigration = {
name: "AddBackgroundImageUrl",
up: (knex: Knex) => {
return knex.schema.alterTable("user_auth", (table) => {
return table.text("backgroundImageUrl").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_auth", (table) => {
return table.dropColumn("backgroundImageUrl");
});
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddWinePrefixToGame: HydraMigration = {
name: "AddWinePrefixToGame",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.text("winePrefixPath").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("winePrefixPath");
});
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddStartMinimizedColumn: HydraMigration = {
name: "AddStartMinimizedColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("startMinimized").notNullable().defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("startMinimized");
});
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddDisableNsfwAlertColumn: HydraMigration = {
name: "AddDisableNsfwAlertColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("disableNsfwAlert").notNullable().defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("disableNsfwAlert");
});
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddShouldSeedColumn: HydraMigration = {
name: "AddShouldSeedColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.boolean("shouldSeed").notNullable().defaultTo(true);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("shouldSeed");
});
},
};

View File

@@ -0,0 +1,20 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddSeedAfterDownloadColumn: HydraMigration = {
name: "AddSeedAfterDownloadColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table
.boolean("seedAfterDownloadComplete")
.notNullable()
.defaultTo(true);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("seedAfterDownloadComplete");
});
},
};

View File

@@ -0,0 +1,20 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddHiddenAchievementDescriptionColumn: HydraMigration = {
name: "AddHiddenAchievementDescriptionColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table
.boolean("showHiddenAchievementsDescription")
.notNullable()
.defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("showHiddenAchievementsDescription");
});
},
};

View File

@@ -0,0 +1,17 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddLaunchOptionsColumnToGame: HydraMigration = {
name: "AddLaunchOptionsColumnToGame",
up: (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.string("launchOptions").nullable();
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("game", (table) => {
return table.dropColumn("launchOptions");
});
},
};

View File

@@ -0,0 +1,11 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const MigrationName: HydraMigration = {
name: "MigrationName",
up: (knex: Knex) => {
return knex.schema.createTable("table_name", async (table) => {});
},
down: async (knex: Knex) => {},
};

27
src/main/repository.ts Normal file
View File

@@ -0,0 +1,27 @@
import { dataSource } from "./data-source";
import {
DownloadQueue,
Game,
GameShopCache,
UserPreferences,
UserAuth,
GameAchievement,
UserSubscription,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const downloadQueueRepository = dataSource.getRepository(DownloadQueue);
export const userAuthRepository = dataSource.getRepository(UserAuth);
export const userSubscriptionRepository =
dataSource.getRepository(UserSubscription);
export const gameAchievementRepository =
dataSource.getRepository(GameAchievement);

View File

@@ -1,4 +1,6 @@
import { gameRepository } from "@main/repository";
import { parseAchievementFile } from "./parse-achievement-file"; import { parseAchievementFile } from "./parse-achievement-file";
import { Game } from "@main/entity";
import { mergeAchievements } from "./merge-achievements"; import { mergeAchievements } from "./merge-achievements";
import fs, { readdirSync } from "node:fs"; import fs, { readdirSync } from "node:fs";
import { import {
@@ -7,20 +9,21 @@ import {
findAllAchievementFiles, findAllAchievementFiles,
getAlternativeObjectIds, getAlternativeObjectIds,
} from "./find-achivement-files"; } from "./find-achivement-files";
import type { AchievementFile, Game, UnlockedAchievement } from "@types"; import type { AchievementFile, UnlockedAchievement } from "@types";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { IsNull, Not } from "typeorm";
import { publishCombinedNewAchievementNotification } from "../notifications"; import { publishCombinedNewAchievementNotification } from "../notifications";
import { gamesSublevel } from "@main/level";
const fileStats: Map<string, number> = new Map(); const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map(); const fltFiles: Map<string, Set<string>> = new Map();
const watchAchievementsWindows = async () => { const watchAchievementsWindows = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: {
.all() isDeleted: false,
.then((games) => games.filter((game) => !game.isDeleted)); },
});
if (games.length === 0) return; if (games.length === 0) return;
@@ -29,7 +32,7 @@ const watchAchievementsWindows = async () => {
for (const game of games) { for (const game of games) {
const gameAchievementFiles: AchievementFile[] = []; const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectID)) {
gameAchievementFiles.push(...(achievementFiles.get(objectId) || [])); gameAchievementFiles.push(...(achievementFiles.get(objectId) || []));
gameAchievementFiles.push( gameAchievementFiles.push(
@@ -44,12 +47,12 @@ const watchAchievementsWindows = async () => {
}; };
const watchAchievementsWithWine = async () => { const watchAchievementsWithWine = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: {
.all() isDeleted: false,
.then((games) => winePrefixPath: Not(IsNull()),
games.filter((game) => !game.isDeleted && game.winePrefixPath) },
); });
for (const game of games) { for (const game of games) {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);
@@ -185,10 +188,11 @@ export class AchievementWatcherManager {
}; };
private static preSearchAchievementsWindows = async () => { private static preSearchAchievementsWindows = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: {
.all() isDeleted: false,
.then((games) => games.filter((game) => !game.isDeleted)); },
});
const gameAchievementFilesMap = findAllAchievementFiles(); const gameAchievementFilesMap = findAllAchievementFiles();
@@ -196,7 +200,7 @@ export class AchievementWatcherManager {
games.map((game) => { games.map((game) => {
const gameAchievementFiles: AchievementFile[] = []; const gameAchievementFiles: AchievementFile[] = [];
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectID)) {
gameAchievementFiles.push( gameAchievementFiles.push(
...(gameAchievementFilesMap.get(objectId) || []) ...(gameAchievementFilesMap.get(objectId) || [])
); );
@@ -212,10 +216,11 @@ export class AchievementWatcherManager {
}; };
private static preSearchAchievementsWithWine = async () => { private static preSearchAchievementsWithWine = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: {
.all() isDeleted: false,
.then((games) => games.filter((game) => !game.isDeleted)); },
});
return Promise.all( return Promise.all(
games.map((game) => { games.map((game) => {

View File

@@ -1,8 +1,9 @@
import path from "node:path"; import path from "node:path";
import fs from "node:fs"; import fs from "node:fs";
import { app } from "electron"; import { app } from "electron";
import type { Game, AchievementFile } from "@types"; import type { AchievementFile } from "@types";
import { Cracker } from "@shared"; import { Cracker } from "@shared";
import { Game } from "@main/entity";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
const getAppDataPath = () => { const getAppDataPath = () => {
@@ -253,7 +254,7 @@ export const findAchievementFiles = (game: Game) => {
for (const cracker of crackers) { for (const cracker of crackers) {
for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) { for (const { folderPath, fileLocation } of getPathFromCracker(cracker)) {
for (const objectId of getAlternativeObjectIds(game.objectId)) { for (const objectId of getAlternativeObjectIds(game.objectID)) {
const filePath = path.join( const filePath = path.join(
game.winePrefixPath ?? "", game.winePrefixPath ?? "",
folderPath, folderPath,

View File

@@ -1,37 +1,40 @@
import {
gameAchievementRepository,
userPreferencesRepository,
} from "@main/repository";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import type { GameShop, SteamAchievement } from "@types"; import type { AchievementData, GameShop } from "@types";
import { UserNotLoggedInError } from "@shared"; import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level"; import { GameAchievement } from "@main/entity";
export const getGameAchievementData = async ( export const getGameAchievementData = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
useCachedData: boolean cachedAchievements: GameAchievement | null
) => { ) => {
const cachedAchievements = await gameAchievementsSublevel.get( if (cachedAchievements && cachedAchievements.achievements) {
levelKeys.game(shop, objectId) return JSON.parse(cachedAchievements.achievements) as AchievementData[];
); }
if (cachedAchievements && useCachedData) const userPreferences = await userPreferencesRepository.findOne({
return cachedAchievements.achievements; where: { id: 1 },
});
const language = await db return HydraApi.get<AchievementData[]>("/games/achievements", {
.get<string, string>(levelKeys.language, {
valueEncoding: "utf-8",
})
.then((language) => language || "en");
return HydraApi.get<SteamAchievement[]>("/games/achievements", {
shop, shop,
objectId, objectId,
language, language: userPreferences?.language || "en",
}) })
.then(async (achievements) => { .then((achievements) => {
await gameAchievementsSublevel.put(levelKeys.game(shop, objectId), { gameAchievementRepository.upsert(
unlockedAchievements: cachedAchievements?.unlockedAchievements ?? [], {
achievements, objectId,
}); shop,
achievements: JSON.stringify(achievements),
},
["objectId", "shop"]
);
return achievements; return achievements;
}) })
@@ -39,9 +42,15 @@ export const getGameAchievementData = async (
if (err instanceof UserNotLoggedInError) { if (err instanceof UserNotLoggedInError) {
throw err; throw err;
} }
logger.error("Failed to get game achievements", err); logger.error("Failed to get game achievements", err);
return gameAchievementRepository
return []; .findOne({
where: { objectId, shop },
})
.then((gameAchievements) => {
return JSON.parse(
gameAchievements?.achievements || "[]"
) as AchievementData[];
});
}); });
}; };

View File

@@ -1,45 +1,42 @@
import type { import {
Game, gameAchievementRepository,
GameShop, userPreferencesRepository,
UnlockedAchievement, } from "@main/repository";
UserPreferences, import type { AchievementData, GameShop, UnlockedAchievement } from "@types";
} from "@types";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements"; import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements";
import { Game } from "@main/entity";
import { publishNewAchievementNotification } from "../notifications"; import { publishNewAchievementNotification } from "../notifications";
import { SubscriptionRequiredError } from "@shared"; import { SubscriptionRequiredError } from "@shared";
import { achievementsLogger } from "../logger"; import { achievementsLogger } from "../logger";
import { db, gameAchievementsSublevel, levelKeys } from "@main/level";
const saveAchievementsOnLocal = async ( const saveAchievementsOnLocal = async (
objectId: string, objectId: string,
shop: GameShop, shop: GameShop,
unlockedAchievements: UnlockedAchievement[], achievements: UnlockedAchievement[],
sendUpdateEvent: boolean sendUpdateEvent: boolean
) => { ) => {
const levelKey = levelKeys.game(shop, objectId); return gameAchievementRepository
.upsert(
{
objectId,
shop,
unlockedAchievements: JSON.stringify(achievements),
},
["objectId", "shop"]
)
.then(() => {
if (!sendUpdateEvent) return;
return gameAchievementsSublevel return getUnlockedAchievements(objectId, shop, true)
.get(levelKey) .then((achievements) => {
.then(async (gameAchievement) => { WindowManager.mainWindow?.webContents.send(
if (gameAchievement) { `on-update-achievements-${objectId}-${shop}`,
await gameAchievementsSublevel.put(levelKey, { achievements
...gameAchievement, );
unlockedAchievements: unlockedAchievements, })
}); .catch(() => {});
if (!sendUpdateEvent) return;
return getUnlockedAchievements(objectId, shop, true)
.then((achievements) => {
WindowManager.mainWindow?.webContents.send(
`on-update-achievements-${objectId}-${shop}`,
achievements
);
})
.catch(() => {});
}
}); });
}; };
@@ -49,14 +46,22 @@ export const mergeAchievements = async (
publishNotification: boolean publishNotification: boolean
) => { ) => {
const [localGameAchievement, userPreferences] = await Promise.all([ const [localGameAchievement, userPreferences] = await Promise.all([
gameAchievementsSublevel.get(levelKeys.game(game.shop, game.objectId)), gameAchievementRepository.findOne({
db.get<string, UserPreferences>(levelKeys.userPreferences, { where: {
valueEncoding: "json", objectId: game.objectID,
shop: game.shop,
},
}), }),
userPreferencesRepository.findOne({ where: { id: 1 } }),
]); ]);
const achievementsData = localGameAchievement?.achievements ?? []; const achievementsData = JSON.parse(
const unlockedAchievements = localGameAchievement?.unlockedAchievements ?? []; localGameAchievement?.achievements || "[]"
) as AchievementData[];
const unlockedAchievements = JSON.parse(
localGameAchievement?.unlockedAchievements || "[]"
).filter((achievement) => achievement.name) as UnlockedAchievement[];
const newAchievementsMap = new Map( const newAchievementsMap = new Map(
achievements.reverse().map((achievement) => { achievements.reverse().map((achievement) => {
@@ -136,13 +141,13 @@ export const mergeAchievements = async (
if (err! instanceof SubscriptionRequiredError) { if (err! instanceof SubscriptionRequiredError) {
achievementsLogger.log( achievementsLogger.log(
"Achievements not synchronized on API due to lack of subscription", "Achievements not synchronized on API due to lack of subscription",
game.objectId, game.objectID,
game.title game.title
); );
} }
return saveAchievementsOnLocal( return saveAchievementsOnLocal(
game.objectId, game.objectID,
game.shop, game.shop,
mergedLocalAchievements, mergedLocalAchievements,
publishNotification publishNotification
@@ -150,7 +155,7 @@ export const mergeAchievements = async (
}); });
} else { } else {
await saveAchievementsOnLocal( await saveAchievementsOnLocal(
game.objectId, game.objectID,
game.shop, game.shop,
mergedLocalAchievements, mergedLocalAchievements,
publishNotification publishNotification

View File

@@ -4,7 +4,8 @@ import {
} from "./find-achivement-files"; } from "./find-achivement-files";
import { parseAchievementFile } from "./parse-achievement-file"; import { parseAchievementFile } from "./parse-achievement-file";
import { mergeAchievements } from "./merge-achievements"; import { mergeAchievements } from "./merge-achievements";
import type { Game, UnlockedAchievement } from "@types"; import type { UnlockedAchievement } from "@types";
import { Game } from "@main/entity";
export const updateLocalUnlockedAchivements = async (game: Game) => { export const updateLocalUnlockedAchivements = async (game: Game) => {
const gameAchievementFiles = findAchievementFiles(game); const gameAchievementFiles = findAchievementFiles(game);

View File

@@ -1,28 +0,0 @@
import { safeStorage } from "electron";
import { logger } from "./logger";
export class Crypto {
public static encrypt(str: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.encryptString(str).toString("base64");
} else {
logger.warn(
"Encrypt method returned raw string because encryption is not available"
);
return str;
}
}
public static decrypt(b64: string) {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(Buffer.from(b64, "base64"));
} else {
logger.warn(
"Decrypt method returned raw string because encryption is not available"
);
return b64;
}
}
}

View File

@@ -1,7 +1,13 @@
import { Game } from "@main/entity";
import { Downloader } from "@shared"; import { Downloader } from "@shared";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import {
downloadQueueRepository,
gameRepository,
userPreferencesRepository,
} from "@main/repository";
import { publishDownloadCompleteNotification } from "../notifications"; import { publishDownloadCompleteNotification } from "../notifications";
import type { Download, DownloadProgress, UserPreferences } from "@types"; import type { DownloadProgress } from "@types";
import { GofileApi, QiwiApi, DatanodesApi } from "../hosters"; import { GofileApi, QiwiApi, DatanodesApi } from "../hosters";
import { PythonRPC } from "../python-rpc"; import { PythonRPC } from "../python-rpc";
import { import {
@@ -10,41 +16,37 @@ import {
PauseDownloadPayload, PauseDownloadPayload,
} from "./types"; } from "./types";
import { calculateETA, getDirSize } from "./helpers"; import { calculateETA, getDirSize } from "./helpers";
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { RealDebridClient } from "./real-debrid"; import { RealDebridClient } from "./real-debrid";
import path from "path"; import path from "path";
import { logger } from "../logger"; import { logger } from "../logger";
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
import { sortBy } from "lodash-es";
export class DownloadManager { export class DownloadManager {
private static downloadingGameId: string | null = null; private static downloadingGameId: number | null = null;
public static async startRPC( public static async startRPC(game?: Game, initialSeeding?: Game[]) {
download?: Download,
downloadsToSeed?: Download[]
) {
PythonRPC.spawn( PythonRPC.spawn(
download?.status === "active" game?.status === "active"
? await this.getDownloadPayload(download).catch(() => undefined) ? await this.getDownloadPayload(game).catch(() => undefined)
: undefined, : undefined,
downloadsToSeed?.map((download) => ({ initialSeeding?.map((game) => ({
game_id: `${download.shop}-${download.objectId}`, game_id: game.id,
url: download.uri!, url: game.uri!,
save_path: download.downloadPath!, save_path: game.downloadPath!,
})) }))
); );
if (download) { this.downloadingGameId = game?.id ?? null;
this.downloadingGameId = `${download.shop}-${download.objectId}`;
}
} }
private static async getDownloadStatus() { private static async getDownloadStatus() {
const response = await PythonRPC.rpc.get<LibtorrentPayload | null>( const response = await PythonRPC.rpc.get<LibtorrentPayload | null>(
"/status" "/status"
); );
if (response.data === null || !this.downloadingGameId) return null; if (response.data === null || !this.downloadingGameId) return null;
const downloadId = this.downloadingGameId;
const gameId = this.downloadingGameId;
try { try {
const { const {
@@ -60,21 +62,24 @@ export class DownloadManager {
const isDownloadingMetadata = const isDownloadingMetadata =
status === LibtorrentStatus.DownloadingMetadata; status === LibtorrentStatus.DownloadingMetadata;
const isCheckingFiles = status === LibtorrentStatus.CheckingFiles; const isCheckingFiles = status === LibtorrentStatus.CheckingFiles;
const download = await downloadsSublevel.get(downloadId);
if (!isDownloadingMetadata && !isCheckingFiles) { if (!isDownloadingMetadata && !isCheckingFiles) {
if (!download) return null; const update: QueryDeepPartialEntity<Game> = {
await downloadsSublevel.put(downloadId, {
...download,
bytesDownloaded, bytesDownloaded,
fileSize, fileSize,
progress, progress,
folderName,
status: "active", status: "active",
}); };
await gameRepository.update(
{ id: gameId },
{
...update,
folderName,
}
);
} }
return { return {
@@ -85,8 +90,7 @@ export class DownloadManager {
isDownloadingMetadata, isDownloadingMetadata,
isCheckingFiles, isCheckingFiles,
progress, progress,
gameId: downloadId, gameId,
download,
} as DownloadProgress; } as DownloadProgress;
} catch (err) { } catch (err) {
return null; return null;
@@ -98,22 +102,14 @@ export class DownloadManager {
if (status) { if (status) {
const { gameId, progress } = status; const { gameId, progress } = status;
const game = await gameRepository.findOne({
where: { id: gameId, isDeleted: false },
});
const userPreferences = await userPreferencesRepository.findOneBy({
id: 1,
});
const [download, game] = await Promise.all([ if (WindowManager.mainWindow && game) {
downloadsSublevel.get(gameId),
gamesSublevel.get(gameId),
]);
if (!download || !game) return;
const userPreferences = await db.get<string, UserPreferences>(
levelKeys.userPreferences,
{
valueEncoding: "json",
}
);
if (WindowManager.mainWindow && download) {
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress); WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send( WindowManager.mainWindow.webContents.send(
"on-download-progress", "on-download-progress",
@@ -125,42 +121,39 @@ export class DownloadManager {
) )
); );
} }
if (progress === 1 && game) {
if (progress === 1 && download) {
publishDownloadCompleteNotification(game); publishDownloadCompleteNotification(game);
if ( if (
userPreferences?.seedAfterDownloadComplete && userPreferences?.seedAfterDownloadComplete &&
download.downloader === Downloader.Torrent game.downloader === Downloader.Torrent
) { ) {
downloadsSublevel.put(gameId, { gameRepository.update(
...download, { id: gameId },
status: "seeding", { status: "seeding", shouldSeed: true }
shouldSeed: true, );
});
} else { } else {
downloadsSublevel.put(gameId, { gameRepository.update(
...download, { id: gameId },
status: "complete", { status: "complete", shouldSeed: false }
shouldSeed: false, );
});
this.cancelDownload(gameId); this.cancelDownload(gameId);
} }
const downloads = await downloadsSublevel await downloadQueueRepository.delete({ game });
.values() const [nextQueueItem] = await downloadQueueRepository.find({
.all() order: {
.then((games) => { id: "DESC",
return sortBy(games, "timestamp", "DESC"); },
}); relations: {
game: true,
const [nextItemOnQueue] = downloads; },
});
if (nextItemOnQueue) { if (nextQueueItem) {
this.resumeDownload(nextItemOnQueue); this.resumeDownload(nextQueueItem.game);
} else { } else {
this.downloadingGameId = null; this.downloadingGameId = -1;
} }
} }
} }
@@ -176,19 +169,20 @@ export class DownloadManager {
logger.log(seedStatus); logger.log(seedStatus);
seedStatus.forEach(async (status) => { seedStatus.forEach(async (status) => {
const download = await downloadsSublevel.get(status.gameId); const game = await gameRepository.findOne({
where: { id: status.gameId },
});
if (!download) return; if (!game) return;
const totalSize = await getDirSize( const totalSize = await getDirSize(
path.join(download.downloadPath!, status.folderName) path.join(game.downloadPath!, status.folderName)
); );
if (totalSize < status.fileSize) { if (totalSize < status.fileSize) {
await this.cancelDownload(status.gameId); await this.cancelDownload(game.id);
await downloadsSublevel.put(status.gameId, { await gameRepository.update(game.id, {
...download,
status: "paused", status: "paused",
shouldSeed: false, shouldSeed: false,
progress: totalSize / status.fileSize, progress: totalSize / status.fileSize,
@@ -210,109 +204,114 @@ export class DownloadManager {
.catch(() => {}); .catch(() => {});
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
this.downloadingGameId = null; this.downloadingGameId = null;
} }
static async resumeDownload(download: Download) { static async resumeDownload(game: Game) {
return this.startDownload(download); return this.startDownload(game);
} }
static async cancelDownload(downloadKey = this.downloadingGameId!) { static async cancelDownload(gameId = this.downloadingGameId!) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "cancel", action: "cancel",
game_id: downloadKey, game_id: gameId,
}); });
WindowManager.mainWindow?.setProgressBar(-1); WindowManager.mainWindow?.setProgressBar(-1);
if (downloadKey === this.downloadingGameId) {
if (gameId === this.downloadingGameId) {
this.downloadingGameId = null; this.downloadingGameId = null;
} }
} }
static async resumeSeeding(download: Download) { static async resumeSeeding(game: Game) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "resume_seeding", action: "resume_seeding",
game_id: levelKeys.game(download.shop, download.objectId), game_id: game.id,
url: download.uri, url: game.uri,
save_path: download.downloadPath, save_path: game.downloadPath,
}); });
} }
static async pauseSeeding(downloadKey: string) { static async pauseSeeding(gameId: number) {
await PythonRPC.rpc.post("/action", { await PythonRPC.rpc.post("/action", {
action: "pause_seeding", action: "pause_seeding",
game_id: downloadKey, game_id: gameId,
}); });
} }
private static async getDownloadPayload(download: Download) { private static async getDownloadPayload(game: Game) {
const downloadId = levelKeys.game(download.shop, download.objectId); switch (game.downloader) {
switch (download.downloader) {
case Downloader.Gofile: { case Downloader.Gofile: {
const id = download.uri!.split("/").pop(); const id = game.uri!.split("/").pop();
const token = await GofileApi.authorize(); const token = await GofileApi.authorize();
const downloadLink = await GofileApi.getDownloadLink(id!); const downloadLink = await GofileApi.getDownloadLink(id!);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: downloadLink, url: downloadLink,
save_path: download.downloadPath!, save_path: game.downloadPath!,
header: `Cookie: accountToken=${token}`, header: `Cookie: accountToken=${token}`,
}; };
} }
case Downloader.PixelDrain: { case Downloader.PixelDrain: {
const id = download.uri!.split("/").pop(); const id = game.uri!.split("/").pop();
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: `https://pixeldrain.com/api/file/${id}?download`, url: `https://pixeldrain.com/api/file/${id}?download`,
save_path: download.downloadPath!, save_path: game.downloadPath!,
}; };
} }
case Downloader.Qiwi: { case Downloader.Qiwi: {
const downloadUrl = await QiwiApi.getDownloadUrl(download.uri!); const downloadUrl = await QiwiApi.getDownloadUrl(game.uri!);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: downloadUrl, url: downloadUrl,
save_path: download.downloadPath!, save_path: game.downloadPath!,
}; };
} }
case Downloader.Datanodes: { case Downloader.Datanodes: {
const downloadUrl = await DatanodesApi.getDownloadUrl(download.uri!); const downloadUrl = await DatanodesApi.getDownloadUrl(game.uri!);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: downloadUrl, url: downloadUrl,
save_path: download.downloadPath!, save_path: game.downloadPath!,
}; };
} }
case Downloader.Torrent: case Downloader.Torrent:
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: download.uri!, url: game.uri!,
save_path: download.downloadPath!, save_path: game.downloadPath!,
}; };
case Downloader.RealDebrid: { case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl( const downloadUrl = await RealDebridClient.getDownloadUrl(game.uri!);
download.uri!
);
return { return {
action: "start", action: "start",
game_id: downloadId, game_id: game.id,
url: downloadUrl!, url: downloadUrl!,
save_path: download.downloadPath!, save_path: game.downloadPath!,
}; };
} }
} }
} }
static async startDownload(download: Download) { static async startDownload(game: Game) {
const payload = await this.getDownloadPayload(download); const payload = await this.getDownloadPayload(game);
await PythonRPC.rpc.post("/action", payload); await PythonRPC.rpc.post("/action", payload);
this.downloadingGameId = levelKeys.game(download.shop, download.objectId);
this.downloadingGameId = game.id;
} }
} }

View File

@@ -1,9 +1,9 @@
export interface PauseDownloadPayload { export interface PauseDownloadPayload {
game_id: string; game_id: number;
} }
export interface CancelDownloadPayload { export interface CancelDownloadPayload {
game_id: string; game_id: number;
} }
export enum LibtorrentStatus { export enum LibtorrentStatus {
@@ -24,7 +24,7 @@ export interface LibtorrentPayload {
fileSize: number; fileSize: number;
folderName: string; folderName: string;
status: LibtorrentStatus; status: LibtorrentStatus;
gameId: string; gameId: number;
} }
export interface ProcessPayload { export interface ProcessPayload {

View File

@@ -1,3 +1,7 @@
import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import axios, { AxiosError, AxiosInstance } from "axios"; import axios, { AxiosError, AxiosInstance } from "axios";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import url from "url"; import url from "url";
@@ -9,10 +13,6 @@ import { omit } from "lodash-es";
import { appVersion } from "@main/constants"; import { appVersion } from "@main/constants";
import { getUserData } from "./user/get-user-data"; import { getUserData } from "./user/get-user-data";
import { isFuture, isToday } from "date-fns"; import { isFuture, isToday } from "date-fns";
import { db } from "@main/level";
import { levelKeys } from "@main/level/sublevels";
import type { Auth, User } from "@types";
import { Crypto } from "./crypto";
interface HydraApiOptions { interface HydraApiOptions {
needsAuth?: boolean; needsAuth?: boolean;
@@ -77,14 +77,14 @@ export class HydraApi {
tokenExpirationTimestamp tokenExpirationTimestamp
); );
db.put<string, Auth>( await userAuthRepository.upsert(
levelKeys.auth,
{ {
accessToken: Crypto.encrypt(accessToken), id: 1,
refreshToken: Crypto.encrypt(refreshToken), accessToken,
tokenExpirationTimestamp, tokenExpirationTimestamp,
refreshToken,
}, },
{ valueEncoding: "json" } ["id"]
); );
await getUserData().then((userDetails) => { await getUserData().then((userDetails) => {
@@ -186,23 +186,17 @@ export class HydraApi {
); );
} }
const result = await db.getMany<string>([levelKeys.auth, levelKeys.user], { const userAuth = await userAuthRepository.findOne({
valueEncoding: "json", where: { id: 1 },
relations: { subscription: true },
}); });
const userAuth = result.at(0) as Auth | undefined;
const user = result.at(1) as User | undefined;
this.userAuth = { this.userAuth = {
authToken: userAuth?.accessToken authToken: userAuth?.accessToken ?? "",
? Crypto.decrypt(userAuth.accessToken) refreshToken: userAuth?.refreshToken ?? "",
: "",
refreshToken: userAuth?.refreshToken
? Crypto.decrypt(userAuth.refreshToken)
: "",
expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0, expirationTimestamp: userAuth?.tokenExpirationTimestamp ?? 0,
subscription: user?.subscription subscription: userAuth?.subscription
? { expiresAt: user.subscription?.expiresAt } ? { expiresAt: userAuth.subscription?.expiresAt }
: null, : null,
}; };
@@ -222,11 +216,11 @@ export class HydraApi {
} }
public static async refreshToken() { public static async refreshToken() {
const response = await this.instance.post(`/auth/refresh`, { const { accessToken, expiresIn } = await this.instance
refreshToken: this.userAuth.refreshToken, .post<{ accessToken: string; expiresIn: number }>(`/auth/refresh`, {
}); refreshToken: this.userAuth.refreshToken,
})
const { accessToken, expiresIn } = response.data; .then((response) => response.data);
const tokenExpirationTimestamp = const tokenExpirationTimestamp =
Date.now() + Date.now() +
@@ -241,19 +235,14 @@ export class HydraApi {
this.userAuth.expirationTimestamp this.userAuth.expirationTimestamp
); );
await db userAuthRepository.upsert(
.get<string, Auth>(levelKeys.auth, { valueEncoding: "json" }) {
.then((auth) => { id: 1,
return db.put<string, Auth>( accessToken,
levelKeys.auth, tokenExpirationTimestamp,
{ },
...auth, ["id"]
accessToken: Crypto.encrypt(accessToken), );
tokenExpirationTimestamp,
},
{ valueEncoding: "json" }
);
});
return { accessToken, expiresIn }; return { accessToken, expiresIn };
} }
@@ -291,16 +280,8 @@ export class HydraApi {
subscription: null, subscription: null,
}; };
db.batch([ userAuthRepository.delete({ id: 1 });
{ userSubscriptionRepository.delete({ id: 1 });
type: "del",
key: levelKeys.auth,
},
{
type: "del",
key: levelKeys.user,
},
]);
this.sendSignOutEvent(); this.sendSignOutEvent();
} }

View File

@@ -1,4 +1,3 @@
export * from "./crypto";
export * from "./logger"; export * from "./logger";
export * from "./steam"; export * from "./steam";
export * from "./steam-250"; export * from "./steam-250";

View File

@@ -1,16 +1,5 @@
import { gamesSublevel, levelKeys } from "@main/level"; import { gameRepository } from "@main/repository";
export const clearGamesRemoteIds = async () => { export const clearGamesRemoteIds = () => {
const games = await gamesSublevel.values().all(); return gameRepository.update({}, { remoteId: null });
await gamesSublevel.batch(
games.map((game) => ({
type: "put",
key: levelKeys.game(game.shop, game.objectId),
value: {
...game,
remoteId: null,
},
}))
);
}; };

View File

@@ -1,21 +1,19 @@
import type { Game } from "@types"; import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { gamesSublevel, levelKeys } from "@main/level"; import { gameRepository } from "@main/repository";
export const createGame = async (game: Game) => { export const createGame = async (game: Game) => {
return HydraApi.post(`/profile/games`, { return HydraApi.post(`/profile/games`, {
objectId: game.objectId, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
}).then((response) => { }).then((response) => {
const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response; const { id: remoteId, playTimeInMilliseconds, lastTimePlayed } = response;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gameRepository.update(
...game, { objectID: game.objectID },
remoteId, { remoteId, playTimeInMilliseconds, lastTimePlayed }
playTimeInMilliseconds, );
lastTimePlayed,
});
}); });
}; };

View File

@@ -1,15 +1,17 @@
import { gameRepository } from "@main/repository";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { steamGamesWorker } from "@main/workers"; import { steamGamesWorker } from "@main/workers";
import { steamUrlBuilder } from "@shared"; import { steamUrlBuilder } from "@shared";
import { gamesSublevel, levelKeys } from "@main/level";
export const mergeWithRemoteGames = async () => { export const mergeWithRemoteGames = async () => {
return HydraApi.get("/profile/games") return HydraApi.get("/profile/games")
.then(async (response) => { .then(async (response) => {
for (const game of response) { for (const game of response) {
const localGame = await gamesSublevel.get( const localGame = await gameRepository.findOne({
levelKeys.game(game.shop, game.objectId) where: {
); objectID: game.objectId,
},
});
if (localGame) { if (localGame) {
const updatedLastTimePlayed = const updatedLastTimePlayed =
@@ -24,12 +26,17 @@ export const mergeWithRemoteGames = async () => {
? game.playTimeInMilliseconds ? game.playTimeInMilliseconds
: localGame.playTimeInMilliseconds; : localGame.playTimeInMilliseconds;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gameRepository.update(
...localGame, {
remoteId: game.id, objectID: game.objectId,
lastTimePlayed: updatedLastTimePlayed, shop: "steam",
playTimeInMilliseconds: updatedPlayTime, },
}); {
remoteId: game.id,
lastTimePlayed: updatedLastTimePlayed,
playTimeInMilliseconds: updatedPlayTime,
}
);
} else { } else {
const steamGame = await steamGamesWorker.run(Number(game.objectId), { const steamGame = await steamGamesWorker.run(Number(game.objectId), {
name: "getById", name: "getById",
@@ -40,15 +47,14 @@ export const mergeWithRemoteGames = async () => {
? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon) ? steamUrlBuilder.icon(game.objectId, steamGame.clientIcon)
: null; : null;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gameRepository.insert({
objectId: game.objectId, objectID: game.objectId,
title: steamGame?.name, title: steamGame?.name,
remoteId: game.id, remoteId: game.id,
shop: game.shop, shop: game.shop,
iconUrl, iconUrl,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,
playTimeInMilliseconds: game.playTimeInMilliseconds, playTimeInMilliseconds: game.playTimeInMilliseconds,
isDeleted: false,
}); });
} }
} }

View File

@@ -1,4 +1,4 @@
import type { Game } from "@types"; import { Game } from "@main/entity";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
export const updateGamePlaytime = async ( export const updateGamePlaytime = async (

View File

@@ -1,19 +1,15 @@
import { gameRepository } from "@main/repository";
import { chunk } from "lodash-es"; import { chunk } from "lodash-es";
import { IsNull } from "typeorm";
import { HydraApi } from "../hydra-api"; import { HydraApi } from "../hydra-api";
import { mergeWithRemoteGames } from "./merge-with-remote-games"; import { mergeWithRemoteGames } from "./merge-with-remote-games";
import { WindowManager } from "../window-manager"; import { WindowManager } from "../window-manager";
import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager"; import { AchievementWatcherManager } from "../achievements/achievement-watcher-manager";
import { gamesSublevel } from "@main/level";
export const uploadGamesBatch = async () => { export const uploadGamesBatch = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: { remoteId: IsNull(), isDeleted: false },
.all() });
.then((results) => {
return results.filter(
(game) => !game.isDeleted && game.remoteId === null
);
});
const gamesChunks = chunk(games, 200); const gamesChunks = chunk(games, 200);
@@ -22,7 +18,7 @@ export const uploadGamesBatch = async () => {
"/profile/games/batch", "/profile/games/batch",
chunk.map((game) => { chunk.map((game) => {
return { return {
objectId: game.objectId, objectId: game.objectID,
playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds), playTimeInMilliseconds: Math.trunc(game.playTimeInMilliseconds),
shop: game.shop, shop: game.shop,
lastTimePlayed: game.lastTimePlayed, lastTimePlayed: game.lastTimePlayed,

View File

@@ -1,6 +1,8 @@
import { Notification, app } from "electron"; import { Notification, app } from "electron";
import { t } from "i18next"; import { t } from "i18next";
import trayIcon from "@resources/tray-icon.png?asset"; import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { userPreferencesRepository } from "@main/repository";
import fs from "node:fs"; import fs from "node:fs";
import axios from "axios"; import axios from "axios";
import path from "node:path"; import path from "node:path";
@@ -9,9 +11,6 @@ import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset"; import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml"; import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger"; import { logger } from "../logger";
import type { Game, UserPreferences } from "@types";
import { levelKeys } from "@main/level";
import { db } from "@main/level";
async function downloadImage(url: string | null) { async function downloadImage(url: string | null) {
if (!url) return undefined; if (!url) return undefined;
@@ -39,12 +38,9 @@ async function downloadImage(url: string | null) {
} }
export const publishDownloadCompleteNotification = async (game: Game) => { export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await db.get<string, UserPreferences>( const userPreferences = await userPreferencesRepository.findOne({
levelKeys.userPreferences, where: { id: 1 },
{ });
valueEncoding: "json",
}
);
if (userPreferences?.downloadNotificationsEnabled) { if (userPreferences?.downloadNotificationsEnabled) {
new Notification({ new Notification({

View File

@@ -1,11 +1,12 @@
import { gameRepository } from "@main/repository";
import { WindowManager } from "./window-manager"; import { WindowManager } from "./window-manager";
import { createGame, updateGamePlaytime } from "./library-sync"; import { createGame, updateGamePlaytime } from "./library-sync";
import type { Game, GameRunning } from "@types"; import type { GameRunning } from "@types";
import { PythonRPC } from "./python-rpc"; import { PythonRPC } from "./python-rpc";
import { Game } from "@main/entity";
import axios from "axios"; import axios from "axios";
import { exec } from "child_process"; import { exec } from "child_process";
import { ProcessPayload } from "./download/types"; import { ProcessPayload } from "./download/types";
import { gamesSublevel, levelKeys } from "@main/level";
const commands = { const commands = {
findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`, findWineDir: `lsof -c wine 2>/dev/null | grep '/drive_c/windows$' | head -n 1 | awk '{for(i=9;i<=NF;i++) printf "%s ", $i; print ""}'`,
@@ -13,7 +14,7 @@ const commands = {
}; };
export const gamesPlaytime = new Map< export const gamesPlaytime = new Map<
string, number,
{ lastTick: number; firstTick: number; lastSyncTick: number } { lastTick: number; firstTick: number; lastSyncTick: number }
>(); >();
@@ -81,28 +82,23 @@ const findGamePathByProcess = (
const pathSet = processMap.get(executable.exe); const pathSet = processMap.get(executable.exe);
if (pathSet) { if (pathSet) {
pathSet.forEach(async (path) => { pathSet.forEach((path) => {
if (path.toLowerCase().endsWith(executable.name)) { if (path.toLowerCase().endsWith(executable.name)) {
const gameKey = levelKeys.game("steam", gameId); gameRepository.update(
const game = await gamesSublevel.get(gameKey); { objectID: gameId, shop: "steam" },
{ executablePath: path }
if (game) { );
gamesSublevel.put(gameKey, {
...game,
executablePath: path,
});
}
if (isLinuxPlatform) { if (isLinuxPlatform) {
exec(commands.findWineDir, (err, out) => { exec(commands.findWineDir, (err, out) => {
if (err) return; if (err) return;
if (game) { gameRepository.update(
gamesSublevel.put(gameKey, { { objectID: gameId, shop: "steam" },
...game, {
winePrefixPath: out.trim().replace("/drive_c/windows", ""), winePrefixPath: out.trim().replace("/drive_c/windows", ""),
}); }
} );
}); });
} }
} }
@@ -163,12 +159,11 @@ const getSystemProcessMap = async () => {
}; };
export const watchProcesses = async () => { export const watchProcesses = async () => {
const games = await gamesSublevel const games = await gameRepository.find({
.values() where: {
.all() isDeleted: false,
.then((results) => { },
return results.filter((game) => game.isDeleted === false); });
});
if (!games.length) return; if (!games.length) return;
@@ -177,8 +172,8 @@ export const watchProcesses = async () => {
for (const game of games) { for (const game of games) {
const executablePath = game.executablePath; const executablePath = game.executablePath;
if (!executablePath) { if (!executablePath) {
if (gameExecutables[game.objectId]) { if (gameExecutables[game.objectID]) {
findGamePathByProcess(processMap, game.objectId); findGamePathByProcess(processMap, game.objectID);
} }
continue; continue;
} }
@@ -190,12 +185,12 @@ export const watchProcesses = async () => {
const hasProcess = processMap.get(executable)?.has(executablePath); const hasProcess = processMap.get(executable)?.has(executablePath);
if (hasProcess) { if (hasProcess) {
if (gamesPlaytime.has(`${game.shop}-${game.objectId}`)) { if (gamesPlaytime.has(game.id)) {
onTickGame(game); onTickGame(game);
} else { } else {
onOpenGame(game); onOpenGame(game);
} }
} else if (gamesPlaytime.has(`${game.shop}-${game.objectId}`)) { } else if (gamesPlaytime.has(game.id)) {
onCloseGame(game); onCloseGame(game);
} }
} }
@@ -220,7 +215,7 @@ export const watchProcesses = async () => {
function onOpenGame(game: Game) { function onOpenGame(game: Game) {
const now = performance.now(); const now = performance.now();
gamesPlaytime.set(`${game.shop}-${game.objectId}`, { gamesPlaytime.set(game.id, {
lastTick: now, lastTick: now,
firstTick: now, firstTick: now,
lastSyncTick: now, lastSyncTick: now,
@@ -235,23 +230,16 @@ function onOpenGame(game: Game) {
function onTickGame(game: Game) { function onTickGame(game: Game) {
const now = performance.now(); const now = performance.now();
const gamePlaytime = gamesPlaytime.get(`${game.shop}-${game.objectId}`)!; const gamePlaytime = gamesPlaytime.get(game.id)!;
const delta = now - gamePlaytime.lastTick; const delta = now - gamePlaytime.lastTick;
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gameRepository.update(game.id, {
...game,
playTimeInMilliseconds: game.playTimeInMilliseconds + delta, playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(), lastTimePlayed: new Date(),
}); });
gamesSublevel.put(levelKeys.game(game.shop, game.objectId), { gamesPlaytime.set(game.id, {
...game,
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
lastTimePlayed: new Date(),
});
gamesPlaytime.set(`${game.shop}-${game.objectId}`, {
...gamePlaytime, ...gamePlaytime,
lastTick: now, lastTick: now,
}); });
@@ -267,7 +255,7 @@ function onTickGame(game: Game) {
gamePromise gamePromise
.then(() => { .then(() => {
gamesPlaytime.set(`${game.shop}-${game.objectId}`, { gamesPlaytime.set(game.id, {
...gamePlaytime, ...gamePlaytime,
lastSyncTick: now, lastSyncTick: now,
}); });
@@ -277,8 +265,8 @@ function onTickGame(game: Game) {
} }
const onCloseGame = (game: Game) => { const onCloseGame = (game: Game) => {
const gamePlaytime = gamesPlaytime.get(`${game.shop}-${game.objectId}`)!; const gamePlaytime = gamesPlaytime.get(game.id)!;
gamesPlaytime.delete(`${game.shop}-${game.objectId}`); gamesPlaytime.delete(game.id);
if (game.remoteId) { if (game.remoteId) {
updateGamePlaytime( updateGamePlaytime(

View File

@@ -10,7 +10,7 @@ import { Readable } from "node:stream";
import { app, dialog } from "electron"; import { app, dialog } from "electron";
interface GamePayload { interface GamePayload {
game_id: string; game_id: number;
url: string; url: string;
save_path: string; save_path: string;
} }

Some files were not shown because too many files have changed in this diff Show More