From f5189f3038cebf1734a8a78568a4bdab62f62aa2 Mon Sep 17 00:00:00 2001 From: mhdzumair Date: Thu, 25 Jan 2024 12:36:57 +0530 Subject: [PATCH] Add support for scraping movie streams from torrentio --- Pipfile | 1 + Pipfile.lock | 225 +++++++++++++++++++++- api/main.py | 24 ++- db/crud.py | 75 +++++--- db/database.py | 4 +- db/models.py | 12 +- resources/exceptions/no_matching_file.mp4 | Bin 0 -> 108177 bytes resources/manifest.json | 6 +- scrappers/helpers.py | 3 +- scrappers/torrentio.py | 162 ++++++++++++++++ streaming_providers/alldebrid/utils.py | 4 +- streaming_providers/debridlink/utils.py | 6 +- streaming_providers/offcloud/utils.py | 4 +- streaming_providers/pikpak/utils.py | 25 ++- streaming_providers/realdebrid/utils.py | 23 +-- streaming_providers/seedr/utils.py | 37 +++- streaming_providers/torbox/utils.py | 25 ++- utils/const.py | 2 + utils/parser.py | 24 ++- utils/torrent.py | 180 ++++++++++++----- 20 files changed, 694 insertions(+), 148 deletions(-) create mode 100644 resources/exceptions/no_matching_file.mp4 create mode 100644 scrappers/torrentio.py diff --git a/Pipfile b/Pipfile index b34d0c6..17bc1c8 100644 --- a/Pipfile +++ b/Pipfile @@ -31,6 +31,7 @@ playwright-stealth = "*" pikpakapi = {git = "git+https://github.com/mhdzumair/PikPakAPI.git"} parse-torrent-title = {git = "git+https://github.com/mhdzumair/parse-torrent-title"} diskcache = "*" +demagnetize = "*" [dev-packages] pysocks = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 44ae31b..9564aa5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7c6de99d98df9c4bedf75371ac04dcf1b6073812835279f87784d5f80fe931da" + "sha256": "b956a956a444c18919c0d7c01e8bb9cf50a3a54c987d0069f7b99d6d46afbe60" }, "pipfile-spec": 6, "requires": { @@ -41,6 +41,14 @@ "markers": "python_version >= '3.6'", "version": "==3.10.4" }, + "attrs": { + "hashes": [ + "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", + "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" + ], + "markers": "python_version >= '3.7'", + "version": "==23.2.0" + }, "beanie": { "hashes": [ "sha256:2328f0f745ea8b4626a818c79ed71021a5c4362fc2c0adc48a59e6b40c5ce54b", @@ -183,6 +191,14 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "click-loglevel": { + "hashes": [ + "sha256:897070fd4bf5b503edb5a1ecc0551448647901f4fac83a01c9f00aa28ad86d60", + "sha256:fcc98a136a96479b4768494df25017114ba1cb525dac0bed619209b3578fd4f3" + ], + "markers": "python_version >= '3.7'", + "version": "==0.5.0" + }, "cloudscraper": { "hashes": [ "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3", @@ -191,6 +207,23 @@ "index": "pypi", "version": "==1.2.71" }, + "colorlog": { + "hashes": [ + "sha256:4ed23b05a1154294ac99f511fabe8c1d6d4364ec1f7fc989c7fb515ccc29d375", + "sha256:fbb6fdf9d5685f2517f388fb29bb27d54e8654dd31f58bc2a3b217e967a95ca6" + ], + "markers": "python_version >= '3.6'", + "version": "==6.8.0" + }, + "demagnetize": { + "hashes": [ + "sha256:a3ac825bb8d949d3d1ea17fb67b48d41c22a22bf0eae6c70817042aa7db9f01c", + "sha256:ab023ff72d03cc2ed369e94aa1d6d513e5774735c3adee9bc66e274e8207af4e" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==0.3.0" + }, "diskcache": { "hashes": [ "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", @@ -218,6 +251,12 @@ "markers": "python_version >= '3.8'", "version": "==0.109.0" }, + "flatbencode": { + "hashes": [ + "sha256:77397d5d0108835404f6f7cad640d403c7025a11812afc7e1df4b0f92589ba77" + ], + "version": "==0.2.1" + }, "greenlet": { "hashes": [ "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", @@ -538,6 +577,86 @@ "markers": "python_version >= '3.7'", "version": "==3.3.2" }, + "multidict": { + "hashes": [ + "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9", + "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8", + "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03", + "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710", + "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161", + "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664", + "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569", + "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067", + "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313", + "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706", + "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2", + "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636", + "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49", + "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93", + "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603", + "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0", + "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60", + "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4", + "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e", + "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1", + "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60", + "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951", + "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc", + "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe", + "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95", + "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d", + "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8", + "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed", + "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2", + "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775", + "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87", + "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c", + "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2", + "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98", + "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3", + "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe", + "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78", + "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660", + "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176", + "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e", + "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988", + "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c", + "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c", + "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0", + "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449", + "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f", + "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde", + "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5", + "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d", + "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac", + "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a", + "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9", + "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca", + "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11", + "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35", + "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063", + "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b", + "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982", + "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258", + "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1", + "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52", + "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480", + "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7", + "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461", + "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d", + "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc", + "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779", + "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a", + "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547", + "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0", + "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171", + "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf", + "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d", + "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.4" + }, "parse-torrent-title": { "git": "git+https://github.com/mhdzumair/parse-torrent-title", "ref": "c3002b767de54ffdcf633e60a16567b49cc7e0e6" @@ -1225,6 +1344,14 @@ "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, + "torf": { + "hashes": [ + "sha256:e84ec73cd81cb2d56e36b9f4604806f16ee0c70aeafadfe2f31ca3266ca03a87", + "sha256:f846ef58b6b00bc3b6a5c25bf225c59149ee871738d87a20a8953f620b77671c" + ], + "markers": "python_version >= '3.7'", + "version": "==4.2.4" + }, "typing-extensions": { "hashes": [ "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", @@ -1461,6 +1588,102 @@ "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7" ], "version": "==12.0" + }, + "yarl": { + "hashes": [ + "sha256:008d3e808d03ef28542372d01057fd09168419cdc8f848efe2804f894ae03e51", + "sha256:03caa9507d3d3c83bca08650678e25364e1843b484f19986a527630ca376ecce", + "sha256:07574b007ee20e5c375a8fe4a0789fad26db905f9813be0f9fef5a68080de559", + "sha256:09efe4615ada057ba2d30df871d2f668af661e971dfeedf0c159927d48bbeff0", + "sha256:0d2454f0aef65ea81037759be5ca9947539667eecebca092733b2eb43c965a81", + "sha256:0e9d124c191d5b881060a9e5060627694c3bdd1fe24c5eecc8d5d7d0eb6faabc", + "sha256:18580f672e44ce1238b82f7fb87d727c4a131f3a9d33a5e0e82b793362bf18b4", + "sha256:1f23e4fe1e8794f74b6027d7cf19dc25f8b63af1483d91d595d4a07eca1fb26c", + "sha256:206a55215e6d05dbc6c98ce598a59e6fbd0c493e2de4ea6cc2f4934d5a18d130", + "sha256:23d32a2594cb5d565d358a92e151315d1b2268bc10f4610d098f96b147370136", + "sha256:26a1dc6285e03f3cc9e839a2da83bcbf31dcb0d004c72d0730e755b33466c30e", + "sha256:29e0f83f37610f173eb7e7b5562dd71467993495e568e708d99e9d1944f561ec", + "sha256:2b134fd795e2322b7684155b7855cc99409d10b2e408056db2b93b51a52accc7", + "sha256:2d47552b6e52c3319fede1b60b3de120fe83bde9b7bddad11a69fb0af7db32f1", + "sha256:357495293086c5b6d34ca9616a43d329317feab7917518bc97a08f9e55648455", + "sha256:35a2b9396879ce32754bd457d31a51ff0a9d426fd9e0e3c33394bf4b9036b099", + "sha256:3777ce5536d17989c91696db1d459574e9a9bd37660ea7ee4d3344579bb6f129", + "sha256:3986b6f41ad22988e53d5778f91855dc0399b043fc8946d4f2e68af22ee9ff10", + "sha256:44d8ffbb9c06e5a7f529f38f53eda23e50d1ed33c6c869e01481d3fafa6b8142", + "sha256:49a180c2e0743d5d6e0b4d1a9e5f633c62eca3f8a86ba5dd3c471060e352ca98", + "sha256:4aa9741085f635934f3a2583e16fcf62ba835719a8b2b28fb2917bb0537c1dfa", + "sha256:4b21516d181cd77ebd06ce160ef8cc2a5e9ad35fb1c5930882baff5ac865eee7", + "sha256:4b3c1ffe10069f655ea2d731808e76e0f452fc6c749bea04781daf18e6039525", + "sha256:4c7d56b293cc071e82532f70adcbd8b61909eec973ae9d2d1f9b233f3d943f2c", + "sha256:4e9035df8d0880b2f1c7f5031f33f69e071dfe72ee9310cfc76f7b605958ceb9", + "sha256:54525ae423d7b7a8ee81ba189f131054defdb122cde31ff17477951464c1691c", + "sha256:549d19c84c55d11687ddbd47eeb348a89df9cb30e1993f1b128f4685cd0ebbf8", + "sha256:54beabb809ffcacbd9d28ac57b0db46e42a6e341a030293fb3185c409e626b8b", + "sha256:566db86717cf8080b99b58b083b773a908ae40f06681e87e589a976faf8246bf", + "sha256:5a2e2433eb9344a163aced6a5f6c9222c0786e5a9e9cac2c89f0b28433f56e23", + "sha256:5aef935237d60a51a62b86249839b51345f47564208c6ee615ed2a40878dccdd", + "sha256:604f31d97fa493083ea21bd9b92c419012531c4e17ea6da0f65cacdcf5d0bd27", + "sha256:63b20738b5aac74e239622d2fe30df4fca4942a86e31bf47a81a0e94c14df94f", + "sha256:686a0c2f85f83463272ddffd4deb5e591c98aac1897d65e92319f729c320eece", + "sha256:6a962e04b8f91f8c4e5917e518d17958e3bdee71fd1d8b88cdce74dd0ebbf434", + "sha256:6ad6d10ed9b67a382b45f29ea028f92d25bc0bc1daf6c5b801b90b5aa70fb9ec", + "sha256:6f5cb257bc2ec58f437da2b37a8cd48f666db96d47b8a3115c29f316313654ff", + "sha256:6fe79f998a4052d79e1c30eeb7d6c1c1056ad33300f682465e1b4e9b5a188b78", + "sha256:7855426dfbddac81896b6e533ebefc0af2f132d4a47340cee6d22cac7190022d", + "sha256:7d5aaac37d19b2904bb9dfe12cdb08c8443e7ba7d2852894ad448d4b8f442863", + "sha256:801e9264d19643548651b9db361ce3287176671fb0117f96b5ac0ee1c3530d53", + "sha256:81eb57278deb6098a5b62e88ad8281b2ba09f2f1147c4767522353eaa6260b31", + "sha256:824d6c50492add5da9374875ce72db7a0733b29c2394890aef23d533106e2b15", + "sha256:8397a3817d7dcdd14bb266283cd1d6fc7264a48c186b986f32e86d86d35fbac5", + "sha256:848cd2a1df56ddbffeb375535fb62c9d1645dde33ca4d51341378b3f5954429b", + "sha256:84fc30f71689d7fc9168b92788abc977dc8cefa806909565fc2951d02f6b7d57", + "sha256:8619d6915b3b0b34420cf9b2bb6d81ef59d984cb0fde7544e9ece32b4b3043c3", + "sha256:8a854227cf581330ffa2c4824d96e52ee621dd571078a252c25e3a3b3d94a1b1", + "sha256:8be9e837ea9113676e5754b43b940b50cce76d9ed7d2461df1af39a8ee674d9f", + "sha256:928cecb0ef9d5a7946eb6ff58417ad2fe9375762382f1bf5c55e61645f2c43ad", + "sha256:957b4774373cf6f709359e5c8c4a0af9f6d7875db657adb0feaf8d6cb3c3964c", + "sha256:992f18e0ea248ee03b5a6e8b3b4738850ae7dbb172cc41c966462801cbf62cf7", + "sha256:9fc5fc1eeb029757349ad26bbc5880557389a03fa6ada41703db5e068881e5f2", + "sha256:a00862fb23195b6b8322f7d781b0dc1d82cb3bcac346d1e38689370cc1cc398b", + "sha256:a3a6ed1d525bfb91b3fc9b690c5a21bb52de28c018530ad85093cc488bee2dd2", + "sha256:a6327976c7c2f4ee6816eff196e25385ccc02cb81427952414a64811037bbc8b", + "sha256:a7409f968456111140c1c95301cadf071bd30a81cbd7ab829169fb9e3d72eae9", + "sha256:a825ec844298c791fd28ed14ed1bffc56a98d15b8c58a20e0e08c1f5f2bea1be", + "sha256:a8c1df72eb746f4136fe9a2e72b0c9dc1da1cbd23b5372f94b5820ff8ae30e0e", + "sha256:a9bd00dc3bc395a662900f33f74feb3e757429e545d831eef5bb280252631984", + "sha256:aa102d6d280a5455ad6a0f9e6d769989638718e938a6a0a2ff3f4a7ff8c62cc4", + "sha256:aaaea1e536f98754a6e5c56091baa1b6ce2f2700cc4a00b0d49eca8dea471074", + "sha256:ad4d7a90a92e528aadf4965d685c17dacff3df282db1121136c382dc0b6014d2", + "sha256:b8477c1ee4bd47c57d49621a062121c3023609f7a13b8a46953eb6c9716ca392", + "sha256:ba6f52cbc7809cd8d74604cce9c14868306ae4aa0282016b641c661f981a6e91", + "sha256:bac8d525a8dbc2a1507ec731d2867025d11ceadcb4dd421423a5d42c56818541", + "sha256:bef596fdaa8f26e3d66af846bbe77057237cb6e8efff8cd7cc8dff9a62278bbf", + "sha256:c0ec0ed476f77db9fb29bca17f0a8fcc7bc97ad4c6c1d8959c507decb22e8572", + "sha256:c38c9ddb6103ceae4e4498f9c08fac9b590c5c71b0370f98714768e22ac6fa66", + "sha256:c7224cab95645c7ab53791022ae77a4509472613e839dab722a72abe5a684575", + "sha256:c74018551e31269d56fab81a728f683667e7c28c04e807ba08f8c9e3bba32f14", + "sha256:ca06675212f94e7a610e85ca36948bb8fc023e458dd6c63ef71abfd482481aa5", + "sha256:d1d2532b340b692880261c15aee4dc94dd22ca5d61b9db9a8a361953d36410b1", + "sha256:d25039a474c4c72a5ad4b52495056f843a7ff07b632c1b92ea9043a3d9950f6e", + "sha256:d5ff2c858f5f6a42c2a8e751100f237c5e869cbde669a724f2062d4c4ef93551", + "sha256:d7d7f7de27b8944f1fee2c26a88b4dabc2409d2fea7a9ed3df79b67277644e17", + "sha256:d7eeb6d22331e2fd42fce928a81c697c9ee2d51400bd1a28803965883e13cead", + "sha256:d8a1c6c0be645c745a081c192e747c5de06e944a0d21245f4cf7c05e457c36e0", + "sha256:d8b889777de69897406c9fb0b76cdf2fd0f31267861ae7501d93003d55f54fbe", + "sha256:d9e09c9d74f4566e905a0b8fa668c58109f7624db96a2171f21747abc7524234", + "sha256:db8e58b9d79200c76956cefd14d5c90af54416ff5353c5bfd7cbe58818e26ef0", + "sha256:ddb2a5c08a4eaaba605340fdee8fc08e406c56617566d9643ad8bf6852778fc7", + "sha256:e0381b4ce23ff92f8170080c97678040fc5b08da85e9e292292aba67fdac6c34", + "sha256:e23a6d84d9d1738dbc6e38167776107e63307dfc8ad108e580548d1f2c587f42", + "sha256:e516dc8baf7b380e6c1c26792610230f37147bb754d6426462ab115a02944385", + "sha256:ea65804b5dc88dacd4a40279af0cdadcfe74b3e5b4c897aa0d81cf86927fee78", + "sha256:ec61d826d80fc293ed46c9dd26995921e3a82146feacd952ef0757236fc137be", + "sha256:ee04010f26d5102399bd17f8df8bc38dc7ccd7701dc77f4a68c5b8d733406958", + "sha256:f3bc6af6e2b8f92eced34ef6a96ffb248e863af20ef4fde9448cc8c9b858b749", + "sha256:f7d6b36dd2e029b6bcb8a13cf19664c7b8e19ab3a58e0fefbb5b8461447ed5ec" + ], + "markers": "python_version >= '3.7'", + "version": "==1.9.4" } }, "develop": { diff --git a/api/main.py b/api/main.py index f901d98..2f6d9b5 100644 --- a/api/main.py +++ b/api/main.py @@ -5,7 +5,15 @@ from typing import Literal from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from diskcache import Cache -from fastapi import FastAPI, Request, Response, Depends, HTTPException, status +from fastapi import ( + FastAPI, + Request, + Response, + Depends, + HTTPException, + status, + BackgroundTasks, +) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, FileResponse, StreamingResponse from fastapi.templating import Jinja2Templates @@ -58,8 +66,9 @@ cache = Cache( @app.on_event("startup") -async def init_db(): +async def init_server(): await database.init() + await torrent.init_best_trackers() @app.on_event("startup") @@ -325,6 +334,7 @@ async def get_streams( catalog_type: Literal["movie", "series", "tv"], video_id: str, response: Response, + background_tasks: BackgroundTasks, secret_str: str = None, season: int = None, episode: int = None, @@ -333,7 +343,9 @@ async def get_streams( response.headers.update(headers) if catalog_type == "movie": - fetched_streams = await crud.get_movie_streams(user_data, secret_str, video_id) + fetched_streams = await crud.get_movie_streams( + user_data, secret_str, video_id, background_tasks + ) elif catalog_type == "series": fetched_streams = await crud.get_series_streams( user_data, secret_str, video_id, season, episode @@ -370,7 +382,9 @@ async def streaming_provider_endpoint( if not stream: raise HTTPException(status_code=400, detail="Stream not found.") - magnet_link = torrent.convert_info_hash_to_magnet(info_hash, stream.announce_list) + magnet_link = await torrent.convert_info_hash_to_magnet( + info_hash, stream.announce_list + ) episode_data = stream.get_episode(season, episode) filename = episode_data.filename if episode_data else stream.filename @@ -382,7 +396,7 @@ async def streaming_provider_endpoint( ) elif user_data.streaming_provider.service == "realdebrid": video_url = get_direct_link_from_realdebrid( - info_hash, magnet_link, user_data, filename, 1, 0 + info_hash, magnet_link, user_data, filename, stream.file_index, 1, 0 ) elif user_data.streaming_provider.service == "alldebrid": video_url = get_direct_link_from_alldebrid( diff --git a/db/crud.py b/db/crud.py index b14b526..f5b132f 100644 --- a/db/crud.py +++ b/db/crud.py @@ -1,22 +1,25 @@ import hashlib import logging +from datetime import datetime, timedelta from typing import Optional from uuid import uuid4 from beanie import WriteRules from beanie.operators import In +from fastapi import BackgroundTasks from db import schemas, models from db.config import settings from db.models import ( MediaFusionMovieMetaData, MediaFusionSeriesMetaData, - Streams, + TorrentStreams, Season, Episode, MediaFusionTVMetaData, ) from db.schemas import Stream, MetaIdProjection +from scrappers.torrentio import scrap_streams_from_torrentio from utils.parser import ( parse_stream_data, get_catalogs, @@ -113,12 +116,28 @@ async def get_tv_data_by_id( return tv_data -async def get_movie_streams(user_data, secret_str: str, video_id: str) -> list[Stream]: - movie_data = await get_movie_data_by_id(video_id, True) - if not movie_data: - return [] +async def get_movie_streams( + user_data, secret_str: str, video_id: str, background_tasks: BackgroundTasks +) -> list[Stream]: + streams = ( + await TorrentStreams.find({"meta_id": video_id}) + .sort(-TorrentStreams.updated_at) + .to_list() + ) + last_torrentio_stream = next( + (stream for stream in streams if stream.source == "Torrentio"), None + ) - return await parse_stream_data(movie_data.streams, user_data, secret_str) + if video_id.startswith("tt") and "torrentio_streams" in user_data.selected_catalogs: + if ( + last_torrentio_stream is None + or last_torrentio_stream.updated_at < datetime.now() - timedelta(days=1) + ): + streams.extend( + await scrap_streams_from_torrentio(video_id, "movie", background_tasks) + ) + + return await parse_stream_data(streams, user_data, secret_str) async def get_series_streams( @@ -181,7 +200,7 @@ async def get_series_meta(meta_id: str): # Loop through streams to populate the videos list for stream in series_data.streams: - stream: Streams + stream: TorrentStreams if stream.season: # Ensure the stream has season data for episode in stream.season.episodes: stream_id = ( @@ -256,9 +275,7 @@ async def save_movie_metadata(metadata: dict): meta_id = existing_movie.id # Determine file index for the main movie file (largest file) - largest_file = max( - metadata["torrent_metadata"]["file_data"], key=lambda x: x["size"] - ) + largest_file = max(metadata["file_data"], key=lambda x: x["size"]) if "language" in metadata: languages = ( @@ -270,11 +287,11 @@ async def save_movie_metadata(metadata: dict): languages = [metadata["scrap_language"]] # Create the stream object - new_stream = Streams( - id=metadata["torrent_metadata"]["info_hash"], - torrent_name=metadata["torrent_metadata"]["torrent_name"], - announce_list=metadata["torrent_metadata"]["announce_list"], - size=metadata["torrent_metadata"]["total_size"], + new_stream = TorrentStreams( + id=metadata["info_hash"], + torrent_name=metadata["torrent_name"], + announce_list=metadata["announce_list"], + size=metadata["total_size"], filename=largest_file["filename"], file_index=largest_file["index"], languages=languages, @@ -286,6 +303,7 @@ async def save_movie_metadata(metadata: dict): source=metadata["source"], catalog=get_catalogs(metadata["catalog"], languages), created_at=metadata["created_at"], + meta_id=meta_id, ) if existing_movie: @@ -349,11 +367,7 @@ async def save_series_metadata(metadata: dict): logging.info("Added series %s", series.title) existing_stream = next( - ( - s - for s in series.streams - if s.id == metadata["torrent_metadata"]["info_hash"] - ), + (s for s in series.streams if s.id == metadata["info_hash"]), None, ) if existing_stream: @@ -361,8 +375,6 @@ async def save_series_metadata(metadata: dict): logging.info("Stream already exists for series %s", series.title) return - season_number = metadata["season"] - # Extract episodes episodes = [ Episode( @@ -371,7 +383,7 @@ async def save_series_metadata(metadata: dict): size=file["size"], file_index=file["index"], ) - for file in metadata["torrent_metadata"]["file_data"] + for file in metadata["file_data"] if file["episode"] ] @@ -386,11 +398,11 @@ async def save_series_metadata(metadata: dict): languages = [metadata["scrap_language"]] # Create the stream - stream = Streams( - id=metadata["torrent_metadata"]["info_hash"], - torrent_name=metadata["torrent_metadata"]["torrent_name"], - announce_list=metadata["torrent_metadata"]["announce_list"], - size=metadata["torrent_metadata"]["total_size"], + stream = TorrentStreams( + id=metadata["info_hash"], + torrent_name=metadata["torrent_name"], + announce_list=metadata["announce_list"], + size=metadata["total_size"], languages=languages, resolution=metadata.get("resolution"), codec=metadata.get("codec"), @@ -400,7 +412,8 @@ async def save_series_metadata(metadata: dict): source=metadata["source"], catalog=get_catalogs(metadata["catalog"], languages), created_at=metadata["created_at"], - season=Season(season_number=season_number, episodes=episodes), + season=Season(season_number=metadata["season"], episodes=episodes), + meta_id=series.id, ) # Add the stream to the series @@ -443,8 +456,8 @@ async def process_search_query(search_query: str, catalog_type: str) -> dict: return {"metas": metas} -async def get_stream_by_info_hash(info_hash: str) -> Streams | None: - stream = await Streams.get(info_hash) +async def get_stream_by_info_hash(info_hash: str) -> TorrentStreams | None: + stream = await TorrentStreams.get(info_hash) return stream diff --git a/db/database.py b/db/database.py index c0fe089..f33c2f1 100644 --- a/db/database.py +++ b/db/database.py @@ -5,7 +5,7 @@ from db.config import settings from db.models import ( MediaFusionSeriesMetaData, MediaFusionMovieMetaData, - Streams, + TorrentStreams, TVStreams, MediaFusionTVMetaData, ) @@ -21,7 +21,7 @@ async def init(): document_models=[ MediaFusionMovieMetaData, MediaFusionSeriesMetaData, - Streams, + TorrentStreams, TVStreams, MediaFusionTVMetaData, ], diff --git a/db/models.py b/db/models.py index 78d502a..205fe3e 100644 --- a/db/models.py +++ b/db/models.py @@ -1,10 +1,9 @@ -import hashlib from datetime import datetime from typing import Optional, Any import pymongo -from beanie import Document, Link -from pydantic import BaseModel, Field, computed_field +from beanie import Document, Link, BackLink +from pydantic import BaseModel, Field from pymongo import IndexModel, ASCENDING @@ -20,7 +19,7 @@ class Season(BaseModel): episodes: list[Episode] -class Streams(Document): +class TorrentStreams(Document): id: str torrent_name: str size: int @@ -32,6 +31,7 @@ class Streams(Document): source: str catalog: list[str] created_at: datetime = Field(default_factory=datetime.now) + updated_at: Optional[datetime] = None resolution: Optional[str] codec: Optional[str] quality: Optional[str] @@ -39,6 +39,7 @@ class Streams(Document): encoder: Optional[str] seeders: Optional[int] = None cached: Optional[bool] = None + meta_id: Optional[str] = None def get_episode(self, season_number: int, episode_number: int) -> Optional[Episode]: """ @@ -58,6 +59,7 @@ class TVStreams(Document): source: str behaviorHints: dict[str, Any] | None = None created_at: datetime = Field(default_factory=datetime.now) + meta_id: Optional[str] = None class MediaFusionMetaData(Document): @@ -67,7 +69,7 @@ class MediaFusionMetaData(Document): poster: str is_poster_working: Optional[bool] = True background: Optional[str] = None - streams: list[Link[Streams]] + streams: list[Link[TorrentStreams]] type: str class Settings: diff --git a/resources/exceptions/no_matching_file.mp4 b/resources/exceptions/no_matching_file.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..e25fb81b672de53e918ea4cad1ef187e917c0bd6 GIT binary patch literal 108177 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 KMjT&4js72XL$+K1 literal 0 HcmV?d00001 diff --git a/resources/manifest.json b/resources/manifest.json index 56f4839..d0d81a8 100644 --- a/resources/manifest.json +++ b/resources/manifest.json @@ -1,10 +1,10 @@ { "id": "mhdzumair.addons.mediafusion", - "version": "3.6.5", + "version": "3.6.6", "name": "Media Fusion", "contactEmail": "mhdzumair@gmail", - "description": "Watch Tamil, Hindi, Malayalam, Kannada, English, and dubbed movies, series & live TV with the Media Fusion add-on. Source: https://github.com/mhdzumair/MediaFusion", - "logo": "https://882b9915d0fe-mediafusion.baby-beamup.club/static/images/mediafusion_logo.png", + "description": "Universal Stremio Add-on for Movies, Series & Live TV. Source: https://github.com/mhdzumair/MediaFusion", + "logo": "https://mediafusion.fun/static/images/mediafusion_logo.png", "behaviorHints": { "configurable": true, "configurationRequired": false diff --git a/scrappers/helpers.py b/scrappers/helpers.py index 10e6d26..1bea473 100644 --- a/scrappers/helpers.py +++ b/scrappers/helpers.py @@ -75,8 +75,7 @@ async def download_and_save_torrent( logging.error(f"Info hash not found for {torrent_link}") return False - parsed_data = PTN.parse(torrent_metadata["torrent_name"]) - metadata.update({"torrent_metadata": torrent_metadata, **parsed_data}) + metadata.update(torrent_metadata) if not metadata.get("year"): logging.error(f"Year not found for {page_link}") diff --git a/scrappers/torrentio.py b/scrappers/torrentio.py new file mode 100644 index 0000000..e1ec51c --- /dev/null +++ b/scrappers/torrentio.py @@ -0,0 +1,162 @@ +import re +from datetime import datetime + +import PTN +import httpx +from fastapi import BackgroundTasks + +from db.models import TorrentStreams +from utils.parser import convert_size_to_bytes +from utils.torrent import info_hashes_to_torrent_metadata + + +async def fetch_stream_data(url: str) -> dict: + """Fetch stream data asynchronously.""" + async with httpx.AsyncClient() as client: + response = await client.get(url, timeout=10) + response.raise_for_status() # Will raise an exception for 4xx/5xx responses + return response.json() + + +async def scrap_streams_from_torrentio( + video_id: str, catalog_type: str, background_tasks: BackgroundTasks +) -> list[TorrentStreams]: + """ + Get streams by IMDb ID from torrentio stremio addon. + """ + url = f"https://torrentio.strem.fun/stream/{catalog_type}/{video_id}.json" + try: + stream_data = await fetch_stream_data(url) + return await store_and_parse_stream_data( + video_id, stream_data.get("streams", []), background_tasks + ) + except (httpx.HTTPError, httpx.TimeoutException): + return [] # Return empty list in case of HTTP errors or timeouts + + +def parse_stream_title(stream: dict) -> dict: + """Parse the stream title for metadata and other details.""" + torrent_name = stream["title"].splitlines()[0] + metadata = PTN.parse(torrent_name) + + # Initialize fields + details = stream["title"] + size = convert_size_to_bytes(extract_size_string(details)) + seeders = extract_seeders(details) + languages = extract_languages(metadata, details) + + return { + "torrent_name": torrent_name, + "size": size, + "seeders": seeders, + "languages": languages, + "metadata": metadata, + } + + +async def store_and_parse_stream_data( + video_id: str, stream_data: list, background_tasks: BackgroundTasks +) -> list[TorrentStreams]: + streams = [] + info_hashes = [] + for stream in stream_data: + parsed_data = parse_stream_title(stream) + if not parsed_data["seeders"]: + continue + + torrent_stream = await TorrentStreams.get(stream["infoHash"]) + + if torrent_stream: + # Update existing stream + torrent_stream.seeders = parsed_data["seeders"] + torrent_stream.updated_at = datetime.now() + await torrent_stream.save() + else: + # Create new stream + torrent_stream = TorrentStreams( + id=stream["infoHash"], + torrent_name=parsed_data["torrent_name"], + announce_list=[], + size=parsed_data["size"], + filename=None, + file_index=stream.get("fileIdx"), + languages=parsed_data["languages"], + resolution=parsed_data["metadata"].get("resolution"), + codec=parsed_data["metadata"].get("codec"), + quality=parsed_data["metadata"].get("quality"), + audio=parsed_data["metadata"].get("audio"), + encoder=parsed_data["metadata"].get("encoder"), + source="Torrentio", + catalog=["torrentio_streams"], + updated_at=datetime.now(), + seeders=parsed_data["seeders"], + meta_id=video_id, + ) + await torrent_stream.save() + + streams.append(torrent_stream) + if torrent_stream.filename is None: + info_hashes.append(stream["infoHash"]) + + background_tasks.add_task(update_torrent_streams_metadata, info_hashes) + + return streams + + +def extract_seeders(details: str) -> int: + """Extract seeders from details string.""" + seeders_match = re.search(r"👤 (\d+)", details) + return int(seeders_match.group(1)) if seeders_match else None + + +def extract_languages_from_title(title: str) -> list: + """Extract languages and country flags from the title string.""" + languages = [] + if "Multi Audio" in title or "Multi Language" in title: + languages.append("Multi Language") + elif "Dual Audio" in title or "Dual Language" in title: + languages.append("Dual Language") + + # Regex to match country flag emojis + flag_emojis = re.findall(r"[\U0001F1E6-\U0001F1FF]{2}", title) + if flag_emojis: + languages.extend(flag_emojis) + + return languages + + +def extract_languages(metadata: dict, title: str) -> list: + """Extract languages from metadata or title.""" + language = metadata.get("language") + if language: + if isinstance(language, str): + return [language] + elif isinstance(language, list): + return language + return extract_languages_from_title(title) + + +def extract_size_string(details: str) -> str: + """Extract the size string from the details.""" + size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", details, re.IGNORECASE) + return size_match.group(1) if size_match else "" + + +async def update_torrent_streams_metadata(info_hashes: list[str]): + """Update torrent streams metadata.""" + streams_metadata = await info_hashes_to_torrent_metadata(info_hashes, []) + + for stream_metadata in streams_metadata: + if not stream_metadata: + continue + + torrent_stream = await TorrentStreams.get(stream_metadata["info_hash"]) + if torrent_stream: + torrent_stream.torrent_name = stream_metadata["torrent_name"] + torrent_stream.size = stream_metadata["total_size"] + + largest_file = max(stream_metadata["file_data"], key=lambda x: x["size"]) + torrent_stream.filename = largest_file["filename"] + torrent_stream.file_index = largest_file["index"] + torrent_stream.updated_at = datetime.now() + await torrent_stream.save() diff --git a/streaming_providers/alldebrid/utils.py b/streaming_providers/alldebrid/utils.py index a388e92..a514571 100644 --- a/streaming_providers/alldebrid/utils.py +++ b/streaming_providers/alldebrid/utils.py @@ -1,6 +1,6 @@ from typing import Any -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.alldebrid.client import AllDebrid from streaming_providers.exceptions import ProviderException @@ -46,7 +46,7 @@ def get_direct_link_from_alldebrid( return response["data"]["link"] -def update_ad_cache_status(streams: list[Streams], user_data: UserData): +def update_ad_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of streams based on AllDebrid's instant availability.""" try: diff --git a/streaming_providers/debridlink/utils.py b/streaming_providers/debridlink/utils.py index 1c42faf..1730b22 100644 --- a/streaming_providers/debridlink/utils.py +++ b/streaming_providers/debridlink/utils.py @@ -2,7 +2,7 @@ import time import PTN -from db.models import Streams, Episode +from db.models import TorrentStreams, Episode from db.schemas import UserData from streaming_providers.debridlink.client import DebridLink from streaming_providers.exceptions import ProviderException @@ -12,7 +12,7 @@ def get_direct_link_from_debridlink( info_hash: str, magnet_link: str, user_data: UserData, - stream: Streams, + stream: TorrentStreams, episode_data: Episode = None, max_retries=5, retry_interval=5, @@ -128,7 +128,7 @@ def select_episode_file(torrent_files: list, episode: int, file_name_key: str) - ) -def update_dl_cache_status(streams: list[Streams], user_data: UserData): +def update_dl_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of streams based on DebridLink's instant availability.""" try: diff --git a/streaming_providers/offcloud/utils.py b/streaming_providers/offcloud/utils.py index 183c016..cf0ac21 100644 --- a/streaming_providers/offcloud/utils.py +++ b/streaming_providers/offcloud/utils.py @@ -1,4 +1,4 @@ -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.exceptions import ProviderException from streaming_providers.offcloud.client import OffCloud @@ -38,7 +38,7 @@ def get_direct_link_from_offcloud( return oc_client.create_download_link(request_id, torrent_info, filename) -def update_oc_cache_status(streams: list[Streams], user_data: UserData): +def update_oc_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of streams based on OffCloud's instant availability.""" try: diff --git a/streaming_providers/pikpak/utils.py b/streaming_providers/pikpak/utils.py index 8c65090..725da07 100644 --- a/streaming_providers/pikpak/utils.py +++ b/streaming_providers/pikpak/utils.py @@ -5,7 +5,7 @@ import httpx from pikpakapi import PikPakApi, PikpakException from thefuzz import fuzz -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.exceptions import ProviderException @@ -101,7 +101,18 @@ async def find_file_in_folder_tree( # Fuzzy matching as a fallback for file in files: file["fuzzy_ratio"] = fuzz.ratio(filename, file["name"]) - return sorted(files, key=lambda x: x["fuzzy_ratio"], reverse=True)[0] + selected_file = max(files, key=lambda x: x["fuzzy_ratio"]) + + # If the fuzzy ratio is less than 50, then select the largest file + if selected_file["fuzzy_ratio"] < 50: + selected_file = max(files, key=lambda x: x["size"]) + + if "video" not in selected_file["mime_type"]: + raise ProviderException( + "No matching file available for this torrent", "no_matching_file.mp4" + ) + + return selected_file async def initialize_pikpak(user_data: UserData): @@ -154,7 +165,7 @@ async def retrieve_or_download_file( filename: str, magnet_link: str, info_hash: str, - stream: Streams, + stream: TorrentStreams, max_retries: int, retry_interval: int, ): @@ -206,7 +217,7 @@ async def get_direct_link_from_pikpak( info_hash: str, magnet_link: str, user_data: UserData, - stream: Streams, + stream: TorrentStreams, filename: str, max_retries=5, retry_interval=5, @@ -220,7 +231,7 @@ async def get_direct_link_from_pikpak( selected_file = await retrieve_or_download_file( pikpak, folder_id, - filename, + filename or stream.torrent_name, magnet_link, info_hash, stream, @@ -232,7 +243,9 @@ async def get_direct_link_from_pikpak( return file_data["web_content_link"] -async def update_pikpak_cache_status(streams: list[Streams], user_data: UserData): +async def update_pikpak_cache_status( + streams: list[TorrentStreams], user_data: UserData +): """Updates the cache status of streams based on PikPak's instant availability.""" try: pikpak = await initialize_pikpak(user_data) diff --git a/streaming_providers/realdebrid/utils.py b/streaming_providers/realdebrid/utils.py index 278ea88..d422741 100644 --- a/streaming_providers/realdebrid/utils.py +++ b/streaming_providers/realdebrid/utils.py @@ -1,14 +1,14 @@ from typing import Any -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.exceptions import ProviderException from streaming_providers.realdebrid.client import RealDebrid -def create_download_link(rd_client, torrent_id, filename): +def create_download_link(rd_client, torrent_id, filename, file_index): torrent_info = rd_client.get_torrent_info(torrent_id) - file_index = select_file_index_from_torrent(torrent_info, filename) + file_index = select_file_index_from_torrent(torrent_info, filename, file_index) response = rd_client.create_download_link(torrent_info["links"][file_index]) return response.get("download") @@ -18,6 +18,7 @@ def get_direct_link_from_realdebrid( magnet_link: str, user_data: UserData, filename: str, + file_index: int, max_retries=5, retry_interval=5, ) -> str: @@ -28,12 +29,12 @@ def get_direct_link_from_realdebrid( if torrent_info: torrent_id = torrent_info.get("id") if torrent_info["status"] == "downloaded": - return create_download_link(rd_client, torrent_id, filename) + return create_download_link(rd_client, torrent_id, filename, file_index) elif torrent_info["status"] == "downloading": rd_client.wait_for_status( torrent_id, "downloaded", max_retries, retry_interval ) - return create_download_link(rd_client, torrent_id, filename) + return create_download_link(rd_client, torrent_id, filename, file_index) elif torrent_info["status"] == "magnet_error": rd_client.delete_torrent(torrent_id) raise ProviderException( @@ -57,10 +58,10 @@ def get_direct_link_from_realdebrid( # Wait for download completion and get the direct link rd_client.wait_for_status(torrent_id, "downloaded", max_retries, retry_interval) - return create_download_link(rd_client, torrent_id, filename) + return create_download_link(rd_client, torrent_id, filename, file_index) -def update_rd_cache_status(streams: list[Streams], user_data: UserData): +def update_rd_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of streams based on RealDebrid's instant availability.""" try: @@ -75,15 +76,15 @@ def update_rd_cache_status(streams: list[Streams], user_data: UserData): pass -def select_file_index_from_torrent(torrent_info: dict[str, Any], filename: str) -> int: +def select_file_index_from_torrent( + torrent_info: dict[str, Any], filename: str, file_index: int +) -> int: """Select the file index from the torrent info.""" selected_files = [file for file in torrent_info["files"] if file["selected"] == 1] for index, file in enumerate(selected_files): if file["path"] == "/" + filename: return index - raise ProviderException( - "No matching file available for this torrent", "api_error.mp4" - ) + return file_index def fetch_downloaded_info_hashes_from_rd(user_data: UserData) -> list[str]: diff --git a/streaming_providers/seedr/utils.py b/streaming_providers/seedr/utils.py index 0abe50b..4fda8ea 100644 --- a/streaming_providers/seedr/utils.py +++ b/streaming_providers/seedr/utils.py @@ -5,7 +5,7 @@ from datetime import datetime from seedrcc import Seedr from thefuzz import fuzz -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.exceptions import ProviderException @@ -91,13 +91,18 @@ def get_file_details_from_folder(seedr, folder_id: int, filename: str): # If the file is not found with exact match, try to find it by fuzzy ratio for file in folder_content["files"]: - # rename file to remove any special characters - file["name"] = seedr_clean_name(file["name"]) - seedr.renameFile(file["folder_file_id"], file["name"]) file["fuzzy_ratio"] = fuzz.ratio(filename, file["name"]) - return sorted( - folder_content["files"], key=lambda x: x["fuzzy_ratio"], reverse=True - )[0] + selected_file = max(folder_content["files"], key=lambda x: x["fuzzy_ratio"]) + + # If the fuzzy ratio is less than 50, then select the largest file + if selected_file["fuzzy_ratio"] < 50: + selected_file = max(folder_content["files"], key=lambda x: x["size"]) + + if selected_file["play_video"] is False: + raise ProviderException( + "No matching file available for this torrent", "no_matching_file.mp4" + ) + return selected_file def seedr_clean_name(name: str, replace: str = "") -> str: @@ -118,11 +123,20 @@ def get_seedr_client(user_data: UserData) -> Seedr: return seedr +def rename_seedr_files(seedr, folder_id): + """rename filenames to fix stremio android client bug.""" + folder_content = seedr.listContents(folder_id) + for file in folder_content["files"]: + # rename file to remove any special characters + if file["name"] != seedr_clean_name(file["name"]): + seedr.renameFile(file["folder_file_id"], seedr_clean_name(file["name"])) + + async def get_direct_link_from_seedr( info_hash: str, magnet_link: str, user_data: UserData, - stream: Streams, + stream: TorrentStreams, filename: str, max_retries=5, retry_interval=5, @@ -144,10 +158,13 @@ async def get_direct_link_from_seedr( folder = check_folder_status(seedr, info_hash) folder_id = folder["id"] + # rename filenames to fix stremio android client bug. + rename_seedr_files(seedr, folder_id) + selected_file = get_file_details_from_folder( seedr, folder_id, - seedr_clean_name(filename), + seedr_clean_name(filename or stream.torrent_name), ) video_link = seedr.fetchFile(selected_file["folder_file_id"])["url"] @@ -183,7 +200,7 @@ def free_up_space(seedr, required_space): available_space += folder["size"] -def update_seedr_cache_status(streams: list[Streams], user_data: UserData): +def update_seedr_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of the streams based on the user's Seedr account.""" try: seedr = get_seedr_client(user_data) diff --git a/streaming_providers/torbox/utils.py b/streaming_providers/torbox/utils.py index 32ce888..48202f4 100644 --- a/streaming_providers/torbox/utils.py +++ b/streaming_providers/torbox/utils.py @@ -1,27 +1,32 @@ from typing import Any -from db.models import Streams +from db.models import TorrentStreams from db.schemas import UserData from streaming_providers.exceptions import ProviderException from streaming_providers.torbox.client import Torbox def get_direct_link_from_torbox( - info_hash: str, - magnet_link: str, - user_data: UserData, - filename: str, - max_retries=5, - retry_interval=5, + info_hash: str, + magnet_link: str, + user_data: UserData, + filename: str, + max_retries=5, + retry_interval=5, ) -> str: torbox_client = Torbox(token=user_data.streaming_provider.token) # Check if the torrent already exists torrent_info = torbox_client.get_available_torrent(info_hash) if torrent_info: - if torrent_info["download_finished"] is True and torrent_info["download_present"] is True: + if ( + torrent_info["download_finished"] is True + and torrent_info["download_present"] is True + ): file_id = select_file_id_from_torrent(torrent_info, filename) - response = torbox_client.create_download_link(torrent_info.get("id"), file_id) + response = torbox_client.create_download_link( + torrent_info.get("id"), file_id + ) return response["data"] else: # If torrent doesn't exist, add it @@ -34,7 +39,7 @@ def get_direct_link_from_torbox( ) -def update_torbox_cache_status(streams: list[Streams], user_data: UserData): +def update_torbox_cache_status(streams: list[TorrentStreams], user_data: UserData): """Updates the cache status of streams based on Torbox's instant availability.""" try: diff --git a/utils/const.py b/utils/const.py index 5ec7960..2d4a6e1 100644 --- a/utils/const.py +++ b/utils/const.py @@ -31,6 +31,7 @@ CATALOG_ID_DATA = [ "english_series", "live_tv", "mediafusion_search_tv", + "torrentio_streams", ] CATALOG_NAME_DATA = [ @@ -66,6 +67,7 @@ CATALOG_NAME_DATA = [ "English Series", "Live TV", "MediaFusion Search TV", + "Torrentio Streams", ] RESOLUTIONS = [ diff --git a/utils/parser.py b/utils/parser.py index 9c3adc2..e905130 100644 --- a/utils/parser.py +++ b/utils/parser.py @@ -1,11 +1,12 @@ import asyncio import math +import re import requests from imdb import Cinemagoer from db.config import settings -from db.models import Streams, MediaFusionTVMetaData +from db.models import TorrentStreams, MediaFusionTVMetaData from db.schemas import Stream, UserData from streaming_providers.alldebrid.utils import ( update_ad_cache_status, @@ -40,8 +41,8 @@ ia = Cinemagoer() async def filter_and_sort_streams( - streams: list[Streams], user_data: UserData -) -> list[Streams]: + streams: list[TorrentStreams], user_data: UserData +) -> list[TorrentStreams]: # Filter streams by selected catalogs and resolutions filtered_streams = [ stream @@ -81,7 +82,7 @@ async def filter_and_sort_streams( async def parse_stream_data( - streams: list[Streams], + streams: list[TorrentStreams], user_data: UserData, secret_str: str, season: int = None, @@ -115,11 +116,14 @@ async def parse_stream_data( else: streaming_provider = "Torrent ⏳" + seeders = f"👤 {stream_data.seeders}" if stream_data.seeders else None + description_parts = [ quality_detail, convert_bytes_to_readable( episode_data.size if episode_data else stream_data.size ), + seeders, " + ".join(stream_data.languages), stream_data.source, ] @@ -159,7 +163,17 @@ def convert_bytes_to_readable(size_bytes: int) -> str: i = int(math.floor(math.log(size_bytes, 1024))) p = math.pow(1024, i) s = round(size_bytes / p, 2) - return f"{s} {size_name[i]}" + return f"💾 {s} {size_name[i]}" + + +def convert_size_to_bytes(size_str: str) -> int: + """Convert size string to bytes.""" + match = re.match(r"(\d+(?:\.\d+)?)\s*(GB|MB)", size_str, re.IGNORECASE) + if match: + size, unit = match.groups() + size = float(size) + return int(size * 1024**3) if "GB" in unit.upper() else int(size * 1024**2) + return 0 def get_catalogs(catalog: str, languages: list[str]) -> list[str]: diff --git a/utils/torrent.py b/utils/torrent.py index 0b1212c..84716d1 100644 --- a/utils/torrent.py +++ b/utils/torrent.py @@ -1,88 +1,168 @@ -import hashlib +import io import logging +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Awaitable, Iterable, AsyncIterator, Optional, TypeVar from urllib.parse import quote import PTN -import bencodepy +import anyio +import httpx +from anyio import ( + create_task_group, + create_memory_object_stream, + CapacityLimiter, +) +from anyio.streams.memory import MemoryObjectSendStream +from demagnetize.core import Demagnetizer +from torf import Magnet, Torrent TRACKERS = [ + "http://tracker3.itzmx.com:8080/announce", + "udp://9.rarbg.me:2710/announce", + "udp://9.rarbg.to:2710/announce", + "udp://exodus.desync.com:6969/announce", + "udp://ipv4.tracker.harry.lu:80/announce", + "udp://tracker.coppersurfer.tk:6969/announce", + "udp://tracker.internetwarriors.net:1337/announce", + "udp://tracker.leechers-paradise.org:6969/announce", "udp://tracker.openbittorrent.com:80/announce", "udp://tracker.opentrackr.org:1337/announce", "udp://tracker.pomf.se:80/announce", - "udp://exodus.desync.com:6969/announce", - "udp://tracker.internetwarriors.net:1337/announce", "udp://tracker.tiny-vps.com:6969/announce", - "udp://tracker.coppersurfer.tk:6969/announce", - "udp://tracker.leechers-paradise.org:6969/announce", - "udp://9.rarbg.to:2710/announce", - "udp://9.rarbg.me:2710/announce", - "http://tracker3.itzmx.com:8080/announce", - "udp://ipv4.tracker.harry.lu:80/announce", ] -def extract_torrent_metadata(content) -> dict: +def response_content_to_torrent(content) -> Torrent | None: try: - torrent_data = bencodepy.decode(content) + return Torrent.read_stream(io.BytesIO(content)) + except Exception as e: + logging.error(f"Error occurred: {e}") + return - info = torrent_data[b"info"] - info_encoded = bencodepy.encode(info) - m = hashlib.sha1() - m.update(info_encoded) - info_hash = m.hexdigest() +def extract_torrent_metadata(content: Torrent | bytes) -> dict: + if isinstance(content, bytes): + torrent = response_content_to_torrent(content) + if not torrent: + return {} + else: + torrent = content - # Extract file size, file list, and announce list - if b"files" in info: - total_size = sum(file[b"length"] for file in info[b"files"]) - file_data = [] - for idx, file in enumerate(info[b"files"]): - filename = file[b"path"][0].decode() - parsed_data = PTN.parse(filename) - file_data.append( - { - "filename": filename, - "size": file[b"length"], - "index": idx, - "season": parsed_data.get("season"), - "episode": parsed_data.get("episode"), - } - ) - else: - total_size = info[b"length"] - filename = info[b"name"].decode() + try: + info_hash = torrent.infohash + total_size = torrent.size + file_data = [] + + for idx, file in enumerate( + torrent.files if torrent.mode == "multifile" else [torrent.name] + ): + filename = str(file).split("/")[-1] parsed_data = PTN.parse(filename) - file_data = [ + file_data.append( { "filename": filename, - "size": total_size, - "index": 0, + "size": file.size if torrent.mode == "multifile" else total_size, + "index": idx, "season": parsed_data.get("season"), "episode": parsed_data.get("episode"), } - ] - - announce_list = [ - tracker[0].decode() for tracker in torrent_data.get(b"announce-list", []) - ] - torrent_name = info.get(b"name", b"").decode() or file_data[0]["filename"] + ) return { + **PTN.parse(torrent.name), "info_hash": info_hash, - "announce_list": announce_list, + "announce_list": sorted(list(torrent.trackers.flat)), "total_size": total_size, "file_data": file_data, - "torrent_name": torrent_name, + "torrent_name": torrent.name, } - except Exception as e: logging.error(f"Error occurred: {e}") return {} -def convert_info_hash_to_magnet(info_hash: str, trackers: list[str]) -> str: +async def convert_info_hash_to_magnet(info_hash: str, trackers: list[str]) -> str: magnet_link = f"magnet:?xt=urn:btih:{info_hash}" - for tracker in trackers or TRACKERS: + for tracker in set(trackers) or TRACKERS: encoded_tracker = quote(tracker, safe="") magnet_link += f"&tr={encoded_tracker}" return magnet_link + + +T = TypeVar("T") + + +@asynccontextmanager +async def acollect( + coros: Iterable[Awaitable[T]], + limit: Optional[CapacityLimiter] = None, + timeout: int = 30, +) -> AsyncIterator[AsyncIterator[T]]: + async with create_task_group() as tg: + sender, receiver = create_memory_object_stream[T]() + async with sender: + for c in coros: + tg.start_soon(_acollect_pipe, c, limit, sender.clone(), timeout) + async with receiver: + yield receiver + + +async def _acollect_pipe( + coro: Awaitable[T], + limit: Optional[CapacityLimiter], + sender: MemoryObjectSendStream[T], + timeout: int, +) -> None: + async with AsyncExitStack() as stack: + if limit is not None: + await stack.enter_async_context(limit) + await stack.enter_async_context(sender) + try: + with anyio.fail_after(timeout): + value = await coro + await sender.send(value) + except Exception as e: + # Send the exception instead of the value + await sender.send(e) + + +async def info_hashes_to_torrent_metadata( + info_hashes: list[str], trackers: list[str] +) -> list[dict]: + magnets = [ + Magnet(xt=info_hash, tr=trackers or TRACKERS) for info_hash in info_hashes + ] + torrents_data = [] + + demagnetizer = Demagnetizer() + async with acollect( + [demagnetizer.demagnetize(magnet) for magnet in magnets], timeout=60 + ) as async_iterator: + async for torrent_result in async_iterator: + try: + if isinstance(torrent_result, Exception): + pass + else: + torrents_data.append(extract_torrent_metadata(torrent_result)) + except Exception as e: + logging.error(f"Error processing torrent: {e}") + + return torrents_data + + +async def init_best_trackers(): + # get the best trackers from https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt + global TRACKERS + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_best.txt" + ) + if response.status_code == 200: + trackers = [tracker for tracker in response.text.split("\n") if tracker] + TRACKERS.extend(trackers) + TRACKERS = list(set(TRACKERS)) + + logging.info(f"Loaded {len(trackers)} trackers. Total: {len(TRACKERS)}") + else: + logging.error(f"Failed to load trackers: {response.status_code}")