2024. április 28., vasárnap

Gyorskeresés

Így készítettem kezdetleges grpc-web klienst Pythonra

Írta: | Kulcsszavak: grpc . web . grpc-web . protobuf . python . kodi . addon . dev . fejlesztés . library . protokoll

[ ÚJ BEJEGYZÉS ]

Nemrég jó nagy fába vágtam a fejszém azzal, hogy belevágtam a StreamShark projektbe:


[+] Katt, hogy mozogjon (~7 MB adatforgalom)

Sosem terveztem, hogy fog támogatni mást, mint a fájl alapú/HTTP alapú webes forrásokat, de rögtön az első publikus verziók megjelenése után kaptam kéréseket, hogy mikor fog torrentet támogatni a kiegészítő. Nagy híve vagyok a P2P protokolloknak, mivel a nem profitközpontú közösségekben jól bizonyított eddig. Sokkal olcsóbban lehet fájlokat megosztani és még a környezetet is jobban védjük vele, mintha az AWS-nél lenne CDN-eken ugyanaz a tartalom, hiszen más felhasználók gépéről töltődik le a tartalom. Ezen gépek többsége pedig egyébként is menne. És nyilván nem csak illegális fájlmegosztásra lehet használni a torrentet és társait. Illetve létezik egy csodálatos Kodi kiegészítő, az Elementum, ami képes a torrenteket közel valós időben streamelni, így technikailag sincs akadálya a kivitelezésnek.

Hamar hozzá is adtam a torrent támogatást kísérleti jelleggel a kiegészítőhöz, és készítettem egy forrást egy privát trackerhez mintának, hogy más fejlesztők is lássák, hogyan lehet torrentes forrást készíteni a kedvenc oldalukhoz. Abban bíztam, hogyha valakinek kell egy bizonyos oldal, akkor megpróbálja hozzáadni magának és esetlegesen elküldi a kész forrást, hogy abból az egész közösség profitálhasson. Sajnos ilyen eddig még nem történt, viszont pozitív visszajelzések jöttek, és egy igény is, hogy legyen már az XY publikus tracker is támogatva. Először nem akartam vele foglalkozni, mivel nem találtam legális tartalmat az oldalon, amin tesztelhetnék, és az ISP-m előszeretettel monitorozza a jogvédett torrentek letöltését. Emiatt számomra lehetetlen volt a tesztelés és nagyon érdekem sem fűződött hozzá, hogy működjön. Aztán annyian kérték, hogy megpróbáltam "vakon" implementálni a forrást, mindenfajta tesztelés nélkül.

Viszont kaptam egy érdekes linket egy Kodi fejlesztővel való beszélgetés során, aki megmutatta nekem a webtor-t: [link]. A vicc az, hogy teljesen más miatt került szóba de gyakorlatilag pont ezt kerestem! :) Egy orosz srác csinálja és az oldal lényege, hogy adsz az oldalnak egy magnet linket/torrentet, az felkerül a srác felhőjébe és a kliens már kapja is a streamet, mintha bármilyen CDN-ről streamelt fájl lenne. Nem tudom hogy éri meg a srácnak ezt fenntartani, de van egy pár támogatója, meg reklám az oldalon. Meg gondolom inkább hobbi ez részéről, mert egész komoly a megvalósítás. A Kodi fejlesztő szerint pont emiatt nem készített hozzá senki addont még Kodira, mert senkinek nem sikerült megvalósítani az oldal által használt protokollt. Az oldal egyébként open-source, go-ban van írva a backend nagy része, és kubernetes alapokon fut.

Mivel nekem pont ez a projekt kellett, gondoltam csak nem lehet olyan nehéz implementálni Kodi alapokon, megoldom simán. Kicsit játszottam az API-val és kb azonnal megértettem, hogy miért nem foglalkozott vele eddig senki:

Láthatóan bináris adatokat küld a kliens és bináris adattal válaszol a szerver is. Illetve grpc-web content typeot kapunk vissza a szervertől:

Szerencsére gRPC-vel kapcsolatban egészen frissek az élményeim a 24 órás programozói verseny okán, de grpc-web-bel még soha nem találkoztam. Elkezdtem hát a kutatómunkát és hamar ráeszméltem, hogy a webtor az improbable-eng féle grpc-web klienst használja. Nincs is nagyon feature complete library más nyelvre, mint a JS/TS, nekem viszont Pythonra kellett volna hasonló.

Protobufnál általában az az első lépés, hogy nekiállok proto fájlokat keresni. Ezek írják le, hogy milyen típusok és adatok küldhetőek/kérhetőek le a szervertől. Viszont ezeket általában nem csomagolják a kész alkalmazásba, hanem minden programnyelvhez van egy tool, amivel az adott nyelv megfelelő formátumában generál nekünk bindingokat és logikát a library. JS/TS esetében ez annyit jelent, hogy egy .js/.ts fájl jön létre, amit be tudunk importálni és metódushívásokon/objektum létrehozáson keresztül tudunk üzeneteket létrehozni és manipulálni. Ezt viszont a Python nem eszi meg, szóval végig kell szaladni a kódon, értelmezni, és kézzel visszaírni proto fájllá. Automatizált toolt sajnos nem ismerek erre a célra.

Szóval így is tettem, mert sokáig nem esett le, hogy a projekt, a proto fájlokkal együtt open source. Szóval megkerestem a generált bindingokat:

És ilyesmi proto fájlokkal álltam elő:

syntax = "proto3";

package store;

// Messages
message PushReply {
string infohash = 1;
}

message PushRequest {
bytes torrent = 1;
int32 expire = 2;
}

message PullRequest {
string infohash = 1;
}

message PullReply {
bytes torrent = 1;
}

message CheckRequest {
string infohash = 1;
}

message CheckReply {
bool exists = 1;
}

message TouchReply {}

message TouchRequest {
string infohash = 1;
int32 expire = 2;
}

service TorrentStore {
rpc Push (PushRequest) returns (PushReply);
rpc Pull (PullRequest) returns (PullReply);
rpc Touch (TouchRequest) returns (TouchReply);
}

Mint utólag kiderült, annyira nem is voltam messze a valóságtól: [link]. :)

Miután a többi proto-t is sikeresen leportoltam, elkezdtem kutatni, hogy nem-e létezik-e valami grpc-web kliens Python alapokra. Egy gyors keresés után erre bukkantam: [link]. Aktuálisan fejleszett projektnek tűnt, viszont megláttam, hogy csak insecure_web_channel-t támogat, illetve elég macerás belenyúlni az internaljaiba, mert elég szeparált a library kívülről elérhető része a belső logikától, ezt a libet elengedtem.

Találtam viszont egy másikat, ami meg már két éve nincs fejlesztve: [link]

A következő kóddal álltam elő:

from pyease_grpc import RpcSession, RpcUri

server_address = "https://api.nocturnal-narwhal.buzz"
api_key = "..."
user_id = "..."
token = "..."
infohash = "..."

session = RpcSession.from_file("proto/torrent-web-seeder.proto")
response = session.request(
RpcUri(server_address, "torrentwebseeder", "TorrentWebSeeder", "Files"),
{},
headers={
"api-key": api_key,
"user-id": user_id,
"token": token,
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
)

print(response.single)

És nem működött. Egy 500-as hibát kaptam a szervertől és semmi többet. Kis kutatás után az alábbi probléma keltette fel a figyelmemet: [link]

Itt a kód bizony így rakja össze a linket:

return f"/{self.package}.{self.service}/{self.method}"

És úgy néz ki, hogy ez is a standard megoldás. Igen ám, de én a böngészőben a következőt láttam: https://api.nocturnal-narwhal.buzz/store/TorrentStore/Touch, nem pedig https://api.nocturnal-narwhal.buzz/store.TorrentStore/Touch-t.

Ennyit arról, hogy a Protobuf és társai egységes szerializációs platformok. :DDD Nincs sok értelme, ha valami lib/developer úgy dönt, hogy máshogy implementálja...

A lényeg, hogy az előbbi kódot kiegészítettem egy csúnya, átmeneti monkey-patchinggel:

RpcUri.build = (
lambda self: f"https://abra--2d8fde14.api.nocturnal-narwhal.buzz/{infohash}/TorrentWebSeeder/Files"
)

És láss csodát, visszakaptam az adott infohash-ű torrentek fájljait. Hasonló módon megcsináltam a Touch hívást, ami elég beszédes. Látszólag ez az első hívás, amit a kliens lekér. Elküldi a letölteni kívánt torrent infohash-jét, majd ha a touch üres választ ad, akkor azt feltételezi, hogy a torrent már létezik a távoli szerver torrent storejában, ha pedig StatusCode.NOT_FOUND-ot kapunk a trailer válaszban, akkor kliensünknek fel kell töltenie a torrentet először a Push endpoint használatával, vagy ha nincs torrentünk, csak magnetünk, akkor rendelkezésre áll egy Magnet2Torrent endpoint, ami szépen összeszedi az adott torrentet a swarmból magnet link alapján és visszaadja a torrentet válaszul, amit utána Pusholhatunk.

És itt jön a dologban a slusszpoén. Van egy Pull endpoint, ami egy infohash-t vár és le tudunk tölteni vele bármilyen torrentet infohash alapján, ami már felkerült az oldal tárolójába. Régebben minden torrent 30 napra volt tárolva a webtor szerverein, de a fejlesztő egy ideje kitolta korlátlanra a tárolt torrentek lejárati idejét. Magyarul, ha valaki egy privát trackerről tölt fel torrentet, akkor utána bárki más le tudja tölteni az általa feltöltött torrentet, ellophatja az adott user passkey-ét, illetve a jövőben az összes user nézheii ugyanazt a torrentet, és mind az első feltöltő nevében fog leechelni. Ez nem valami ideális... Nem is értem, hogy minek kell ez a Pull endpoint, amikor cachelhetné kliens oldalon az oldal az éppen a user által feltöltött torrentet/magnet linket és elég lenne azt kiszolgálnia, ha akar egy torrent letöltése gombot az oldalra.

Kíváncsiságból felmentem az egyik legnagyobb magyar oldalra, listáztam a legnépszerűbb torrenteket, majd az egyik infohash-ét megadva le tudtam tölteni a kliens kezdeményemmel azt a torrentet, amit valaki más töltött fel. És lett egy létező passkeyem az oldalra, amit használhattam volna letöltésre az enyém helyett. Ezt nyilván nem tettem meg, mert nem akarok senkinek kárt okozni, de megírtam az oldal adminjának a kérdéses passkey-t és ők érvénytelenítették azt. Ezért nem ajánlom senkinek, hogy privát trackerekkel használja, de publikusnál mindegy, mert ott eleve nincs passkey, meg a torrentek is publikusak.

Volt viszont még megoldandó rejtély a protokollban. Weben egy wss://abra--2d8fde14.api.nocturnal-narwhal.buzz/08ada5a7a6183aae1e09d831df6748d566095a10/TorrentWebSeeder/StatStream websocketre csatlakozott a kliensem és küldött mindig egy bizonyos mennyiségű adatot, majd mintha csak fogadott volna. Utánaolvastam, hogy ez mi lehet és arra jutottam, hogy a bidirectional streamek - azaz amikor mindkét fél (a szerver és a kliens is) kommunikál egymással pérhuzamosan - nem támogatottak. Akkor minden bizonnyal ez egy szerveroldali stream lesz, amikor kezdetben elküldök egy adag adatot magamról, majd várok, hogy mit küld folyamatosan a szerver. Sok helyen olvastam, hogy itt a websocketek nem támogatottak, hanem pollinggal oldja meg a hivatalos kliens a friss adatok lehúzását bizonyos időközönként. Azóta vagy változott a protokoll, vagy ez is valami egyedi megoldás lehet az oldal részéről. Mindenesetre találtam a protokollban egy nem stream alapú, úgynevezett unary kérést ugyanerre a célra, ami annyit jelent, mint a korábbi összes kérésünk. Ha szükségünk van a statisztikákra, akkor lekérjük, de nem várunk folyamatosan frissítésekre. Amúgy ez a Stat endpoint felelős azért, hogy le tudjuk kérni, hogy áll a torrent letöltése éppen, melyik szeletek vannak letöltve ésatöbbi.

Viszont nem szükséges lekérni a statisztikákat ahhoz, hogy sikeres legyen a lejátszás. Nyilván jó dolog, mert lehet szép GUI-kat rajzolni arról, hogy hogy áll a betöltés, de egy torrent rengeteg szeletből is állhat, a Kodi pedig nem éppen egy grafikonok rajzolására optimalizált megjelenítő. Nyilván meg lehetne oldani, de nem biztos, hogy örülne neki a legtöbb olyan user, akinek a Kodi-ját CPU rendereli. Meg a dugóban állva sem segít sokat, ha látod az előtted és mögötted álló kocsikat, hiszen nem érsz haza előbb ettől, max láthatod, hogy kb mennyi idő kell míg, mire kijutsz belőle.

Node, mindent megfejtettünk, ami csak kellhet a torrent feltöltéstől kezdve a lejátszásig! Már csak a Kodira portolás van hátra. Igen ám, de mi eddig az RpcSession.from_file utasítással töltöttük be a proto fájljainkat. Pl.: session = RpcSession.from_file("proto/torrent-web-seeder.proto")

Ha megnézzük, akkor a from_file a generate_descriptor-t hívja meg, aminek viszont függősége a grpc-tools csomag: [link]. Nyilván ez egy PC-s Python telepítésen nem okoz nagy gondot, hiszen plusz egy csomag a gépen belefér. Node a Kodi futhat ARM alapú médiaboxtól kezdve az Android TV-n át a PC-ig, mindenen. A grpc-tools pedig rendelkezik lefordítandó bináris részekkel, szóval minden architektúrára külön le kéne fordítani és becsomagolni az egészet, hogy be tudja tölteni a proto fájlokat és descriptorrá tudja konvertálni őket a lib. Ezt valahogy szerettem volna elkerülni, hiszen bőven elég nekem ezt egyszer megcsinálni a gépemen és mindenkinek megspórolok egy csomó extra csomagot, meg magamnak a szenvedést. Próbálkoztam a libbe épített protobuf = Protobuf.restore_file("torrent-store.json") megoldást használni, ahogy a readme is írja, de ez már nem működik. Feltételezem, hogy valami változott és eltörte a kompatibilitást.

De hogy őszinte legyek, annyira nem is volt szimpatikus a library, így úgy döntöttem, hogy ez egy jó alap lesz, de megpróbálok előállni valami minimálissal én, ami konkrétan a feladatra készül.

Ehhez nem ártott megismerni a protokollt egy kicsit jobban. Mint kiderült, az alapokban annyira nem tér el a rendes grpc-től, csak a böngészők nem engedélyezik a trailerek beállítását, így kénytelenek voltak a trailert az üzenetbe csomagolni a protokollban. Amúgy létezik base64 és binary formátum a csodából. Ha a content-type application/grpc-web+proto, akkor tudja a másik fél, hogy bináris az adat. Nekem csak erre van szükségem, bár a base64 verzió nagyon hasznos lenne streamek esetében.

Azt is megtudtam, hogy az üzenetek alap esetben 512 byte méretű chunkokként jönnek át, az adathoz tartozó fejléc pedig big-endian, egy unsigned char és egy unsigned int formájában. Ebből a fejlécből megtudható, hogy az adott chunk tömörített-e, illetve, hogy tartalmaz-e trailert, vagy nem.

Szerencsére az implementáció a _protocol.py-ben az eredeti libből nagyon jó és minimalista, így ezt szinte 1:1-ben átemeltem innen (hiszen a licenc engedi): [link]

És a következővel álltam elő: [link]

Kicsit módosítanom kellett pár dolgon, például az _unwrap_message_stream-et teljesen átírtam, de egész jól működik az eredmény. Illetve trailer hiba elvileg HTTP fejlécben is lehetne, ezt pl nem implementáltam, mert a webtor backend nem használja ezt. Nomeg a compression támogatás se az eredeti libben, se az enyémben nem támogatott, szintén csupán azért, mert nincs rá szükség.

Ezt követően pedig valahogy így tudom majd használni a kódból (feltéve, hogy egy osztályban vagyok):

self.grpc_client = GrpcWebClient(self.api_url, self.grpc_headers)

def touch(self, infohash: str) -> None:
touch_request = torrent_store_pb2.TouchRequest()
touch_request.infohash = infohash
# expire is 30 days on the site, so we do that too
# but it isn't respected anymore
touch_request.expire = 60 * 60 * 24 * 30
data = self.grpc_client.wrap_message(touch_request.SerializeToString())
for message_data in self.grpc_client.call_grpc(
"store/TorrentStore/Touch", data
):
touch_response = torrent_store_pb2.TouchReply()
touch_response.ParseFromString(message_data)

A torrent_store_pb2 pedig egy mezei import eredménye, ami simán a proto fájlokból generált py és pyi fájlokból jön. Magát a kérést pedig egyszerűen requests használatával küldöm el a backendnek, miután protobuffal összeraktam és kiexportáltam az üzenetet. A visszakapott nyers választ pedig feldolgozom és visszaadom. Jelen esetben nincs értelmes adat, amivel a touch válaszolna, ezért a metódusnak nincs visszatérési értéke. De nyilván megtehetném, hogy az utolsó sorban visszaadnám a kinyert választ.

Szerencsére protobuf library van kodira is, így csak le kellett implementálnom a fentebb megnevezett elérési utakat, majd össze kellett raknom a hls linket a fájl lista alapján egy eredetileg JavaScriptben meghatározott logika alapján, s amint ez megvolt, élvezhettem Kodiból a streameket.

Ami viszont előny és egyben hátrány is, hogy a böngésző nem támogat mkv streameket direktben, így a projekt ezeket transzkódolva játssza le. Sajnos emiatt Kodiból is a transzkódolt streamre kell várni, ami ugyan oké SD és 720p streameknél, 1080p-t már neccesen kódol le. 1080p feletti tartalmakat pedig egyszerűen nem hagy letölteni. Meg transzkódolt streameknél nem elérhető a teljes timeline és lehet seekelni adott pontra a filmben, hanem várni kell, míg az adott rész az elejétől lekódolásra kerül. De SD és 720p MP4-ekre bőven jó volt nekem tesztelni. Ezt a Sintel.torrent minta fájlt már rongyosra néztem a tesztelések alatt. :DDD

Hát ennyi lenne a szösszenet. Teljes forrást GIthubra nem raknék ki publikusan a fejlesztő iránti tiszteletből, mivel a projekten érezhető, hogy veszteséges, illetve támogatásokból és reklámokból tartaná fent magát. Utóbbiból pedig Kodiból nézve nem részesedik. Éppen ezért nem szeretném, ha nemzetközileg is elterjedne a megoldás és hamar ellehetetlenedne a projektje. Ha valakit érdekel a teljes implementáció, tudok adni Github hozzáférést privát üzenetben, de a legegyszerűbb, ha a StreamShark repó zipből szedi ki a forrást.

Végezetül pedig sosem gondoltam volna, hogy egy ilyen kihívást fog majd rejteni az oldal első pillantásra. Nyilván nem a világ legnehezebb dolga volt megoldani a problémát, de sok kérdés volt a fejemben az elején, mert nem sok infó van még a grpc-webről a neten. Azt meg nem teljesen értem, hogy mi értelme a grpc-web-nek az oldalon, mert az kétségtelen, hogy kisebb az adatforgalma egy bináris adatnak, mint mondjuk egy JSON-nak, viszont itt ugye ott a header minden üzenetben, illetve le kell tölteni pár kb JS-t, ami szintén sok adatforgalom, hogy egyáltalán tudja parsolni a formátumot.

Azt tudom elképzelni, hogy a fejlesztő szeretett volna több platformot támogatni, ezért döntött a protobuf mellett. De mivel még mindig csak a web a hivatalosan támogatott, ezt sem teljesen értem. Feltételezem, hogy inkább azért lett ez használva, mert cutting-edge technológia és menőzni szeretett volna a CV-ben a projekttel, vagy esetlegesen pont a visszaélések lehetőségét akarta csökkenteni, hogy egy kevésbé mainstream megoldást használ.

No mindegy. A lényeg a happy end és hogy ismét sokat tanultam! :)

Köszönöm, hogy elolvastad!

Hozzászólások

(#1) joghurt


joghurt
addikt

Zsír. Élvezet ilyen szakmai írásokat olvasni.

Csak ki ne égj/el ne kallódj!

A tej élet, erő, egészség.

További hozzászólások megtekintése...
Copyright © 2000-2024 PROHARDVER Informatikai Kft.